diff --git a/.gitattributes b/.gitattributes index b18e6f7..104d310 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,3 @@ -src/reynir/resources/ordalisti-*.bin filter=lfs diff=lfs merge=lfs -text -src/reynir/resources/ord.compressed filter=lfs diff=lfs merge=lfs -text - # Set the default line ending behavior to auto * text=auto diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 7b3eccd..4adc604 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -2,9 +2,10 @@ name: tests on: push: - branches: [ "*" ] + # Note: "**" is required to match branch names containing '/' + branches: [ "**" ] pull_request: - branches: [ "*" ] + branches: [ "**" ] jobs: build: @@ -13,25 +14,32 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy-3.11"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.11"] + # The Windows and macOS wheels are built from the same sources, + # so test at least one Python version on each of those platforms + include: + - os: windows-latest + python-version: "3.13" + - os: macos-latest + python-version: "3.13" steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv + # Exact tag: setup-uv has no floating 'v8' major tag (unlike v6/v7) + uses: astral-sh/setup-uv@v8.3.2 with: python-version: ${{ matrix.python-version }} - name: Install GreynirEngine - run: | - python -m pip install uv - uv pip install --system wheel setuptools pytest ruff - uv pip install --system -e . + run: uv sync --locked - name: Lint with ruff - run: | - ruff check src/reynir + run: uv run ruff check src/reynir + - name: Type check with mypy + # mypy is not installed on PyPy (see [dependency-groups] in pyproject.toml) + if: ${{ !startsWith(matrix.python-version, 'pypy') }} + run: uv run mypy src/reynir - name: Test with pytest - run: | - python -m pytest + run: uv run pytest - name: Slack notification uses: 8398a7/action-slack@v3 with: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 2551879..f002230 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -16,26 +16,25 @@ jobs: steps: - uses: actions/checkout@v4 - with: - lfs: true - uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install cibuildwheel - run: python -m pip install --upgrade pip wheel setuptools cibuildwheel + # Pinned so that releases only change toolchain when we choose to + run: python -m pip install --upgrade pip wheel setuptools cibuildwheel==4.1.0 - name: Build wheels run: python -m cibuildwheel --output-dir wheelhouse # Options (https://cibuildwheel.readthedocs.io/en/stable/options/) env: - # Build abi3 wheel for CPython 3.9+ (one wheel for all versions) + # Build abi3 wheel for CPython 3.10+ (one wheel for all versions) # Build version-specific wheel for PyPy (which doesn't support abi3) - CIBW_BUILD: cp39-* pp311-* + CIBW_BUILD: cp310-* pp311-* CIBW_SKIP: "*musllinux*" CIBW_ENABLE: pypy - CIBW_PROJECT_REQUIRES_PYTHON: ">=3.9" + CIBW_PROJECT_REQUIRES_PYTHON: ">=3.10" CIBW_BEFORE_BUILD_MACOS: python3 -m pip install --upgrade setuptools wheel cffi CIBW_ARCHS_MACOS: "x86_64 arm64" CIBW_ARCHS_WINDOWS: "AMD64" @@ -54,8 +53,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - lfs: true - uses: actions/setup-python@v5 with: diff --git a/.gitignore b/.gitignore index 07f2716..533b759 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,8 @@ test/test_corpus/handpsd/annotaldLog.txt src/reynir/_eparser.cpp *.bin !ordalisti-*.dawg.bin +*.bin.lock +*.bin.tmp.* *.sublime-project *.sublime-workspace @@ -93,7 +95,6 @@ p37/ pypy* # uv stuff -uv.lock .python-version # Installer logs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e828fc4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +GreynirEngine (PyPI package name: `reynir`, imported as `reynir`) is an NLP engine +for parsing Icelandic text into sentence trees. It combines a hand-written +context-free grammar for Icelandic with a C++ Earley-Scott parser core, wrapped +via CFFI. Source lives in `src/reynir/`. Supports CPython 3.10+ and PyPy 3.11+. + +## Commands + +The project uses [uv](https://docs.astral.sh/uv/) with a committed `uv.lock`; +dev dependencies live in `[dependency-groups]` in `pyproject.toml`. + +```sh +# Set up the dev environment (installs dev deps from uv.lock and +# compiles the C++ parser extension via CFFI) +uv sync + +# Run all tests +uv run pytest + +# Run a single test file / single test +uv run pytest test/test_parse.py +uv run pytest test/test_parse.py::test_long_parse + +# Lint (CI runs this) +uv run ruff check src/reynir + +# Type check (config in pyproject.toml [tool.mypy]; runs as a CI gate +# on non-PyPy jobs, so it must stay clean) +uv run mypy src/reynir +``` + +Note: after changing the C++ sources (`eparser.cpp`, `eparser.h`) or +`eparser_build.py`, force a rebuild of the `_eparser` CFFI extension with +`uv sync --reinstall-package reynir` (the extension is compiled at install +time; see `setup.py`, which exists only for the `cffi_modules` hook — all +other metadata is in `pyproject.toml`). + +## Architecture + +The parsing pipeline, orchestrated by the `Greynir` class in `reynir.py` +(`parse()`, `parse_single()`, `submit()`): + +1. **Tokenization** — the external `tokenizer` package splits text into tokens; + `bintokenizer.py` is a dictionary-aware layer that annotates each token with + its possible meanings from BÍN (the Database of Icelandic Morphology), via + `bindb.py`, which wraps the external `islenska`/BinPackage vocabulary database. + +2. **Grammar** — `Greynir.grammar` is a ~7,500-line CFG for Icelandic in extended + BNF, read by `grammar.py`. It is compiled to a binary form + (`Greynir.grammar.bin`, gitignored) automatically at runtime whenever the + source grammar file is newer. The binary is written atomically (temp file + + `os.replace`), and regeneration is serialized across processes with a + `filelock` lock file next to the binary (`Greynir.grammar.bin.lock`); + acquisition times out with a `GrammarError` instead of hanging. The lock + is only taken when the binary is missing or stale — the warm path is + lock-free, guarded only by an in-process `threading.Lock` protecting the + class-level grammar caches. `glock.py` is a deprecated compatibility shim + over `filelock`, no longer used internally. + +3. **Parsing** — `fastparser.py` (`Fast_Parser`) wraps the C++ Earley-Scott + parser (`eparser.cpp`) through the `_eparser` CFFI extension, producing a + parse forest (SPPF) of all possible parses. `binparser.py` (`BIN_Parser`) + maps BÍN token meanings to grammar terminals (e.g. `no_et_nf_kvk` = noun, + singular, nominative, feminine); terminal matching logic lives here. + `baseparser.py` holds a pure-Python reference parser. `incparser.py` + handles incremental parsing of token streams by paragraph/sentence. + +4. **Reduction** — `reducer.py` scores the parse forest and reduces it to the + single most likely tree, using preferences from `config/Prefs.conf`, + production priorities and `$score()` pragmas in the grammar, and + verb-preposition matching driven by `config/Verbs.conf`. + +5. **Output/API** — `simpletree.py` provides `SimpleTree`, the simplified tree + API users interact with (`.tree.S.IP.NP_SUBJ`, `.lemmas`, `.nouns`, `.flat`, + etc.). `matcher.py` implements pattern matching over these trees. + `nounphrase.py` provides the `NounPhrase` class with case inflection through + `__format__` (e.g. `f"{np:þgf}"`). `lemmatize.py`, `verbframe.py` and + `ifdtagger.py` provide lemmatization, verb frames and IFD-style POS tagging. + +Supporting data: + +- `src/reynir/config/*.conf` — linguistic configuration (verbs with their + argument cases and prepositions, phrase preferences, name preferences, + adjective/noun predicates, etc.), loaded by `settings.py`. +- `src/reynir/resources/ord*.csv` — vocabulary additions layered on top of BÍN. + +The public API is defined by the exports in `src/reynir/__init__.py`. +`Reynir` is retained as a compatibility alias for `Greynir`. + +## Conventions + +- Grammar terminal and category names are Icelandic abbreviations (`no`=noun, + `so`=verb, `nf`/`þf`/`þgf`/`ef`=cases, `et`/`ft`=number, `kk`/`kvk`/`hk`=gender); + these appear throughout the code, tests and grammar files. +- Ruff line length is 88; `E731` (lambda assignment) is ignored. +- CI (`.github/workflows/python-package.yml`) runs ruff + pytest via + `uv sync --locked` on Python 3.10–3.14 and PyPy 3.11 on Linux, plus one + job each on Windows and macOS. Wheels are built on tag push via a pinned + cibuildwheel (`cp310` abi3 wheel for CPython, version-specific for PyPy). +- The `old/` and `build/` directories contain legacy/build artifacts — do not + edit code there. diff --git a/MANIFEST.in b/MANIFEST.in index 55b35f2..20540f9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,8 @@ prune src/reynir/.mypy_cache prune test/test_corpus include src/reynir/Greynir.grammar exclude src/reynir/Greynir.*.bin +exclude src/reynir/*.bin.lock +exclude src/reynir/*.bin.tmp.* include src/reynir/eparser.h exclude src/reynir/_eparser.cpp include src/reynir/config/*.conf diff --git a/README.md b/README.md index 53a6f6f..0615445 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Python 3.9](https://img.shields.io/badge/python-3.9-blue.svg)](https://www.python.org/downloads/release/python-3817/) +[![Python 3.10](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/) ![Release](https://shields.io/github/v/release/mideind/GreynirEngine?display_name=tag) ![PyPI](https://img.shields.io/pypi/v/reynir) [![Build](https://github.com/mideind/GreynirEngine/actions/workflows/python-package.yml/badge.svg)]() @@ -12,7 +12,7 @@ ## Overview -Greynir is a Python 3 (>=3.9) package, +Greynir is a Python 3 (>=3.10) package, published by [Miðeind ehf.](https://mideind.is), for **working with Icelandic natural language text**. Greynir can parse text into **sentence trees**, find **lemmas**, @@ -147,7 +147,7 @@ and each token annotated with its lemma and POS tag (`no`=noun, `so`=verb): ## Prerequisites -This package runs on CPython 3.9 or newer, and on PyPy 3.11 or newer. +This package runs on CPython 3.10 or newer, and on PyPy 3.11 or newer. To find out which version of Python you have, enter: @@ -195,14 +195,17 @@ The package source code is in `GreynirEngine/src/reynir`. ## Tests -To run the built-in tests, install [pytest](https://docs.pytest.org/en/latest), -`cd` to your `GreynirEngine` subdirectory (and optionally activate your -virtualenv), then run: +To run the built-in tests, `cd` to your `GreynirEngine` subdirectory and, +using [uv](https://docs.astral.sh/uv/), run: ````sh -python -m pytest +uv sync +uv run pytest ```` +Alternatively, install [pytest](https://docs.pytest.org/en/latest) into +your virtualenv and run `python -m pytest`. + ## Evaluation A parsing test pipeline for different parsing schemas, including the Greynir schema, @@ -219,22 +222,13 @@ as well as important information about ## Troubleshooting -If parsing seems to hang, it is possible that a lock file that GreynirEngine -uses has been left locked. This can happen if a Python process that uses -GreynirEngine is killed abruptly. The solution is to delete the lock file -and try again: - -On Linux and macOS: - -````sh -rm /tmp/greynir-grammar # May require sudo privileges -```` - -On Windows: - -````cmd -del %TEMP%\greynir-grammar -```` +GreynirEngine uses a lock file, located alongside the binary grammar file +within the package directory (`reynir/Greynir.grammar.bin.lock`), to +serialize the (re)generation of the binary grammar between processes. +If a process gets stuck holding the lock, parser initialization fails +after a timeout with an error message identifying the lock file. +Terminate the process holding the lock, or - if no such process exists - +delete the lock file named in the error message, and try again. ## Copyright and licensing diff --git a/doc/cpp-matching-design.md b/doc/cpp-matching-design.md new file mode 100644 index 0000000..8846bcf --- /dev/null +++ b/doc/cpp-matching-design.md @@ -0,0 +1,219 @@ +# Design sketch: moving token/terminal matching into the C++ core + +Status: IMPLEMENTED (Phases 0-2) — 2026-07-14 + +## 0. Results (added after implementation) + +Phase 0 instrumentation over a 242-sentence corpus: 1.74M match queries, +of which 69.1% classify as natively answerable (55.9% lemma literals). + +Implemented as designed, with one delivery difference: token matching +data is fetched via a `MeaningsFunc` callback once per Earley column +(mirroring the existing `alloc_func` handshake) and cached per token key. +Additionally, unknown-word tokens (no BÍN meanings) are handled natively +via a per-terminal constant flag (`TF_MATCHES_EMPTY`). + +Measured (CPython 3.13, typical 10-25 token sentences, cold matching +cache): **2.2x faster** overall parse time (1.40s -> 0.65s for the +8-sentence benchmark set); ~89% of Python matching callbacks eliminated. +The full test suite runs ~20% faster. PyPy 3.11: ~20% faster cold. +Warm-cache and long-sentence workloads: unchanged, as predicted. + +Verification: query-level parity mode (`GREYNIR_MATCHING_PARITY=1`, +where every native decision is compared against the Python matcher) +reports zero discrepancies over the test corpus; the full test suite +passes with the fast path enabled by default. The fast path can be +disabled with `GREYNIR_DISABLE_CPP_MATCHING=1`, via the +`Fast_Parser._USE_CPP_MATCHING` class attribute, and is automatically +disabled for parser subclasses that override token wrapping +(the GreynirCorrect compatibility gate). + +Incidental finding during verification: reduction of exact score ties +is not stable across repeated parses of the same sentence (independent +of this work - it reproduces with pure Python matching); see +test_native_matching.py for why forests, not reduced trees, are +compared there. + +## 1. Motivation and measured cost + +Profiling (CPython 3.13, warm grammar, fresh matching cache) shows that for +typical 10-25 token sentences, parse time divides roughly as: + +| Component | Share | +|------------------------------------------------------------|-------| +| C++ Earley-Scott core (`earleyParse` self-time) | ~42% | +| Python matching callbacks (`matching_func` → `BIN_Token.matches`) | ~40% | +| Reducer (`reducer.py`) | ~9% | +| Forest conversion (`Node.from_c_node`) | ~4.5% | +| Tokenization and misc | ~4% | + +The callback share is first-encounter cost: the per-token match cache +(`alloc_func`/`matching_cache`) already eliminates repeat queries, giving a +2.6x speedup on cache-warm text. The target of this design is the fresh-text +path: eliminate most C→Python callbacks entirely, by making the C++ core +able to answer the common matching queries itself. + +A secondary motivation: C→Python CFFI *callbacks* are a structural weak +spot on PyPy (measured ~50% slower than CPython 3.13 on typical text). +Removing the callback from the hot path benefits both interpreters. + +## 2. Why this is feasible: the machinery is already half-built + +Three observations from `binparser.py`: + +1. **The terminal side is already bit-encoded.** `VariantHandler` maps each + terminal's variants to `_vbits`/`_fbits` (39 distinct `VBIT_*` bits — fits + in a `uint64_t` with headroom), and the workhorse predicates + (`fbits_match`, `fbits_match_mask`) are pure bitmask tests. + +2. **The token-meaning side maps to the same bit space.** `get_fbits()` + converts a BÍN `beyging` inflection string into the same fbits, already + cached per distinct `beyging` string. + +3. **81% of all terminals are literals.** Of the 6,012 terminals in + `Greynir.grammar`, 4,893 are literal terminals (`'lemma:cat'_variants` or + `"form:cat"`), whose match semantics are: interned-string identity on the + lemma or word form, an optional category filter, fbits, and (for + single-quoted verb literals) an MM exclusion. All of this reduces to + integer compares. The remaining ~1,100 category terminals are dominated + by `lo` (378) and `so` (326); most non-verb category matchers + (`matcher_default`, `abfn`, `pfn`, and the simple paths of `no`/`lo`) + are already just fbits tests plus small special cases. + +The genuinely complex logic is concentrated in a few places: verb argument +frames and subject cases (`verb_matches`, driven by Verbs.conf), adjective +subject cases (`matcher_lo` with `_ADJ_ARGUMENTS`), prepositions +(`matcher_fs`), person/street/proper-name matching, ending-constraint +variants (`_x.../_z...`), and the unknown-word fallbacks. These stay in +Python behind an escape hatch — indefinitely, if we like. + +## 3. Design + +### 3.1 Terminal spec table (built in Python, passed to C++ once) + +Python already parses terminal names into structured form (`VariantHandler`), +so the classification is done at grammar-load time in Python and handed to +the C++ parser as a flat array — **no change to the binary grammar file**. + +```c +enum TerminalKind : uint8_t { + T_PYTHON = 0, // escape hatch: call matching_func as today + T_LIT_FORM, // "form" - token text identity (case-folded id) + T_LIT_LEMMA, // 'lemma:cat'_variants - lemma id + cat + fbits + T_LIT_LEMMA_MM, // as above, verb lemma with the MM exclusion rule + T_CAT, // single category + fbits/mask test + T_CAT_NOUN, // category in {kk, kvk, hk} + noun special cases +}; + +struct TerminalSpec { + uint8_t nKind; + uint8_t nCatId; // small enum of ordfl values; CAT_NONE if unused + uint16_t nFlags; // TF_HAS_GR, TF_ABBREV, TF_NO_INFO_OK, ... + uint32_t nLitId; // interned literal id (lemma or form), or 0 + uint64_t nFbits; // required feature bits + uint64_t nFbitMask; // comparison mask (e.g. cases-only for abfn) +}; +``` + +A new entry point `setTerminalSpecs(Parser*, const TerminalSpec*, UINT n)` +(or an extra argument to `newParser`) installs the table. Any terminal whose +semantics we have not (yet) encoded is simply `T_PYTHON`. + +### 3.2 Token meaning records (built lazily in Python, once per token key) + +For each distinct token (keyed exactly like today's `matching_cache`), +Python builds a compact meaning array once: + +```c +struct MeaningRec { + uint8_t nCatId; // ordfl as enum + uint8_t nFlags; // MF_NO_BEYGING ('-'), MF_NAME_FL (fl in nafn/ætt), ... + uint32_t nLemmaId; // interned id, 0 if lemma not in grammar lexicon + uint32_t nFormId; // interned id of the (case-folded) word form + uint64_t nFbits; // get_fbits(m.beyging) +}; +``` + +Interning: the id space is defined by the grammar's literal lexicon (the +~4,900 distinct lemma/form strings appearing in literal terminals, interned +at grammar load). A meaning whose lemma/form is not in that lexicon gets +id 0 and can never match a literal terminal — one dict lookup per meaning +at encoding time, integer compares forever after. + +Delivery to C++ mirrors the existing cache handshake: a new callback + +```c +typedef const BYTE* (*MeaningsFunc)(UINT nHandle, UINT nToken, UINT* pnCount); +``` + +which Python answers from a per-token-key cache (like `alloc_cache` today). +Non-WORD tokens (numbers, dates, persons, entities, punctuation...) return +a sentinel marking the token Python-only in this phase. + +### 3.3 Matching in `Column::matches()` + +``` +if terminal spec is T_PYTHON, or token is Python-only: + fall back to m_pMatchingFunc(...) // exactly today's behavior +else: + for each MeaningRec of the token: + switch on spec.nKind: integer/bit compares only + cache the result byte as today +``` + +The per-column byte cache and the cross-sentence buffer cache are unchanged; +warm-path behavior is identical. The only change is who computes a cache +miss for simple terminals. + +### 3.4 What stays in Python (Phase 1) + +- All `so_*` category terminals (verb frames, Verbs.conf subjects/arguments) +- `lo` terminals with subject-case variants (`_sþf`/`_sþgf`/`_sef`) +- Ending-constraint variants (`_x...`, `_z...`) +- `fs`, `person`, `gata`, `sérnafn`, `eo`, `stt` and other special matchers +- All unknown-word tokens (no BÍN meanings) and all non-WORD token kinds + +**Compatibility requirement**: `verb_subject_matches` and +`verb_is_strictly_impersonal` are overridden by GreynirCorrect +(`reynir_correct.errfinder`), and any subclass may override matching +behavior. The fast path must therefore be gated: a class-level flag on +`BIN_Parser` (e.g. `_ALLOW_CPP_MATCHING`), turned off automatically when a +subclass overrides any matcher hook. Derived packages then keep bit-exact +behavior with zero changes, at today's speed. + +### 3.5 Parity harness and rollout + +- **Phase 0 — instrumentation**: count callback volume per matcher function + on a realistic corpus, to rank phases by actual query volume (not + terminal count). +- **Phase 1 — literal terminals** (81% of the terminal set; the profile's + 808k calls to `BIN_LiteralTerminal.matches` are pure string compares + crossing the boundary today). +- **Phase 2 — fbits-only category terminals** (`matcher_default`, `abfn`, + `pfn`, simple `no`/`lo`/`töl` paths). +- **Phase 3 (optional) — verb frames**: encode per-verb argument/subject + sets (Verbs.conf) as id-keyed bitsets in C++. Largest complexity; + do only if Phase 0 data shows verbs dominate remaining callbacks. + Note that `verb_matches` is already `lru_cache`d in Python, which + absorbs some of the repeat cost. +- **Parity mode**: a debug flag under which C++ computes its answer AND + calls the Python matcher, asserting equality on every query; run the full + test suite and a large corpus in this mode before each phase ships. + Divergence in any query → the terminal is demoted to `T_PYTHON`. + +### 3.6 Expected win + +Fresh-text typical sentences: most of the ~40% callback share disappears +(bounded by Phase coverage of query volume — measure in Phase 0); estimated +overall parse speedup of 25-35% on novel text, larger on PyPy. Cache-warm +and very-long-sentence workloads: little change (already cache/C++-bound). + +## 4. Alternatives considered and rejected + +- **Eager full-row precomputation in Python** (fill all 6K terminal bytes + per token up front): does strictly more work than lazy queries; most of + a row is never queried. +- **Bitset vectorization in Python (numpy)**: adds a dependency and still + pays per-query Python call overhead; the boundary is the problem. +- **Moving the reducer to C++**: only ~9% of typical-sentence time; poor + effort/reward compared to the matching boundary. diff --git a/pyproject.toml b/pyproject.toml index 0c3925f..8529fbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,22 @@ [build-system] -requires = ["setuptools>=61.0", "cffi>=1.15.1"] +requires = ["setuptools>=77.0.3", "cffi>=1.15.1"] build-backend = "setuptools.build_meta" [project] name = "reynir" -version = "3.6.2" +version = "3.7.0" description = "A natural language parser for Icelandic" authors = [{ name = "Miðeind ehf.", email = "mideind@mideind.is" }] maintainers = [{ name = "Miðeind ehf.", email = "mideind@mideind.is" }] readme = { file = "README.md", content-type = "text/markdown" } -license = { text = "MIT License" } +license = "MIT" +license-files = ["LICENSE.txt"] keywords = ["nlp", "parser", "icelandic"] classifiers = [ # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: Science/Research", - "License :: OSI Approved :: MIT License", "Operating System :: Unix", "Operating System :: POSIX", "Operating System :: Microsoft :: Windows", @@ -24,7 +24,6 @@ classifiers = [ "Natural Language :: Icelandic", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -36,11 +35,12 @@ classifiers = [ "Topic :: Utilities", "Topic :: Text Processing :: Linguistic", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "cffi>=1.17.1", - "tokenizer>=3.6.0", + "tokenizer>=3.6.3", "islenska>=1.3.2", + "filelock>=3.9.0", "typing_extensions", ] @@ -50,9 +50,17 @@ Homepage = "https://greynir.is" Documentation = "https://greynir.is/doc/" Issues = "https://github.com/mideind/GreynirEngine/issues" -[project.optional-dependencies] -# dev dependencies -dev = ["pytest", "setuptools"] +[dependency-groups] +# Development dependencies: uv installs these automatically with 'uv sync'; +# with pip (>=25.1), use 'pip install --group dev'. +# mypy is excluded on PyPy, where its native dependencies don't build. +dev = [ + "pytest", + "ruff", + "mypy; implementation_name != 'pypy'", + "types-cffi", + "setuptools", +] # *** Configuration of tools *** @@ -77,7 +85,6 @@ ignore = [ "E731", # 'E731: Do not assign a lambda expression, use a def' ] -[tool.isort] -# This forces these imports to placed at the top -known_future_library = ["__future__", "typing", "typing_extensions"] -line_length = 88 +[tool.mypy] +python_version = "3.10" +exclude = ['doc/conf\.py'] diff --git a/src/reynir/bindb.py b/src/reynir/bindb.py index 68639e0..1266ff8 100644 --- a/src/reynir/bindb.py +++ b/src/reynir/bindb.py @@ -131,8 +131,8 @@ def lookup_name_gender(self, name: str, preferred_case: str = "nf") -> str: # The first name was not found: check whether the full name is # in the static phrases - m = StaticPhrases.lookup(name) - if m is not None: - if m.fl in PERSON_NAME_FL: - return m.ordfl + sp = StaticPhrases.lookup(name) + if sp is not None: + if sp.fl in PERSON_NAME_FL: + return sp.ordfl return "hk" # Unknown gender diff --git a/src/reynir/binparser.py b/src/reynir/binparser.py index dbd8de1..ed73351 100644 --- a/src/reynir/binparser.py +++ b/src/reynir/binparser.py @@ -62,6 +62,7 @@ class used for parsing text. import os import time import re +import struct from datetime import datetime from functools import reduce, lru_cache @@ -2537,3 +2538,254 @@ def describe_token( else: d["v"] = t.val return d + + +# ---------------------------------------------------------------------- +# Native (C++) token/terminal matching support +# +# The functions below build a matching table that enables the C++ +# Earley parser core (eparser.cpp) to decide most token/terminal match +# queries natively, without calling back into Python. Terminals whose +# matching semantics are not natively encodable (verbs, prepositions, +# proper names, ending constraints, etc.) are marked T_PYTHON and +# continue to be matched through the regular Python callback. +# The binary record layouts here mirror, byte for byte, the TerminalSpec, +# MeaningRec and TokenRec structures declared in eparser.h. + +# Terminal spec kinds (see eparser.h) +_T_PYTHON = 0 +_T_TEXT = 1 +_T_TEXT_CAT = 2 +_T_LEMMA = 3 +_T_CAT_DEFAULT = 4 +_T_CAT_MASK = 5 +_T_CAT_FIRST = 6 +_T_CAT_NOUN = 7 +_T_CAT_LO = 8 +_T_CAT_AO = 9 + +# Terminal spec flags +_TF_ABBREV = 0x0100 +_TF_MM_EXCLUDE = 0x0200 +_TF_MATCHES_EMPTY = 0x0400 + +# Token header flags +_TKF_FAST = 0x00010000 +_TKF_EMPTY_WORD = 0x00020000 + +# Meaning record flags +_MF_NO_BEYGING = 1 +_MF_NAME_FL = 2 +_MF_IS_NOUN = 4 +_MF_IS_LO_SO = 8 + +# Terminal categories whose matchers cannot be encoded natively +_PYTHON_FIRSTS = frozenset(("so", "eo", "fs", "person", "gata", "sérnafn", "stt")) + +_STRUCT_SPEC = struct.Struct(" None: + self.specs = specs + self.num_specs = num_specs + self.masks = masks + # Interned literal lemma/form strings from the grammar + self.lexicon = lexicon + # Interned word category (ordfl/terminal first part) strings + self.catmap = catmap + + +def _make_terminal_spec( + t: Terminal, lexicon: Dict[str, int], catmap: Dict[str, int] +) -> bytes: + """Classify a terminal and encode its native matching spec. + Any terminal that is not fully understood is conservatively + classified as T_PYTHON.""" + + def intern_str(s: str) -> int: + ix = lexicon.get(s) + if ix is None: + ix = lexicon[s] = len(lexicon) + 1 + return ix + + def intern_cat(s: str) -> int: + ix = catmap.get(s) + if ix is None: + ix = catmap[s] = len(catmap) + 1 + return ix + + kind = _T_PYTHON + flags = 0 + cat = 0 + lit = 0 + fbits = 0 + mask = 0 + # Note: exact type checks, not isinstance(); terminal subclasses + # (such as SequenceTerminal, or terminals from derived packages) + # may have different matching semantics and stay on the Python path + if type(t) is BIN_LiteralTerminal: + first = t._first + if t._strong: + if t._cat is None: + # "form": pure token text identity (shortcut_match) + kind = _T_TEXT + lit = intern_str(first) + elif t._match_cat != "punctuation": + # "form:cat": token text identity plus meaning category + kind = _T_TEXT_CAT + lit = intern_str(first) + cat = intern_cat(t._match_cat or "") + else: + if t._match_cat != "punctuation" and not t.has_any_vbits( + BIN_Token.VBIT_ENDING | BIN_Token.VBIT_SCASES + ): + # 'lemma:cat'_variants: lemma identity, optional category, + # default matcher feature logic + kind = _T_LEMMA + lit = intern_str(first) + cat = intern_cat(t._match_cat) if t._match_cat else 0 + fbits = t._fbits + if ( + t._match_cat == "so" + and not first[:1].isupper() + and not t.is_mm + ): + # matcher_lemma_literal(): verb lemma literals don't + # match middle voice meanings unless _mm is specified + flags |= _TF_MM_EXCLUDE + elif type(t) is BIN_Terminal: + first = t.first + if first not in _PYTHON_FIRSTS and not t.has_any_vbits( + BIN_Token.VBIT_ENDING | BIN_Token.VBIT_SCASES + ): + fbits = t._fbits + if first == "no": + kind = _T_CAT_NOUN + if t.is_abbrev: + flags |= _TF_ABBREV + if t.has_vbits( + BIN_Token.VBIT_ET | BIN_Token.VBIT_HK + ) and not t.has_vbits(BIN_Token.VBIT_GR): + # This terminal matches unknown words, + # cf. the fallback in BIN_Token.matches_WORD() + flags |= _TF_MATCHES_EMPTY + elif first == "lo": + kind = _T_CAT_LO + cat = intern_cat("lo") + elif first == "ao": + kind = _T_CAT_AO + cat = intern_cat("ao") + elif first == "abfn": + # Check the case only (matcher_abfn) + kind = _T_CAT_MASK + cat = intern_cat("abfn") + mask = BIN_Token.VBIT_CASES + elif first == "pfn": + # Check the case and number only (matcher_pfn) + kind = _T_CAT_MASK + cat = intern_cat("pfn") + mask = BIN_Token.VBIT_CASES | BIN_Token.VBIT_NUMBER + elif first == "töl": + # Category check only (matcher_töl) + kind = _T_CAT_FIRST + cat = intern_cat("töl") + else: + # All remaining categories use matcher_default() + kind = _T_CAT_DEFAULT + cat = intern_cat(first) + return _STRUCT_SPEC.pack(kind | flags, cat, lit, 0, fbits, mask) + + +def build_matching_table(grammar: "BIN_Grammar") -> MatchingTable: + """Build (and cache on the grammar object) the native matching table""" + table: Optional[MatchingTable] = getattr(grammar, "_matching_table", None) + if table is not None: + return table + lexicon: Dict[str, int] = {} + catmap: Dict[str, int] = {} + # Pre-intern all known BÍN categories and their mapped forms, so that + # common meaning categories receive ids regardless of terminal order + for c in BIN_Token.KIND: + catmap.setdefault(c, len(catmap) + 1) + for c in BIN_Token.KIND.values(): + catmap.setdefault(c, len(catmap) + 1) + num = grammar.num_terminals + empty = _STRUCT_SPEC.pack(_T_PYTHON, 0, 0, 0, 0, 0) + specs: List[bytes] = [empty] * (num + 1) + for t in grammar.terminals.values(): + if 1 <= t.index <= num: + specs[t.index] = _make_terminal_spec(t, lexicon, catmap) + masks = _STRUCT_MASKS.pack( + BIN_Token.VBIT_GENDERS, + BIN_Token.VBIT_NUMBER, + BIN_Token.VBIT_ET, + BIN_Token.VBIT_GR, + BIN_Token.VBIT_MM, + ) + table = MatchingTable(b"".join(specs), num + 1, masks, lexicon, catmap) + setattr(grammar, "_matching_table", table) + return table + + +def encode_token_matching_data(token: BIN_Token, table: MatchingTable) -> bytes: + """Encode a token, along with its BÍN meanings, into the packed + native matching format (a TokenRec header followed by MeaningRec + entries; see eparser.h)""" + lexicon = table.lexicon + catmap = table.catmap + form_id = lexicon.get(token.t1_lower, 0) + count_flags = 0 + parts: List[bytes] = [] + if token.t0 == TOK.WORD: + if token.t2: + genders_map = BIN_Token.GENDERS_MAP + genders_set = BIN_Token.GENDERS_SET + kind_map = BIN_Token.KIND + get_fbits = BIN_Token.get_fbits + pack = _STRUCT_REC.pack + for m in token.meanings: + ordfl = m.ordfl + mf = 0 + if m.beyging == "-": + mf |= _MF_NO_BEYGING + if m.fl == "nafn" or m.fl == "ætt": + mf |= _MF_NAME_FL + if ordfl in genders_set: + mf |= _MF_IS_NOUN + elif ordfl == "lo" or ordfl == "so": + mf |= _MF_IS_LO_SO + # For nouns, the gender is coded in ordfl; append it to + # the beyging field so that the corresponding feature bit + # is included (mirrors matcher_default/matcher_no) + fb = get_fbits(m.beyging + genders_map.get(ordfl, "")) + parts.append( + pack( + lexicon.get(m.stofn, 0), + catmap.get(ordfl, 0), + catmap.get(kind_map.get(ordfl, ordfl), 0), + mf, + fb, + ) + ) + count_flags = _TKF_FAST | len(parts) + else: + # Word token with no BÍN meanings (unknown word) + count_flags = _TKF_EMPTY_WORD + return _STRUCT_HDR.pack(form_id, count_flags) + b"".join(parts) diff --git a/src/reynir/eparser.cpp b/src/reynir/eparser.cpp index 09a2d2e..83d0d62 100644 --- a/src/reynir/eparser.cpp +++ b/src/reynir/eparser.cpp @@ -174,11 +174,13 @@ friend class AllocReporter; MatchingFunc m_pMatchingFunc; // Pointer to the token/terminal matching function BYTE* m_abCache; // Matching cache, a true/false flag for every terminal in the grammar BOOL m_bNeedsRelease; // Does the matching cache need to be explicitly released? + const BYTE* m_pTokenRec; // Packed matching data for this column's token, or NULL HashBin m_aHash[HASH_BINS]; // The hash bin array UINT m_nEnumBin; // Round robin used during enumeration of states static AllocCounter ac; static AllocCounter acMatches; + static AllocCounter acNativeMatches; protected: @@ -388,7 +390,8 @@ static void freeStates(StateChunk*& pChunkHead) } // Counter of states that are allocated and then immediately discarded -static UINT nDiscardedStates = 0; +// (atomic for the same reason as the AllocCounter members) +static std::atomic nDiscardedStates(0); AllocCounter State::ac; @@ -455,6 +458,10 @@ Node* State::getResult(INT iStartNt) const AllocCounter Column::ac; AllocCounter Column::acMatches; +AllocCounter Column::acNativeMatches; + +// Counter of native matching tables allocated by setMatchingTable() +static AllocCounter acMatchingTables; Column::Column(Parser* pParser, UINT nToken) : m_pParser(pParser), @@ -462,6 +469,7 @@ Column::Column(Parser* pParser, UINT nToken) m_pNtStates(NULL), m_pMatchingFunc(pParser->getMatchingFunc()), m_abCache(NULL), m_bNeedsRelease(false), + m_pTokenRec(NULL), m_nEnumBin(0) { Column::ac++; @@ -505,8 +513,12 @@ void Column::startParse(UINT nHandle) ASSERT(this->m_abCache == NULL); // Ask the parser to create a matching cache for us // (or eventually re-use a previous one) - if (this->m_nToken != (UINT)-1) + if (this->m_nToken != (UINT)-1) { this->m_abCache = this->m_pParser->allocCache(nHandle, this->m_nToken, &this->m_bNeedsRelease); + // Fetch the packed matching data for this column's token, + // if a native matching table has been installed + this->m_pTokenRec = this->m_pParser->fetchTokenRec(nHandle, this->m_nToken); + } } void Column::stopParse(void) @@ -516,6 +528,7 @@ void Column::stopParse(void) // The matching cache needs to be released this->m_pParser->releaseCache(this->m_abCache); this->m_abCache = NULL; + this->m_pTokenRec = NULL; } BOOL Column::addState(State* p) @@ -606,7 +619,31 @@ BOOL Column::matches(UINT nHandle, UINT nTerminal) const // We already have a cached result for this terminal return (BOOL)(this->m_abCache[nTerminal] & 0x01); // Not cached: obtain a result and store it in the cache - BOOL b = this->m_pMatchingFunc(nHandle, this->m_nToken, nTerminal) != 0; + BOOL b; + const TerminalSpec* pSpec = this->m_pParser->getSpec(nTerminal); + const UINT nKind = pSpec ? (pSpec->nKindFlags & 0xFFu) : T_PYTHON; + const BYTE* pRec = this->m_pTokenRec; + // The native fast path can decide T_TEXT terminals for any token, + // and all non-T_PYTHON terminals for word tokens (TKF_FAST for + // tokens with BÍN meanings, TKF_EMPTY_WORD for unknown words) + const BOOL bNative = nKind != T_PYTHON && pRec != NULL && + (nKind == T_TEXT || + (((const TokenRec*)pRec)->nCountFlags & (TKF_FAST | TKF_EMPTY_WORD)) != 0); + if (bNative) { + b = Parser::evalMatch(pSpec, pRec, this->m_pParser->getMasks()); + Column::acNativeMatches++; // Count native match evaluations + if (this->m_pParser->parityMode()) { + // Parity mode: also obtain the Python result; count any + // discrepancy and return the Python (canonical) answer + BOOL bPy = this->m_pMatchingFunc(nHandle, this->m_nToken, nTerminal) != 0; + if ((bPy != 0) != (b != 0)) + this->m_pParser->countParityMismatch(); + b = bPy; + } + } + else { + b = this->m_pMatchingFunc(nHandle, this->m_nToken, nTerminal) != 0; + } Column::acMatches++; // Count calls to the matching function // Mark our cache this->m_abCache[nTerminal] = b ? (BYTE)0x81 : (BYTE)0x80; @@ -733,18 +770,26 @@ BOOL Grammar::readBinary(const CHAR* pszFilename) return false; if (!f.read_UINT(nNonterminals)) return false; -#ifdef DEBUG +#ifdef DEBUG printf("Reading %u terminals and %u nonterminals\n", nTerminals, nNonterminals); #endif + // Sanity check on the counts, to fail cleanly on a corrupt file + // rather than attempting a huge allocation + const UINT MAX_SYMBOLS = 1u << 24; + if (nTerminals > MAX_SYMBOLS || nNonterminals > MAX_SYMBOLS) + return false; if (!nNonterminals) // No nonterminals to read: we're done return true; INT iRoot; if (!f.read_INT(iRoot)) return false; -#ifdef DEBUG +#ifdef DEBUG printf("Root nonterminal index is %d\n", iRoot); #endif + // The root must be a valid nonterminal index (negative, within range) + if (iRoot >= 0 || ~((UINT)iRoot) >= nNonterminals) + return false; // Initialize the nonterminals array Nonterminal** ppnts = new Nonterminal*[nNonterminals]; memset(ppnts, 0, nNonterminals * sizeof(Nonterminal*)); @@ -764,25 +809,52 @@ BOOL Grammar::readBinary(const CHAR* pszFilename) // Loop through the productions for (UINT j = 0; j < nLenPlist; j++) { UINT nId; - if (!f.read_UINT(nId)) + if (!f.read_UINT(nId)) { + delete pnt; return false; + } UINT nPriority; - if (!f.read_UINT(nPriority)) + if (!f.read_UINT(nPriority)) { + delete pnt; return false; + } UINT nLenProd; - if (!f.read_UINT(nLenProd)) + if (!f.read_UINT(nLenProd)) { + delete pnt; return false; + } const UINT MAX_LEN_PROD = 256; if (nLenProd > MAX_LEN_PROD) { // Production too long -#ifdef DEBUG +#ifdef DEBUG printf("Production too long\n"); -#endif +#endif + delete pnt; return false; } // Read the production INT aiProd[MAX_LEN_PROD]; - f.read(aiProd, nLenProd * sizeof(INT)); + if (f.read(aiProd, nLenProd * sizeof(INT)) != nLenProd * sizeof(INT)) { + // Truncated file + delete pnt; + return false; + } + // Validate the production items: a negative item must be a + // valid nonterminal index and a positive item a valid terminal + // index (terminals are 1-based); zero items are not allowed + for (UINT k = 0; k < nLenProd; k++) { + INT iItem = aiProd[k]; + BOOL bValid = (iItem < 0) + ? ~((UINT)iItem) < nNonterminals + : (iItem > 0 && (UINT)iItem <= nTerminals); + if (!bValid) { +#ifdef DEBUG + printf("Invalid item %d in production %u\n", iItem, nId); +#endif + delete pnt; + return false; + } + } // Create a fresh production object Production* pprod = new Production(nId, nPriority, nLenProd, aiProd); // Add it to the nonterminal @@ -999,14 +1071,168 @@ void NodeDict::reset(void) Parser::Parser(Grammar* p, MatchingFunc pMatchingFunc, AllocFunc pAllocFunc) - : m_pGrammar(p), m_pMatchingFunc(pMatchingFunc), m_pAllocFunc(pAllocFunc) + : m_pGrammar(p), m_pMatchingFunc(pMatchingFunc), m_pAllocFunc(pAllocFunc), + m_pSpecs(NULL), m_nSpecs(0), m_pMeaningsFunc(NULL), + m_bParity(false), m_nParityMismatches(0) { ASSERT(this->m_pGrammar != NULL); ASSERT(this->m_pMatchingFunc != NULL); + memset(&this->m_masks, 0, sizeof(MatchMasks)); } Parser::~Parser(void) { + if (this->m_pSpecs) { + delete [] this->m_pSpecs; + acMatchingTables--; + } +} + +void Parser::setMatchingTable(const BYTE* pSpecs, UINT nSpecs, + MeaningsFunc fpMeanings, const BYTE* pMasks, BOOL bParity) +{ + // Install (or remove, if pSpecs is NULL) a native matching table. + // The spec table and mask structure are copied, so the caller's + // buffers need not outlive this call. + if (this->m_pSpecs) { + delete [] this->m_pSpecs; + this->m_pSpecs = NULL; + acMatchingTables--; + } + this->m_nSpecs = 0; + this->m_pMeaningsFunc = NULL; + this->m_bParity = bParity; + this->m_nParityMismatches.store(0, std::memory_order_relaxed); + memset(&this->m_masks, 0, sizeof(MatchMasks)); + if (!pSpecs || !nSpecs || !fpMeanings || !pMasks) + return; + this->m_pSpecs = new TerminalSpec[nSpecs]; + acMatchingTables++; + memcpy(this->m_pSpecs, pSpecs, nSpecs * sizeof(TerminalSpec)); + this->m_nSpecs = nSpecs; + memcpy(&this->m_masks, pMasks, sizeof(MatchMasks)); + this->m_pMeaningsFunc = fpMeanings; +} + +static inline BOOL defaultMatchMeaning(const TerminalSpec* pSpec, + const MeaningRec& m, const MatchMasks& mk) +{ + // Mirrors the feature bit logic of WordMatchers.matcher_default() + // in binparser.py + if (m.nFlags & MF_NO_BEYGING) { + if (m.nFlags & MF_IS_LO_SO) + // Adjective or verb abbreviation: matches irrespective of variants + return true; + if (m.nFlags & MF_IS_NOUN) + // Noun abbreviation: check gender, permit singular forms only + return (pSpec->nFbits & (mk.genders | mk.number) & ~(m.nFbits | mk.et)) == 0; + return pSpec->nFbits == 0; + } + return (pSpec->nFbits & ~m.nFbits) == 0; +} + +BOOL Parser::evalMatch(const TerminalSpec* pSpec, const BYTE* pTokenRec, + const MatchMasks& mk) +{ + // Native token/terminal matching. This function mirrors, exactly, + // the semantics of the corresponding Python matchers in binparser.py + // for the terminal kinds that are encoded natively; parity between + // the two implementations is asserted by tests (and can be verified + // at run-time via the parity mode). + const TokenRec* pHdr = (const TokenRec*)pTokenRec; + const UINT nKind = pSpec->nKindFlags & 0xFFu; + if (nKind == T_TEXT) + // Strong literal without category: pure token text identity, + // valid for all token kinds (mirrors shortcut_match) + return pHdr->nFormId != 0 && pHdr->nFormId == pSpec->nLitId; + if (pHdr->nCountFlags & TKF_EMPTY_WORD) + // Unknown word without BÍN meanings: per BIN_Token.matches_WORD(), + // only a no_..._et_hk terminal without 'gr' can match natively + // (sérnafn terminals are T_PYTHON and don't reach this point) + return nKind == T_CAT_NOUN && (pSpec->nKindFlags & TF_MATCHES_EMPTY) != 0; + if (nKind == T_TEXT_CAT && pHdr->nFormId != pSpec->nLitId) + // Strong literal with category: the token text must be identical + return false; + const UINT n = pHdr->nCountFlags & 0xFFFFu; + const MeaningRec* pm = (const MeaningRec*)(pTokenRec + sizeof(TokenRec)); + for (UINT i = 0; i < n; i++) { + const MeaningRec& m = pm[i]; + switch (nKind) { + case T_TEXT_CAT: + // Token text already matched: require the specified category + if (m.nCatId == pSpec->nCatId) + return true; + break; + case T_LEMMA: + // Lemma literal: interned lemma identity, optional category, + // then default matcher logic, then the middle voice exclusion + // for verb lemma literals (matcher_lemma_literal) + if (m.nLemmaId != 0 && m.nLemmaId == pSpec->nLitId + && (pSpec->nCatId == 0 || m.nCatId == pSpec->nCatId) + && defaultMatchMeaning(pSpec, m, mk) + && (!(pSpec->nKindFlags & TF_MM_EXCLUDE) || !(m.nFbits & mk.mm))) + return true; + break; + case T_CAT_DEFAULT: + if (m.nMappedCatId == pSpec->nCatId + && defaultMatchMeaning(pSpec, m, mk)) + return true; + break; + case T_CAT_MASK: + // abfn/pfn: masked feature bit test (matcher_abfn/matcher_pfn) + if (m.nCatId == pSpec->nCatId + && (pSpec->nFbits & pSpec->nMask & ~m.nFbits) == 0) + return true; + break; + case T_CAT_FIRST: + // töl: category only, variants don't disqualify (matcher_töl) + if (m.nMappedCatId == pSpec->nCatId) + return true; + break; + case T_CAT_NOUN: + // no_...: mirrors matcher_no() + if (!(m.nFlags & MF_IS_NOUN)) + break; + if (pSpec->nKindFlags & TF_ABBREV) { + // no_abbrev: only match meanings without inflection info + if (m.nFlags & MF_NO_BEYGING) + return true; + break; + } + if (m.nFlags & MF_NAME_FL) + // Person/family names are only matched by person terminals + break; + if (m.nFlags & MF_NO_BEYGING) { + // No case/number info (probably a foreign word): + // check gender only, and don't match a demand for + // the definite article ('gr') + if ((pSpec->nFbits & mk.genders & ~m.nFbits) == 0 + && (pSpec->nFbits & mk.gr) == 0) + return true; + } + else { + if ((pSpec->nFbits & ~m.nFbits) == 0) + return true; + } + break; + case T_CAT_LO: + // lo_... without subject cases or ending constraints + // (matcher_lo): abbreviations match regardless of variants + if (m.nCatId == pSpec->nCatId + && ((m.nFlags & MF_NO_BEYGING) || (pSpec->nFbits & ~m.nFbits) == 0)) + return true; + break; + case T_CAT_AO: + // ao_... without ending constraints (matcher_ao) + if (m.nCatId == pSpec->nCatId + && (pSpec->nFbits & ~m.nFbits) == 0) + return true; + break; + default: + break; + } + } + return false; } BYTE* Parser::allocCache(UINT nHandle, UINT nToken, BOOL* pbNeedRelease) @@ -1083,7 +1309,7 @@ void Parser::push(UINT nHandle, State* pState, Column* pE, State*& pQ, StateChun // The state is the most recently allocated one in the chunk // (a very common case): go back one location in the chunk pChunkHead->m_nIndex -= sizeof(State); - nDiscardedStates++; + nDiscardedStates.fetch_add(1, std::memory_order_relaxed); } } @@ -1349,12 +1575,14 @@ void AllocReporter::report(void) const printf("Grammars : %6d %8d\n", Grammar::ac.getBalance(), Grammar::ac.numAllocs()); printf("Nodes : %6d %8d\n", Node::ac.getBalance(), Node::ac.numAllocs()); printf("States : %6d %8d\n", State::ac.getBalance(), State::ac.numAllocs()); - printf("...discarded : %6s %8d\n", "", nDiscardedStates); + printf("...discarded : %6s %8d\n", "", nDiscardedStates.load(std::memory_order_relaxed)); printf("StateChunks : %6d %8d\n", acChunks.getBalance(), acChunks.numAllocs()); printf("Columns : %6d %8d\n", Column::ac.getBalance(), Column::ac.numAllocs()); printf("HNodes : %6d %8d\n", HNode::ac.getBalance(), HNode::ac.numAllocs()); printf("NodeDict lookups: %6s %8d\n", "", NodeDict::acLookups.numAllocs()); printf("Matching calls : %6s %8d\n", "", Column::acMatches.numAllocs()); + printf("...thereof native: %5s %8d\n", "", Column::acNativeMatches.numAllocs()); + printf("MatchingTables : %6d %8d\n", acMatchingTables.getBalance(), acMatchingTables.numAllocs()); fflush(stdout); // !!! Debugging } @@ -1371,18 +1599,28 @@ BOOL defaultMatcher(UINT nHandle, UINT nToken, UINT nTerminal) Grammar* newGrammar(const CHAR* pszGrammarFile) { + // Note: the extern "C" entry points that allocate memory catch + // all C++ exceptions (out-of-memory in particular) and return NULL + // instead, since an exception must never propagate through the + // C ABI boundary into the CFFI caller (that would be undefined + // behavior) if (!pszGrammarFile) return NULL; - // Read grammar from binary file - Grammar* pGrammar = new Grammar(); - if (!pGrammar->readBinary(pszGrammarFile)) { -#ifdef DEBUG - printf("Unable to read binary grammar file %s\n", pszGrammarFile); -#endif - delete pGrammar; + try { + // Read grammar from binary file + Grammar* pGrammar = new Grammar(); + if (!pGrammar->readBinary(pszGrammarFile)) { +#ifdef DEBUG + printf("Unable to read binary grammar file %s\n", pszGrammarFile); +#endif + delete pGrammar; + return NULL; + } + return pGrammar; + } + catch (...) { return NULL; } - return pGrammar; } void deleteGrammar(Grammar* pGrammar) @@ -1395,7 +1633,13 @@ Parser* newParser(Grammar* pGrammar, MatchingFunc fpMatcher, AllocFunc fpAlloc) { if (!pGrammar || !fpMatcher) return NULL; - return new Parser(pGrammar, fpMatcher, fpAlloc); + try { + return new Parser(pGrammar, fpMatcher, fpAlloc); + } + catch (...) { + // See note in newGrammar() + return NULL; + } } void deleteParser(Parser* pParser) @@ -1404,6 +1648,26 @@ void deleteParser(Parser* pParser) delete pParser; } +void setMatchingTable(Parser* pParser, const BYTE* pSpecs, UINT nSpecs, + MeaningsFunc fpMeanings, const BYTE* pMasks, BOOL bParity) +{ + if (!pParser) + return; + try { + pParser->setMatchingTable(pSpecs, nSpecs, fpMeanings, pMasks, bParity); + } + catch (...) { + // See note in newGrammar(); out of memory here simply means + // no native matching table, i.e. Python matching throughout + pParser->setMatchingTable(NULL, 0, NULL, NULL, false); + } +} + +UINT getParityMismatches(Parser* pParser) +{ + return pParser ? pParser->getParityMismatches() : 0; +} + void deleteForest(Node* pNode) { if (pNode) @@ -1442,7 +1706,17 @@ Node* earleyParse(Parser* pParser, UINT nTokens, INT iRoot, UINT nHandle, UINT* #ifdef DEBUG printf("Calling pParser->parse()\n"); fflush(stdout); #endif - Node* pNode = pParser->parse(nHandle, iRoot, pnErrorToken, nTokens); + Node* pNode; + try { + pNode = pParser->parse(nHandle, iRoot, pnErrorToken, nTokens); + } + catch (...) { + // See note in newGrammar(). If an exception (most likely + // out-of-memory) is thrown mid-parse, the working memory of the + // parse is not reclaimed, but that is preferable to undefined + // behavior at the C ABI boundary. + return NULL; + } #ifdef DEBUG printf("Back from pParser->parse()\n"); fflush(stdout); #endif diff --git a/src/reynir/eparser.h b/src/reynir/eparser.h index 6087bc7..a9b5ae8 100644 --- a/src/reynir/eparser.h +++ b/src/reynir/eparser.h @@ -45,6 +45,7 @@ #include #include #include +#include // Assert macro @@ -61,6 +62,7 @@ typedef wchar_t WCHAR; typedef char CHAR; typedef unsigned char BYTE; typedef bool BOOL; +typedef unsigned long long UINT64; class Production; @@ -80,10 +82,14 @@ class AllocCounter { // and call ac++ and ac-- in the constructor and destructor, // respectively. + // The counters are atomic (with relaxed ordering, which is + // sufficient for statistics) so that concurrent parses in + // multiple threads do not cause data races. + private: - UINT m_nAllocs; - UINT m_nFrees; + std::atomic m_nAllocs; + std::atomic m_nFrees; public: @@ -94,18 +100,18 @@ class AllocCounter { { } void operator++(int) - { this->m_nAllocs++; } + { this->m_nAllocs.fetch_add(1, std::memory_order_relaxed); } void operator--(int) { - ASSERT(this->m_nAllocs > this->m_nFrees); - this->m_nFrees++; + ASSERT(this->numAllocs() > this->numFrees()); + this->m_nFrees.fetch_add(1, std::memory_order_relaxed); } UINT numAllocs(void) const - { return this->m_nAllocs; } + { return this->m_nAllocs.load(std::memory_order_relaxed); } UINT numFrees(void) const - { return this->m_nFrees; } + { return this->m_nFrees.load(std::memory_order_relaxed); } INT getBalance(void) const - { return (INT)(this->m_nAllocs - this->m_nFrees); } + { return (INT)(this->numAllocs() - this->numFrees()); } }; @@ -253,8 +259,16 @@ class Label { : m_iNt(iNt), m_nDot(nDot), m_pProd(pProd), m_nI(nI), m_nJ(nJ) { } + // Note: member-wise comparison, rather than memcmp() of the + // whole struct, which would depend on the absence of padding BOOL operator==(const Label& other) const - { return ::memcmp((void*)this, (void*)&other, sizeof(Label)) == 0; } + { + return this->m_iNt == other.m_iNt && + this->m_nDot == other.m_nDot && + this->m_pProd == other.m_pProd && + this->m_nI == other.m_nI && + this->m_nJ == other.m_nJ; + } }; @@ -309,11 +323,85 @@ typedef BOOL (*MatchingFunc)(UINT nHandle, UINT nToken, UINT nTerminal); // Allocator for token/terminal matching cache typedef BYTE* (*AllocFunc)(UINT nHandle, UINT nToken, UINT nTerminals); +// Provider of packed token matching data (TokenRec header +// followed by MeaningRec entries) for native matching +typedef const BYTE* (*MeaningsFunc)(UINT nHandle, UINT nToken); + // Default matching function that simply // compares the token value with the terminal number BOOL defaultMatcher(UINT nHandle, UINT nToken, UINT nTerminal); +// Native token/terminal matching support. +// A table of TerminalSpec entries, built on the Python side by +// build_matching_table() in binparser.py, describes for each terminal +// how the C++ core can decide token/terminal matches natively, +// without calling back into Python. Terminals whose matching semantics +// are not natively encodable are marked T_PYTHON and continue to be +// matched via the MatchingFunc callback. The structure layouts below +// are mirrored byte-for-byte by the Python encoder (little-endian). + +// Terminal spec kinds (low byte of TerminalSpec::nKindFlags) +#define T_PYTHON 0 // Always match via the Python callback +#define T_TEXT 1 // Strong literal without category: token text identity +#define T_TEXT_CAT 2 // Strong literal with category +#define T_LEMMA 3 // Lemma literal, with optional category +#define T_CAT_DEFAULT 4 // Category terminal, default matcher semantics +#define T_CAT_MASK 5 // Category terminal, masked fbits test (abfn/pfn) +#define T_CAT_FIRST 6 // Category terminal, category test only (töl) +#define T_CAT_NOUN 7 // Noun terminal (no_...) +#define T_CAT_LO 8 // Adjective terminal (lo_...) +#define T_CAT_AO 9 // Adverb terminal (ao_...) + +// Terminal spec flags (higher bits of TerminalSpec::nKindFlags) +#define TF_ABBREV 0x0100u // no_abbrev: matches only meanings without inflection info +#define TF_MM_EXCLUDE 0x0200u // Verb lemma literal: don't match middle voice (MM) +#define TF_MATCHES_EMPTY 0x0400u // Terminal matches unknown words (no_..._et_hk without gr) + +// Token header flags (high 16 bits of TokenRec::nCountFlags) +#define TKF_FAST 0x00010000u // Word token with BÍN meanings: fully matchable natively +#define TKF_EMPTY_WORD 0x00020000u // Word token without BÍN meanings + +// Meaning record flags +#define MF_NO_BEYGING 1u // Meaning has no inflection info (beyging == "-") +#define MF_NAME_FL 2u // Meaning fl is 'nafn' or 'ætt' (person names) +#define MF_IS_NOUN 4u // Meaning ordfl is kk, kvk or hk +#define MF_IS_LO_SO 8u // Meaning ordfl is lo or so + +struct TerminalSpec { + UINT nKindFlags; // Kind in low byte, TF_* flags above + UINT nCatId; // Category id to compare, or 0 + UINT nLitId; // Interned literal (lemma/form) id, or 0 + UINT nReserved; + UINT64 nFbits; // Required feature bits + UINT64 nMask; // Comparison mask (T_CAT_MASK) +}; + +struct MeaningRec { + UINT nLemmaId; // Interned lemma id, or 0 + UINT nCatId; // Raw category (ordfl) id + UINT nMappedCatId; // Terminal-name-mapped category id (kk/kvk/hk -> no) + UINT nFlags; // MF_* flags + UINT64 nFbits; // Feature bits of this meaning (gender-augmented) +}; + +struct TokenRec { + UINT nFormId; // Interned id of the (lower-cased) token text, or 0 + UINT nCountFlags; // Meaning count in low 16 bits, TKF_* flags above + // Followed by (nCountFlags & 0xFFFF) MeaningRec entries +}; + +struct MatchMasks { + // Bit masks for the feature bit space, passed from Python + // (the bit layout is defined by BIN_Token.VBIT) + UINT64 genders; + UINT64 number; + UINT64 et; + UINT64 gr; + UINT64 mm; +}; + + class Parser { // Earley-Scott parser for a given Grammar @@ -328,6 +416,16 @@ class Parser { MatchingFunc m_pMatchingFunc; AllocFunc m_pAllocFunc; + // Native matching support (may be absent) + TerminalSpec* m_pSpecs; // Owned copy of the terminal spec table, or NULL + UINT m_nSpecs; // Number of entries in m_pSpecs + MeaningsFunc m_pMeaningsFunc; + MatchMasks m_masks; + BOOL m_bParity; // Parity checking mode + // Atomic (like the diagnostic allocation counters), since it is + // incremented from within concurrent parses + std::atomic m_nParityMismatches; + void push(UINT nHandle, State*, Column*, State*&, StateChunk*); Node* makeNode(State* pState, UINT nEnd, Node* pV, NodeDict& ndV); @@ -352,6 +450,31 @@ class Parser { Grammar* getGrammar(void) const { return this->m_pGrammar; } + // Native matching support + void setMatchingTable(const BYTE* pSpecs, UINT nSpecs, + MeaningsFunc fpMeanings, const BYTE* pMasks, BOOL bParity); + const TerminalSpec* getSpec(UINT nTerminal) const + { + return (this->m_pSpecs && nTerminal < this->m_nSpecs) + ? &this->m_pSpecs[nTerminal] : NULL; + } + const BYTE* fetchTokenRec(UINT nHandle, UINT nToken) const + { + return this->m_pMeaningsFunc + ? this->m_pMeaningsFunc(nHandle, nToken) : NULL; + } + const MatchMasks& getMasks(void) const + { return this->m_masks; } + BOOL parityMode(void) const + { return this->m_bParity; } + void countParityMismatch(void) + { this->m_nParityMismatches.fetch_add(1, std::memory_order_relaxed); } + UINT getParityMismatches(void) const + { return this->m_nParityMismatches.load(std::memory_order_relaxed); } + + // Evaluate a native token/terminal match + static BOOL evalMatch(const TerminalSpec*, const BYTE* pTokenRec, const MatchMasks&); + // If pnToklist is NULL, a sequence of integers 0..nTokens-1 will be used Node* parse(UINT nHandle, INT iStartNt, UINT* pnErrorToken, UINT nTokens, const UINT pnToklist[] = NULL); @@ -372,6 +495,15 @@ extern "C" Parser* newParser(Grammar*, MatchingFunc fpMatcher = defaultMatcher, extern "C" void deleteParser(Parser*); +// Install a native matching table (see TerminalSpec above); +// pMasks points to a MatchMasks structure +extern "C" void setMatchingTable(Parser*, const BYTE* pSpecs, UINT nSpecs, + MeaningsFunc fpMeanings, const BYTE* pMasks, BOOL bParity); + +// Return the number of native/Python matching discrepancies +// detected while running in parity mode +extern "C" UINT getParityMismatches(Parser*); + extern "C" void deleteForest(Node*); extern "C" void dumpForest(Node*, Grammar*); diff --git a/src/reynir/eparser_build.py b/src/reynir/eparser_build.py index 26f6b4e..9d976da 100644 --- a/src/reynir/eparser_build.py +++ b/src/reynir/eparser_build.py @@ -97,6 +97,7 @@ typedef BOOL (*MatchingFunc)(UINT nHandle, UINT nToken, UINT nTerminal); typedef BYTE* (*AllocFunc)(UINT nHandle, UINT nToken, UINT nSize); + typedef const BYTE* (*MeaningsFunc)(UINT nHandle, UINT nToken); struct Node* earleyParse(struct Parser*, UINT nTokens, INT iRoot, UINT nHandle, UINT* pnErrorToken); struct Grammar* newGrammar(const CHAR* pszGrammarFile); @@ -107,6 +108,10 @@ void dumpForest(struct Node*, struct Grammar*); UINT numCombinations(struct Node*); + void setMatchingTable(struct Parser*, const BYTE* pSpecs, UINT nSpecs, + MeaningsFunc fpMeanings, const BYTE* pMasks, BOOL bParity); + UINT getParityMismatches(struct Parser*); + void printAllocationReport(void); """ @@ -118,6 +123,7 @@ extern "Python" BOOL matching_func(UINT, UINT, UINT); extern "Python" BYTE* alloc_func(UINT, UINT, UINT); + extern "Python" const BYTE* meanings_func(UINT, UINT); """ @@ -130,7 +136,7 @@ elif MACOS: os.environ["CFLAGS"] = "-stdlib=libc++" # Fixes PyPy build on macOS 10.15.6+ os.environ["MACOSX_DEPLOYMENT_TARGET"] = "10.9" - extra_compile_args = ["-mmacosx-version-min=10.9", "-stdlib=libc++"] + extra_compile_args = ["-mmacosx-version-min=10.9", "-stdlib=libc++", "-std=c++11"] else: extra_compile_args = ["-std=c++11"] diff --git a/src/reynir/fastparser.py b/src/reynir/fastparser.py index 6f83cb8..2387278 100644 --- a/src/reynir/fastparser.py +++ b/src/reynir/fastparser.py @@ -81,14 +81,18 @@ from .binparser import ( BIN_Parser, BIN_Token, + MatchingTable, + build_matching_table, + encode_token_matching_data, simplify_terminal, augment_terminal, Tok, TokenDict, ) +from filelock import FileLock, Timeout + from .grammar import Grammar, GrammarError, Nonterminal, Terminal, Production from .settings import Settings -from .glock import GlobalLock # Import the CFFI wrapper module for the _eparser.*.so library # which is compiled from eparser.cpp (see eparser_build.py) @@ -121,6 +125,8 @@ def __init__( tokens: List[BIN_Token], terminals: Dict[int, Terminal], matching_cache: Dict[Tuple[Hashable, ...], Any], + matching_table: Optional[MatchingTable] = None, + meanings_cache: Optional[Dict[Tuple[Hashable, ...], Any]] = None, ) -> None: self._handle = handle self.tokens = tokens @@ -128,6 +134,12 @@ def __init__( self.grammar = grammar self.c_dict: Dict[Any, "Node"] = dict() # Node pointer conversion dictionary self.matching_cache = matching_cache # Token/terminal matching buffers + # Native matching support: interned lexicon/categories, and a + # cache of packed per-token matching records + self.matching_table = matching_table + self.meanings_cache: Dict[Tuple[Hashable, ...], Any] = ( + meanings_cache if meanings_cache is not None else dict() + ) def matches(self, token_index: int, terminal_index: int) -> bool: """Convert the token reference from a 0-based token index @@ -149,6 +161,20 @@ def alloc_cache(self, token: int, size: int) -> Any: assert False, "alloc_cache() unable to hash key: {0}".format(repr(key)) return b + def token_matching_data(self, token: int) -> Any: + """Return packed native matching data (a TokenRec header followed + by MeaningRec entries, see eparser.h) for the given token""" + table = self.matching_table + if table is None: + return ffi_NULL + tok = self.tokens[token] + key = tok.key + b: Any = self.meanings_cache.get(key) + if b is None: + data = encode_token_matching_data(tok, table) + b = self.meanings_cache[key] = ffi_new("BYTE[]", data) + return b + def reset(self) -> None: """Reset the node pointer conversion dictionary""" self.c_dict = dict() @@ -175,6 +201,8 @@ def make( tokens: List[BIN_Token], terminals: Dict[int, Terminal], matching_cache: Dict[Tuple[Hashable, ...], Any], + matching_table: Optional[MatchingTable] = None, + meanings_cache: Optional[Dict[Tuple[Hashable, ...], Any]] = None, ) -> "ParseJob": """Create a new parse job with for a given token sequence and set of terminals""" with cls._lock: @@ -182,7 +210,15 @@ def make( cls._seq += 1 if cls._seq >= cls._MAX_JOBS: cls._seq = 0 - j = cls._jobs[h] = ParseJob(h, grammar, tokens, terminals, matching_cache) + j = cls._jobs[h] = ParseJob( + h, + grammar, + tokens, + terminals, + matching_cache, + matching_table, + meanings_cache, + ) return j @classmethod @@ -201,6 +237,11 @@ def alloc(cls, handle: int, token_index: int, size: int): """Dispatch a cache buffer allocation request to the correct parse job""" return cls._jobs[handle].alloc_cache(token_index, size) + @classmethod + def meanings(cls, handle: int, token_index: int) -> Any: + """Dispatch a token matching data request to the correct parse job""" + return cls._jobs[handle].token_matching_data(token_index) + # Declare CFFI callback functions to be called from the C++ code # See: https://cffi.readthedocs.io/en/latest/using.html#extern-python-new-style-callbacks @@ -226,6 +267,15 @@ def alloc_func(handle: int, token_index: int, size: int): return ParseJob.alloc(handle, token_index, size) +@ffi.def_extern() # type: ignore +def meanings_func(handle: int, token_index: int): + """Called from the C++ parser, once per Earley column, to obtain + packed matching data for a token. This enables the C++ core to + decide most token/terminal matches natively, without calling + matching_func() for each terminal.""" + return ParseJob.meanings(handle, token_index) + + class Node: """Shared Packed Parse Forest (SPPF) node representation, @@ -338,45 +388,50 @@ def from_c_node( # refer to the same nonterminal, are interior, # and not ambiguous. ch: List[Any] = [] + # Pending child nodes, processed iteratively via an explicit + # stack rather than by recursion, so that long coalescible + # interior node chains cannot hit the Python recursion limit. + # Nodes are pushed in reverse order so that they are popped - + # and appended to ch - in left-to-right order. + stack: List[Any] = [] def push_pair(p1: Any, p2: Any) -> None: - """Push a pair of child nodes onto the child list""" - - def push_child(p: Any) -> None: - """Push a single child node onto the child list""" - if p.label.iNt == nt and p.label.pProd != ffi_NULL: - # Interior node for the same nonterminal - if p.pHead.pNext == ffi_NULL: - # Unambiguous: recurse - push_pair(p.pHead.p1, p.pHead.p2) - else: - # Ambiguous node, i.e. more than one family of children. - # In this case we don't know which (p1,p2) pair - # to add as a child of the parent, so we must - # retain the original node with its family of children - # and end the recursion. We also need to add - # placeholder (dummy) nodes to keep the child - # list in sync with the nonterminal's production. - if p.label.nDot > 2: - # Add placeholders for the part of the production - # that is missing from the front since we abandon - # the recursion here - ch.extend([ffi_NULL] * (p.label.nDot - 2)) - ch.append(p) - ch.append(ffi_NULL) # Placeholder - else: - # Terminal, epsilon or unrelated nonterminal - ch.append(p) - + """Push a pair of child nodes onto the pending stack""" if p1 != ffi_NULL and p2 != ffi_NULL: - push_child(p1) - push_child(p2) + stack.append(p2) + stack.append(p1) elif p2 != ffi_NULL: - push_child(p2) + stack.append(p2) else: - push_child(p1) + stack.append(p1) push_pair(fe.p1, fe.p2) + while stack: + p = stack.pop() + if p.label.iNt == nt and p.label.pProd != ffi_NULL: + # Interior node for the same nonterminal + if p.pHead.pNext == ffi_NULL: + # Unambiguous: coalesce, i.e. expand in place + push_pair(p.pHead.p1, p.pHead.p2) + else: + # Ambiguous node, i.e. more than one family of children. + # In this case we don't know which (p1,p2) pair + # to add as a child of the parent, so we must + # retain the original node with its family of children + # and end the coalescing. We also need to add + # placeholder (dummy) nodes to keep the child + # list in sync with the nonterminal's production. + if p.label.nDot > 2: + # Add placeholders for the part of the production + # that is missing from the front since we abandon + # the coalescing here + ch.extend([ffi_NULL] * (p.label.nDot - 2)) + ch.append(p) + ch.append(ffi_NULL) # Placeholder + else: + # Terminal, epsilon or unrelated nonterminal + ch.append(p) + node._add_family(job, fe.pProd, ch) fe = fe.pNext @@ -678,31 +733,132 @@ def _load_binary_grammar(cls) -> Any: ) return cls._c_grammar + # Maximum time to wait for the grammar lock, in seconds + _GRAMMAR_LOCK_TIMEOUT = 180.0 + # Maximum number of entries in the token/terminal matching cache. + # Each entry occupies roughly one byte per grammar terminal (~5 KB + # for Greynir.grammar), so this cap corresponds to on the order of + # 125 MB. Without a cap, the cache would grow without limit in + # long-running processes. + _MAX_MATCHING_CACHE_SIZE = 25_000 + # Set to False in derived classes to disable the native (C++) + # token/terminal matching fast path and match everything in Python + _USE_CPP_MATCHING = True + + def _cpp_matching_allowed(self) -> bool: + """Return True if the native (C++) matching fast path may be + used by this parser instance""" + cls = type(self) + if not cls._USE_CPP_MATCHING: + return False + if os.environ.get("GREYNIR_DISABLE_CPP_MATCHING"): + return False + # Auto-gate: the native fast path replicates the matching + # semantics of BIN_Token/BIN_Terminal exactly. Derived classes + # (e.g. GreynirCorrect) that override token wrapping have + # different matching semantics and automatically fall back to + # Python matching here. (Terminals of derived types likewise + # fall back individually, via the exact type checks in + # binparser.build_matching_table().) + return ( + cls.wrap_token is BIN_Parser.wrap_token + and cls._wrap is BIN_Parser._wrap + ) + # Serializes parser initialization between threads of this process, + # protecting the class-level grammar caches (both the Python grammar + # singleton in BIN_Parser and the C++ grammar blob above) + _init_lock = Lock() + + @classmethod + def _regeneration_needed(cls) -> bool: + """Return True if the binary grammar file is missing or older than + the grammar text file, i.e. if initialization may write to it""" + try: + binary_ts = os.path.getmtime(cls._GRAMMAR_BINARY_FILE) + except OSError: + return True + try: + source_ts = os.path.getmtime(cls._GRAMMAR_FILE) + except OSError: + # No grammar text file present: use the binary as-is + return False + return binary_ts < source_ts + def __init__(self, verbose: bool = False, root: Optional[str] = None) -> None: - # Only one initialization at a time, since we don't want a race - # condition between threads with regards to reading and parsing the grammar file - # vs. writing the binary grammar - with GlobalLock("grammar"): - # Read and parse the grammar text file - super().__init__(verbose) - # Create instances of the C++ Grammar and Parser classes - c_grammar = self._load_binary_grammar() - # Create a C++ parser object for the grammar, passing the proxies for the - # two Python callback functions into it - self._c_parser: Any = eparser.newParser( # type: ignore - c_grammar, eparser.matching_func, eparser.alloc_func # type: ignore - ) - # Find the index of the default root nonterminal for this parser instance - self._root_index = ( - 0 if root is None else self.grammar.nonterminals[root].index + # Only one initialization at a time within this process, since we + # don't want a race condition between threads with regards to the + # class-level grammar caches + with Fast_Parser._init_lock: + if self._regeneration_needed(): + # The binary grammar file is missing or stale, so this + # initialization may (re)generate it: serialize with other + # processes via a lock file, which lives next to the binary + # grammar file, scoping the lock to this particular + # installation of the package. + grammar_lock = FileLock( + self._GRAMMAR_BINARY_FILE + ".lock", + timeout=self._GRAMMAR_LOCK_TIMEOUT, + ) + try: + grammar_lock.acquire() + except Timeout: + raise GrammarError( + "Timed out waiting for the grammar lock; another process " + "may be stuck holding it. If this persists, delete the " + "lock file {0} and retry.".format(grammar_lock.lock_file) + ) + try: + self._initialize(verbose, root) + finally: + grammar_lock.release() + else: + # Warm path: the binary grammar is up to date, so no lock + # file is needed. Even if another process concurrently + # decides to regenerate the binary (e.g. if the grammar text + # file is modified right now), it will only ever replace it + # atomically with another complete version. + self._initialize(verbose, root) + + def _initialize(self, verbose: bool, root: Optional[str]) -> None: + """Read the grammar and create the C++ parser instance; + must be called with _init_lock held""" + # Read and parse the grammar text file + super().__init__(verbose) + # Create instances of the C++ Grammar and Parser classes + c_grammar = self._load_binary_grammar() + # Create a C++ parser object for the grammar, passing the proxies for the + # two Python callback functions into it + self._c_parser: Any = eparser.newParser( # type: ignore + c_grammar, eparser.matching_func, eparser.alloc_func # type: ignore + ) + # Find the index of the default root nonterminal for this parser instance + self._root_index = ( + 0 if root is None else self.grammar.nonterminals[root].index + ) + # Maintain a token/terminal matching cache for the duration + # of this parser instance. Note that this cache will grow with use, + # as it includes an entry (consisting of one byte per terminal in the + # grammar, or currently about 5K bytes for Greynir.grammar) for every + # distinct token that the parser encounters. + self._matching_cache: Dict[Tuple[Hashable, ...], Any] = dict() + # Cache of packed per-token records for native matching + self._meanings_cache: Dict[Tuple[Hashable, ...], Any] = dict() + # Install the native matching table, enabling the C++ core to + # decide most token/terminal matches without Python callbacks + self._matching_table: Optional[MatchingTable] = None + if self._cpp_matching_allowed(): + table = build_matching_table(self.grammar) + parity = 1 if os.environ.get("GREYNIR_MATCHING_PARITY") else 0 + eparser.setMatchingTable( # type: ignore + self._c_parser, + table.specs, + table.num_specs, + eparser.meanings_func, # type: ignore + table.masks, + parity, ) - # Maintain a token/terminal matching cache for the duration - # of this parser instance. Note that this cache will grow with use, - # as it includes an entry (consisting of one byte per terminal in the - # grammar, or currently about 5K bytes for Greynir.grammar) for every - # distinct token that the parser encounters. - self._matching_cache: Dict[Tuple[Hashable, ...], Any] = dict() + self._matching_table = table def __enter__(self): """Python context manager protocol""" @@ -724,11 +880,27 @@ def go(self, tokens: Iterable[Tok], *, root: Optional[str] = None) -> Node: err: Sequence[int] = ffi_new("unsigned int*") result: Optional[Node] = None + if len(self._matching_cache) > self._MAX_MATCHING_CACHE_SIZE: + # The matching cache has grown too large: discard it. + # The cost is only that subsequent parses need to re-match + # tokens against terminals as they are encountered again. + # Note: the dicts are replaced, not cleared in place. Parse + # jobs that may be in flight in other threads hold references + # to the current dicts, which keeps the CFFI buffers that the + # C++ core points into alive until those jobs complete. + self._matching_cache = dict() + self._meanings_cache = dict() + # Use the context manager protocol to guarantee that the parse job # handle will be properly deleted even if an exception is thrown with ParseJob.make( - self.grammar, wrapped_tokens, self._terminals, self._matching_cache + self.grammar, + wrapped_tokens, + self._terminals, + self._matching_cache, + self._matching_table, + self._meanings_cache, ) as job: # Determine the root nonterminal to be used for this parse @@ -777,6 +949,21 @@ def go_no_exc(self, tokens: Iterable[Tok], **kwargs: Any) -> Optional[Node]: except ParseError: return None + @property + def uses_native_matching(self) -> bool: + """Return True if this parser decides most token/terminal + matches natively in the C++ core""" + return self._matching_table is not None + + @property + def parity_mismatches(self) -> int: + """Return the number of native/Python matching discrepancies + detected so far (only counted in parity mode, i.e. with the + environment variable GREYNIR_MATCHING_PARITY set)""" + if self._c_parser == ffi_NULL: + return 0 + return cast(int, eparser.getParityMismatches(self._c_parser)) # type: ignore + def cleanup(self) -> None: """Delete C++ objects. Must call after last use of Fast_Parser to avoid memory leaks. The context manager protocol is recommended diff --git a/src/reynir/glock.py b/src/reynir/glock.py index 980ba7b..eaf2cdc 100644 --- a/src/reynir/glock.py +++ b/src/reynir/glock.py @@ -29,10 +29,15 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. This module implements the GlobalLock class, providing - interprocess locks within a server. + named interprocess locks within a single machine. - A GlobalLock is implemented as a file in the /tmp/ directory, - which is assumed to exist (on the current drive in Windows). + Note: this class is no longer used by GreynirEngine itself, which + uses the cross-platform filelock package directly (see fastparser.py). + GlobalLock is retained as a thin wrapper around filelock for backwards + compatibility with external code that imports it. + + A GlobalLock is implemented as a lock file in the system temporary + directory. A quick and easy way to use a blocking GlobalLock is as follows: @@ -41,12 +46,13 @@ """ -from typing import Any, IO, Optional +from typing import Any import os -import stat import tempfile +from filelock import FileLock, Timeout + class LockError(Exception): """Lock could not be obtained""" @@ -54,148 +60,39 @@ class LockError(Exception): pass -POSIX: bool = False - -try: - # Try Linux/POSIX - import fcntl -except ImportError: - - try: - # Try Windows - import msvcrt - except ImportError: - - # Not Unix, not Windows: bail out - def _lock_file(file: IO[str], block: bool) -> None: - raise TypeError("File locking not supported on this platform") - - def _unlock_file(file: IO[str]) -> None: - raise TypeError("File locking not supported on this platform") - - else: - - # Windows - - def _lock_file(file: IO[str], block: bool) -> None: - # Lock just the first byte of the file - retry = True - while retry: - retry = False - try: - msvcrt.locking( # type: ignore - file.fileno(), - msvcrt.LK_LOCK if block else msvcrt.LK_NBLCK, # type: ignore - 1, - ) - except OSError as e: - if block and e.errno == 36: - # Windows says 'resource deadlock avoided', but we truly want - # a longer blocking wait: try again - retry = True - else: - raise LockError( - "Couldn't lock {0}, errno is {1}".format(file.name, e.errno) - ) - - def _unlock_file(file: IO[str]) -> None: - try: - file.seek(0) - msvcrt.locking(file.fileno(), msvcrt.LK_UNLCK, 1) # type: ignore - except OSError as e: - raise LockError( - "Couldn't unlock {0}, errno is {1}".format(file.name, e.errno) - ) - -else: - - # Linux/POSIX - - POSIX = True # type: ignore - - def _lock_file(file: IO[str], block: bool) -> None: - try: - fcntl.flock(file.fileno(), fcntl.LOCK_EX | (0 if block else fcntl.LOCK_NB)) - except IOError: - raise LockError("Couldn't lock {0}".format(file.name)) - - def _unlock_file(file: IO[str]) -> None: - # File is automatically unlocked on close - pass - - class GlobalLock: - - _TMP_DIR = tempfile.gettempdir() + """A named interprocess lock, implemented as a lock file + in the system temporary directory""" def __init__(self, lockname: str) -> None: """Initialize a global lock with the given name""" assert lockname and isinstance(lockname, str) - # Locate global locks in the system temporary directory - # (should work on both Windows and Unix/POSIX) - self._path = os.path.join(self._TMP_DIR, "greynir-" + lockname) - self._fp: Optional[IO[str]] = None + # Locate global locks in the system temporary directory, + # using the same file name as previous versions of this module + self._lock = FileLock( + os.path.join(tempfile.gettempdir(), "greynir-" + lockname) + ) def acquire(self, block: bool = True) -> None: """Acquire a global lock, blocking if block = True""" - - if self._fp is not None: - # Already hold the lock + if self._lock.is_locked: + # This process already holds the lock return - - path = self._path - fp = None try: - # Try to open for writing without truncation: - fp = open(path, "r+") - except IOError: - # If the file doesn't exist, we'll get an IO error, try a+ - # Note that there may be a race here. Multiple processes - # could fail on the r+ open and open the file a+, but only - # one will get the the lock and write a pid. - try: - fp = open(path, "a+") - # Make sure that the file is readable and writable by others - if POSIX: - os.fchmod( - fp.fileno(), - stat.S_IRUSR - | stat.S_IWUSR - | stat.S_IRGRP - | stat.S_IWGRP - | stat.S_IROTH - | stat.S_IWOTH, - ) - except IOError: - raise LockError("Couldn't open or create lock file {0}".format(path)) - - self._fp = fp - - try: - _lock_file(fp, block) - except: - fp.seek(1) - fp.close() - raise - - # Once acquired, write the process id to the file - fp.write(" %s\n" % os.getpid()) - fp.truncate() - fp.flush() + self._lock.acquire(timeout=-1 if block else 0) + except Timeout: + raise LockError("Couldn't lock {0}".format(self._lock.lock_file)) def release(self) -> None: """Release the lock""" - if self._fp is not None: - _unlock_file(self._fp) - self._fp.close() - self._fp = None + if self._lock.is_locked: + self._lock.release() - def __enter__(self): + def __enter__(self) -> "GlobalLock": """Python context manager protocol""" self.acquire(block=True) return self - def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any): + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: """Python context manager protocol""" self.release() - return False diff --git a/src/reynir/grammar.py b/src/reynir/grammar.py index 5253034..083fa25 100644 --- a/src/reynir/grammar.py +++ b/src/reynir/grammar.py @@ -81,6 +81,7 @@ import os import struct +import time from datetime import datetime from collections import defaultdict @@ -680,31 +681,55 @@ def _make_nonterminal(name: str, fname: str, line: int) -> Nonterminal: def _write_binary(self, fname: str) -> None: """Write grammar to binary file. Called after reading a grammar text file - that is newer than the corresponding binary file.""" - with open(fname, "wb") as f: - if Settings.DEBUG: - print("Writing binary grammar file {0}".format(fname)) - # Version header - f.write("Greynir00.00.01\n".encode("ascii")) # 16 bytes total - num_nt = self.num_nonterminals - # Number of terminals and nonterminals in grammar - f.write(struct.pack(" None: self._err_index: Optional[int] = None self._error: Optional[ParseError] = None self._simplified_tree: Optional[SimpleTree] = None - self._tree = None + self._tree: Optional[Node] = None # Number of possible combinations self._num: Optional[int] = None # Score of best parse tree @@ -154,7 +155,7 @@ def parse(self) -> bool: job = self._job num = 0 score = 0 - tree = None + tree: Optional[Node] = None try: # Invoke the parser on the sentence tokens tree, num, score = job.parse(self._s) @@ -736,9 +737,44 @@ class Greynir: _reducer: Optional[Reducer] = None _lock = Lock() + # The set of keyword options recognized by the Greynir constructor. + # Apart from parse_foreign_sentences, these are tokenization options, + # passed through to the tokenize() call - see bintokenizer.py and the + # tokenizer package. Derived classes that accept additional options + # can extend this set. + _KNOWN_OPTIONS: FrozenSet[str] = frozenset( + ( + "parse_foreign_sentences", + # Options consumed by bintokenizer.py + "auto_uppercase", + "no_sentence_start", + "no_multiply_numbers", + # Options consumed by the tokenizer package + "coalesce_percent", + "convert_measurements", + "convert_numbers", + "normalize", + "one_sent_per_line", + "original", + "replace_composite_glyphs", + "replace_html_escapes", + "with_annotation", + ) + ) + def __init__(self, **options: Any) -> None: """Tokenization options can be passed as keyword arguments to the Greynir constructor""" + # Check for unknown options, which would otherwise be + # silently ignored; note that e.g. max_sent_tokens is a + # parameter of the parse methods, not of the constructor + unknown = set(options) - self._KNOWN_OPTIONS + if unknown: + raise TypeError( + "Greynir() got unexpected keyword argument(s): {0}".format( + ", ".join(sorted(unknown)) + ) + ) # Set parse_foreign_sentences to True to attempt to parse # all sentences, even if probably foreign self._parse_foreign_sentences: bool = options.pop( diff --git a/test/test_binary_grammar.py b/test/test_binary_grammar.py new file mode 100644 index 0000000..ed4a1c0 --- /dev/null +++ b/test/test_binary_grammar.py @@ -0,0 +1,126 @@ +""" + + test_binary_grammar.py + + Tests for robust loading of the binary grammar file, + i.e. newGrammar()/readBinary() in eparser.cpp. A corrupt or + truncated binary grammar file should cause newGrammar() to + return NULL (surfacing as a GrammarError in Python), never + to crash. + +""" + +import struct + +import pytest + +from reynir._eparser import lib as eparser, ffi # type: ignore +from reynir.fastparser import Fast_Parser + +BIN_FILE: str = Fast_Parser._GRAMMAR_BINARY_FILE + +# The binary grammar header layout (see Grammar._write_binary() +# in grammar.py): 16 byte signature, then two unsigned ints +# (number of terminals, number of nonterminals), then a signed int +# (root nonterminal index) +SIGNATURE = b"Greynir00.00.01\n" +ROOT_OFFSET = len(SIGNATURE) + 8 + + +@pytest.fixture(scope="module", autouse=True) +def ensure_binary_grammar(): + """Make sure the binary grammar file exists before running + the tests in this module""" + fp = Fast_Parser() + fp.cleanup() + yield + + +def load_grammar(path: str) -> bool: + """Attempt to load a binary grammar file; return True if successful""" + g = eparser.newGrammar(str(path).encode("utf-8")) + if g == ffi.NULL: + return False + eparser.deleteGrammar(g) + return True + + +def test_valid_grammar_loads() -> None: + assert load_grammar(BIN_FILE) + + +def test_nonexistent_file_fails() -> None: + assert not load_grammar(BIN_FILE + ".does-not-exist") + + +def test_truncated_grammar_fails(tmp_path) -> None: + with open(BIN_FILE, "rb") as f: + data = f.read() + p = tmp_path / "truncated.bin" + for cut in (0, 7, 16, 24, 28, len(data) // 2, len(data) - 4): + p.write_bytes(data[:cut]) + assert not load_grammar(str(p)), ( + "Grammar truncated at {0} bytes should fail to load".format(cut) + ) + + +def test_bad_signature_fails(tmp_path) -> None: + with open(BIN_FILE, "rb") as f: + data = bytearray(f.read()) + data[0:7] = b"Invalid" + p = tmp_path / "badsig.bin" + p.write_bytes(data) + assert not load_grammar(str(p)) + + +def test_bad_root_fails(tmp_path) -> None: + with open(BIN_FILE, "rb") as f: + data = bytearray(f.read()) + p = tmp_path / "badroot.bin" + # A nonnegative root index is invalid + data[ROOT_OFFSET : ROOT_OFFSET + 4] = struct.pack(" None: + with open(BIN_FILE, "rb") as f: + header = f.read(ROOT_OFFSET + 4) + p = tmp_path / "garbage.bin" + p.write_bytes(header + b"\xff" * 256) + assert not load_grammar(str(p)) + + +def _minimal_grammar(production_item: int) -> bytes: + """Construct a minimal binary grammar with one terminal, one + nonterminal and a single one-item production""" + buf = SIGNATURE + buf += struct.pack(" None: + p = tmp_path / "minimal.bin" + p.write_bytes(_minimal_grammar(1)) # Terminal index 1: valid + assert load_grammar(str(p)) + + +def test_out_of_range_production_items_fail(tmp_path) -> None: + p = tmp_path / "baditem.bin" + # Terminal index 2 is out of range (there is only one terminal) + p.write_bytes(_minimal_grammar(2)) + assert not load_grammar(str(p)) + # A zero item is invalid within a production + p.write_bytes(_minimal_grammar(0)) + assert not load_grammar(str(p)) + # Nonterminal index -2 is out of range (there is only one nonterminal) + p.write_bytes(_minimal_grammar(-2)) + assert not load_grammar(str(p)) diff --git a/test/test_native_matching.py b/test/test_native_matching.py new file mode 100644 index 0000000..04911df --- /dev/null +++ b/test/test_native_matching.py @@ -0,0 +1,243 @@ +""" + + test_native_matching.py + + Tests for the native (C++) token/terminal matching fast path + (build_matching_table()/encode_token_matching_data() in binparser.py + and evalMatch() in eparser.cpp). Verifies that the fast path is + enabled by default, produces results identical to Python matching, + reports zero discrepancies in parity mode, and is automatically + disabled for parser subclasses that override token wrapping. + +""" + +import os + +from tokenizer import TOK + +from reynir import Greynir +from reynir.binparser import BIN_Token +from reynir.fastparser import Fast_Parser + +# A corpus exercising all producible token kinds and all families of +# token/terminal matching: strong and lemma literal terminals, category +# terminals (nouns with/without the definite article, adjectives with +# degrees and subject cases, adverbs, pronouns, number words), verb +# terminals (argument frames, impersonal/oblique subjects, middle voice, +# past participle, supine, expletives), prepositions, person and street +# names, abbreviations, unknown/foreign words, and the various +# non-word token kinds (numbers, amounts, percentages, dates, times, +# timestamps, measurements, e-mail addresses, URLs, domains, hashtags, +# usernames, telephone numbers, molecules, companies and entities). +# Sentences that do not parse still exercise matching and are valuable +# here; the corpus is also used for token kind coverage checks below. +SENTENCES = [ + "Ása sá sól.", + "Konan sem kom í heimsókn í gær ætlar að kaupa nýja íbúð í miðbænum.", + "Hr. Jón Jónsson býr á Laugavegi 26 og á 3,4 milljónir króna í banka.", + "Verðbólgan hefur aukist verulega, þ.e.a.s. um 5,6%, á síðustu mánuðum.", + "Xylofonn og Kwerty eru ekki íslensk orð.", + "Hinn 17. júní 2011 var lýðveldið Ísland 67 ára gamalt.", + "Þórunn Ólafsdóttir varð sér úti um brimsalta poka af poppi.", + "Það rignir sjaldan í Reykjavík í júlí en þó gerist það stundum.", + "Bandaríkin og Evrópusambandið gerðu með sér nýjan viðskiptasamning.", + "Kötturinn, sem heitir Brandur, veiddi þrjár mýs í nótt.", + # Person names, titles + "Guðrún Helgadóttir forsætisráðherra hitti Pál Ólafsson og frú Sigríði í gær.", + # Currency amounts, percentages, numbers + "Bíllinn kostaði 4,5 milljónir króna og lækkaði um 12,5% í verði.", + "Fyrirtækið greiddi 1.200 USD fyrir hugbúnaðinn og 45 EUR fyrir þjónustuna.", + # Time, absolute and relative dates + "Fundurinn hefst klukkan 14:30 þriðjudaginn 5. mars 2024 í aðalbyggingunni.", + "Árið 1944 varð Ísland lýðveldi og 17. júní er þjóðhátíðardagurinn.", + # Timestamps, absolute and relative + "Tölvupósturinn barst 2024-03-05 14:30 og var lesinn strax.", + "Afmælið er 17. júní kl. 15:00 í sumar.", + # Ordinals + "Hún varð í 2. sæti í keppninni um helgina.", + # Measurements + "Vegalengdin er 42,2 km og hitinn fór í 15,5 gráður í morgun.", + # E-mail, telephone, domain, hashtag, URL, username + "Sendu póst á jon@example.is eða hringdu í síma 581-2345 fyrir hádegi.", + "Umræðan um #veðrið á vefnum visir.is var fjörug í gærkvöldi.", + "Nánari upplýsingar má finna á https://greynir.is um helgina.", + "Hún merkti @jonjons og @gudrun í færslunni á miðlinum.", + # Number with letter, street names + "Fjölskyldan flutti af Laugavegi 12 á Skólavörðustíg 4b.", + # Abbreviations, meanings without declension info + "Hr. Jón og dr. Páll komu ásamt fleiri gestum á fundinn.", + # Personal and reflexive pronouns + "Hann meiddi sig illa og hún skammaði sjálfa sig fyrir það.", + # Impersonal verbs, oblique subjects + "Mig langar að fara heim því að mér leiðist svo hérna.", + # Middle voice verbs + "Þeir hittust í bænum og ræddust lengi við um málið.", + # Past participle, supine + "Verkinu er löngu lokið og húsið hefur verið málað að utan.", + # Adjectives with subject case, comparatives, superlatives + "Hún er samþykk tillögunni en þetta er samt besta lausnin og miklu skemmtilegri en hinar.", + # Unknown/foreign words, proper name terminals + "Forritið TensorFlow og tólið grep eru notuð í verkefninu.", + # Companies and entities + "Microsoft Word er vinsælt forrit frá Microsoft Corporation.", + "Össur hf. og Marel seldu vörur til útlanda í fyrra.", + # Molecules + "Losun CO2 jókst um fimm prósent milli ára.", + # Quotes and punctuation variety (does not currently parse, + # but exercises punctuation and literal terminal matching) + "„Komdu sæll,“ sagði hún — og hann svaraði: „Sömuleiðis!“", + # Undeclinable and declinable number words + "Tuttugu og þrír hestar, fimm kýr og tólf kindur voru á bænum.", + # Expletive verbs + "Það snjóaði mikið í nótt og það verður kalt á morgun.", + # Question words, interrogative form + "Hvenær kemur þú og hverjir verða með þér í ferðinni?", + # Roman ordinal, year range + "Elísabet II. Bretadrottning ríkti frá 1952 til 2022.", +] + +# Token kinds that the corpus above must produce, so that native +# matching is exercised (or correctly bypassed) for all of them +REQUIRED_TOKEN_KINDS = frozenset( + ( + TOK.WORD, + TOK.PERSON, + TOK.ENTITY, + TOK.COMPANY, + TOK.PUNCTUATION, + TOK.NUMBER, + TOK.NUMWLETTER, + TOK.ORDINAL, + TOK.PERCENT, + TOK.AMOUNT, + TOK.YEAR, + TOK.DATEABS, + TOK.DATEREL, + TOK.TIME, + TOK.TIMESTAMPABS, + TOK.TIMESTAMPREL, + TOK.MEASUREMENT, + TOK.DOMAIN, + TOK.HASHTAG, + TOK.EMAIL, + TOK.TELNO, + TOK.URL, + TOK.MOLECULE, + TOK.USERNAME, + ) +) + + +def _parse_results(disable_native: bool): + """Parse the corpus with a fresh parser, native matching on or off. + Returns, per sentence, the number of parse tree combinations in the + forest (or None if the sentence did not parse). Note that we compare + forest sizes rather than reduced trees, since the reducer may break + exact score ties differently between runs; the forest itself is + fully determined by the token/terminal match results.""" + key = "GREYNIR_DISABLE_CPP_MATCHING" + old = os.environ.get(key) + try: + if disable_native: + os.environ[key] = "1" + else: + os.environ.pop(key, None) + # Discard the cached parser so that a fresh one is constructed + # under the current environment settings + Greynir.cleanup() + g = Greynir() + results = [] + for s in SENTENCES: + r = g.parse_single(s) + results.append(None if r.tree is None else r.combinations) + assert g.parser.uses_native_matching == (not disable_native) + return results + finally: + if old is None: + os.environ.pop(key, None) + else: + os.environ[key] = old + Greynir.cleanup() + + +def test_native_matching_enabled_by_default(): + fp = Fast_Parser() + try: + assert fp.uses_native_matching + finally: + fp.cleanup() + + +def test_corpus_token_coverage(): + """The corpus must produce all required token kinds, so that the + other tests in this module exercise the full matching surface""" + g = Greynir() + kinds = set() + for s in SENTENCES: + for t in g.tokenize(s): + kinds.add(t.kind) + missing = REQUIRED_TOKEN_KINDS - kinds + assert not missing, "Corpus does not produce token kinds: {0}".format( + ", ".join(sorted(TOK.descr[k] for k in missing)) + ) + + +def test_native_python_equivalence(): + """The native fast path must produce exactly the same parse forests + as Python matching""" + native = _parse_results(disable_native=False) + python = _parse_results(disable_native=True) + assert native == python + # Sanity check: nearly all of the corpus should actually parse + assert sum(1 for r in native if r is not None) >= len(SENTENCES) - 2 + + +def test_parity_mode(): + """In parity mode, every native match evaluation is compared with + the Python matcher; there must be no discrepancies""" + key = "GREYNIR_MATCHING_PARITY" + old = os.environ.get(key) + try: + os.environ[key] = "1" + Greynir.cleanup() + g = Greynir() + for s in SENTENCES: + g.parse_single(s) + fp = g.parser + assert fp.uses_native_matching + assert fp.parity_mismatches == 0 + finally: + if old is None: + os.environ.pop(key, None) + else: + os.environ[key] = old + Greynir.cleanup() + + +def test_gate_subclass_wrap_token(): + """A parser subclass that overrides token wrapping (as e.g. + GreynirCorrect does) must automatically fall back to Python matching""" + + class TokenWrappingParser(Fast_Parser): + @staticmethod + def wrap_token(t, ix): + return BIN_Token(t, ix) + + fp = TokenWrappingParser() + try: + assert not fp.uses_native_matching + finally: + fp.cleanup() + + +def test_gate_class_flag(): + """Setting _USE_CPP_MATCHING = False must disable the fast path""" + + class PythonMatchingParser(Fast_Parser): + _USE_CPP_MATCHING = False + + fp = PythonMatchingParser() + try: + assert not fp.uses_native_matching + finally: + fp.cleanup() diff --git a/test/test_reynir.py b/test/test_reynir.py index d684f34..d73b70a 100644 --- a/test/test_reynir.py +++ b/test/test_reynir.py @@ -540,6 +540,21 @@ def test_compounds(): assert m[0].ordmynd == "Félags- og barnamála-ráðherra" +def test_unknown_options(): + import pytest + + # Unknown options passed to the Greynir constructor should raise, + # not be silently ignored + with pytest.raises(TypeError): + Greynir(no_such_option=True) + with pytest.raises(TypeError): + # Valid for the parse methods, but not for the constructor + Greynir(max_sent_tokens=100) + # Known options should be accepted + g = Greynir(parse_foreign_sentences=True, no_multiply_numbers=True) + assert g is not None + + if __name__ == "__main__": test_augment_terminal() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f497e23 --- /dev/null +++ b/uv.lock @@ -0,0 +1,569 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/94/00f2059e4835eace3ae8fde680b932c496f8ec7bdc99168dfa53fb2e6b79/filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d", size = 71521, upload-time = "2026-07-08T05:46:58.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51", size = 46036, upload-time = "2026-07-08T05:46:57.53Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "islenska" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/c9/c84235e9b5019c7f30440c14b39958fd99554d7de5e5b5cc2cdab5074e80/islenska-1.3.2.tar.gz", hash = "sha256:debd07a1ef9d534f56f01c910bf3c84494ef960212c143e309510336a3b9110e", size = 50397548, upload-time = "2026-06-23T15:27:33.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/32/81bf5ab46fa387de9438c713c33c9768ef5092fc3aeb0cd45cd194779b72/islenska-1.3.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e89fec978e563a62899b66ad23bf90217c262b5073899382867c7d51c393ed3e", size = 50471533, upload-time = "2026-06-23T15:27:08.173Z" }, + { url = "https://files.pythonhosted.org/packages/d8/71/89073daff8d4f34b04aaca339733b6830c984a9be2745664e9afd892f318/islenska-1.3.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:86ccf3430d6cffabf2be1fea01d0a387f9ce8cc3dd2448cfca064844e37aa252", size = 50470361, upload-time = "2026-06-23T15:27:11.342Z" }, + { url = "https://files.pythonhosted.org/packages/26/ed/f5f82bce0c4c688a3c5220c9eeb50c963ff4ae87a900f1d72efb93e3679f/islenska-1.3.2-cp39-abi3-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1df0528480b988cad6ac1b72a8e221fbd36a02452b5b909fc127ee9c0e845a11", size = 51021633, upload-time = "2026-06-23T15:27:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/ed2e603e0a3ed4f8f298b09d0556660ecd140b3ce4b3d3bdc5e95f1937a7/islenska-1.3.2-cp39-abi3-win_amd64.whl", hash = "sha256:abebaaf94fcce9595af6a32298cb08b59a935dad57abb3c0ddf2650ccca97bce", size = 50657669, upload-time = "2026-06-23T15:27:17.674Z" }, + { url = "https://files.pythonhosted.org/packages/34/85/fdd835172a4ab1d910bbd3b6ca6d78748d02b02d946aebaedc538049834d/islenska-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:51a58d7be6240b53a2e60de2012273255168c114413c7331c5311fac362d1909", size = 50464002, upload-time = "2026-06-23T15:27:21.129Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9e/160f283b66bf80481d576b2222ff357e44f75265a7b78be783974a139379/islenska-1.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:165e61c9cf4cd6172bd71077fb2db87dc74bcd5298fd8824d0c08b5db70c73af", size = 50462405, upload-time = "2026-06-23T15:27:24.573Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/efbf0fb9996465e43876c45387a2f852d08a4d9b3fd0b55b1da44545e18b/islenska-1.3.2-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c0ccb5e1c985710dc691c968e943c41b61f5bca0031f293ee239f8d7de71189", size = 50467012, upload-time = "2026-06-23T15:27:27.646Z" }, + { url = "https://files.pythonhosted.org/packages/62/b8/c036792983c7dbe714f55b5bd2e06419d28f129f7eabb2ed50d3987b21f4/islenska-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:988aeadf80d5853910a353d9490a18755522e2a98a2a9424b7342390c2437ed7", size = 50669468, upload-time = "2026-06-23T15:27:30.768Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, + { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "reynir" +version = "3.7.0" +source = { editable = "." } +dependencies = [ + { name = "cffi" }, + { name = "filelock" }, + { name = "islenska" }, + { name = "tokenizer" }, + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy", marker = "implementation_name != 'pypy'" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "setuptools" }, + { name = "types-cffi" }, +] + +[package.metadata] +requires-dist = [ + { name = "cffi", specifier = ">=1.17.1" }, + { name = "filelock", specifier = ">=3.9.0" }, + { name = "islenska", specifier = ">=1.3.2" }, + { name = "tokenizer", specifier = ">=3.6.3" }, + { name = "typing-extensions" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", marker = "implementation_name != 'pypy'" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "setuptools" }, + { name = "types-cffi" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "tokenizer" +version = "3.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/f9/a8df10145499d06b097886dc771a27d7a0c189a186c91f821761ad45a97f/tokenizer-3.6.4.tar.gz", hash = "sha256:1bc7423853e0d2e1430ffdff2c1a4048db3aedd480480c7542ecd2ff8d5fcf58", size = 135242, upload-time = "2026-07-10T12:09:14.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/4e/67d552dd2536b25d7d51776e27c6ccb5ea7112e17bdcd20e8f5a118399d9/tokenizer-3.6.4-py3-none-any.whl", hash = "sha256:ed99ff113688506f16b754341d9569da194d5926a065a7c0555fb024e0c0d9a1", size = 84251, upload-time = "2026-07-10T12:09:13.701Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "types-cffi" +version = "2.0.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/0b/b352742758a6054d1053783887bf8cfb739deda1102fda8722294bdc01f7/types_cffi-2.0.0.20260518.tar.gz", hash = "sha256:f9707e66c13454789a58f8843d1ded4a66f1e9c8b10bd24d5eb5e0f25c0c5472", size = 17790, upload-time = "2026-05-18T06:06:50.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/44/d3b4aafa20a3f76384ba19a513d39272add13746dcfe0409d8d4974fd464/types_cffi-2.0.0.20260518-py3-none-any.whl", hash = "sha256:5b68a215a95d0eac4203b58e766ff7fe40c2e091b1fa1a9e54111f04cc560084", size = 20198, upload-time = "2026-05-18T06:06:49.83Z" }, +] + +[[package]] +name = "types-setuptools" +version = "83.0.0.20260706" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/1d/161cde2b9d56177d04207a67cfdcebaabc881ac9b8f98ef8ca972c630c96/types_setuptools-83.0.0.20260706.tar.gz", hash = "sha256:9e586ac6c1eb504d28b5f06aced1d705e0043839413219ee3c08d16625fbb4ff", size = 45290, upload-time = "2026-07-06T06:16:08.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b0/e070aeec63dc76489261282392c6a3422ed914848365fb9f8481894da520/types_setuptools-83.0.0.20260706-py3-none-any.whl", hash = "sha256:42e5bba1bb7c1dc30c1ef3092f0c224dededbf3ae7db525a854bd1a9c6407241", size = 68747, upload-time = "2026-07-06T06:16:07.492Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]