From 676bb96bb4468411b2093d580b9ccf7ff934e281 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Mon, 13 Jul 2026 22:18:26 +0000 Subject: [PATCH 01/12] Modernize interprocess locking and make binary grammar writes atomic - Write Greynir.grammar.bin via a temp file + os.replace(), so a killed process can never leave a truncated binary grammar behind - Replace the hand-rolled fcntl/msvcrt lock in glock.py with the cross-platform filelock package; the lock file now lives next to the binary grammar, scoping it per installation instead of /tmp - Time out lock acquisition after 180s with a clear GrammarError instead of hanging indefinitely - Skip the file lock entirely on the warm path (binary grammar up to date); thread safety of the class-level grammar caches is now provided by an in-process threading.Lock - Retry os.replace() on transient PermissionError (Windows, when another process has the binary grammar open for reading) - Keep GlobalLock as a thin backwards-compatible shim over filelock - Add CLAUDE.md with build/test commands and architecture notes Co-Authored-By: Claude Fable 5 --- .gitignore | 2 + CLAUDE.md | 99 ++++++++++++++++++++++++ MANIFEST.in | 2 + README.md | 23 ++---- pyproject.toml | 1 + src/reynir/fastparser.py | 104 ++++++++++++++++++++------ src/reynir/glock.py | 157 +++++++-------------------------------- src/reynir/grammar.py | 75 ++++++++++++------- 8 files changed, 268 insertions(+), 195 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 07f27160..ea2e3bb0 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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8386ef78 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# 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.9+ and PyPy 3.11+. + +## Commands + +```sh +# Install for development (compiles the C++ parser extension via CFFI) +pip install -e . + +# Run all tests +python -m pytest + +# Run a single test file / single test +python -m pytest test/test_parse.py +python -m pytest test/test_parse.py::test_long_parse + +# Lint (CI runs this) +ruff check src/reynir + +# Type check (config in mypy.ini, targets Python 3.9) +mypy src/reynir +``` + +Note: `pip install -e .` must be re-run after changing the C++ sources +(`eparser.cpp`, `eparser.h`) or `eparser_build.py`, since the `_eparser` +CFFI 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 on + Python 3.9–3.14 and PyPy 3.11. Wheels are built via cibuildwheel on tag push + (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 55b35f2a..20540f91 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 53a6f6f2..e9a5525d 100644 --- a/README.md +++ b/README.md @@ -219,22 +219,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/pyproject.toml b/pyproject.toml index 0c3925fc..965beaef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "cffi>=1.17.1", "tokenizer>=3.6.0", "islenska>=1.3.2", + "filelock>=3.9.0", "typing_extensions", ] diff --git a/src/reynir/fastparser.py b/src/reynir/fastparser.py index 6f83cb84..17af3ecd 100644 --- a/src/reynir/fastparser.py +++ b/src/reynir/fastparser.py @@ -86,9 +86,10 @@ 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) @@ -678,31 +679,86 @@ 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 + # 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 - ) - # 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() + # 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() def __enter__(self): """Python context manager protocol""" diff --git a/src/reynir/glock.py b/src/reynir/glock.py index 980ba7b3..eaf2cdc8 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 52530340..083fa25e 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(" Date: Mon, 13 Jul 2026 22:35:40 +0000 Subject: [PATCH 02/12] Modernize project configuration: uv, Python 3.10+, PEP 639, CI coverage - Require Python >= 3.10 (3.9 is EOL and held back the toolchain: tokenizer >= 3.6.3, current mypy and setuptools all need 3.10+); bump version to 3.7.0, tokenizer floor to >= 3.6.3 - Adopt uv properly: commit uv.lock, move dev dependencies to PEP 735 [dependency-groups] (adding ruff and mypy; mypy excluded on PyPy where its native dependencies don't build), use astral-sh/setup-uv and 'uv sync --locked' in CI - Switch license metadata to PEP 639 SPDX form (license = "MIT", license-files), drop the deprecated license classifier, require setuptools >= 77.0.3 in the build system - Move mypy configuration into pyproject.toml [tool.mypy] (target 3.10); drop the unused [tool.isort] section - Test on Windows and macOS (one job each) in addition to the Linux matrix, since wheels are shipped for all three platforms - Pin cibuildwheel (4.1.0) in the wheels workflow; build cp310 abi3 wheels; remove vestigial git-lfs configuration (no files use LFS) - Update README and CLAUDE.md accordingly Co-Authored-By: Claude Fable 5 --- .gitattributes | 3 - .github/workflows/python-package.yml | 24 +- .github/workflows/wheels.yml | 13 +- .gitignore | 1 - CLAUDE.md | 39 +- README.md | 17 +- pyproject.toml | 33 +- uv.lock | 546 +++++++++++++++++++++++++++ 8 files changed, 616 insertions(+), 60 deletions(-) create mode 100644 uv.lock diff --git a/.gitattributes b/.gitattributes index b18e6f73..104d310e 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 7b3eccd2..3a861bf1 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -13,25 +13,27 @@ 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 + uses: astral-sh/setup-uv@v8 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: 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 25518794..f0022309 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 ea2e3bb0..533b7596 100644 --- a/.gitignore +++ b/.gitignore @@ -95,7 +95,6 @@ p37/ pypy* # uv stuff -uv.lock .python-version # Installer logs diff --git a/CLAUDE.md b/CLAUDE.md index 8386ef78..c5029cbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,32 +7,38 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 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.9+ and PyPy 3.11+. +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 -# Install for development (compiles the C++ parser extension via CFFI) -pip install -e . +# Set up the dev environment (installs dev deps from uv.lock and +# compiles the C++ parser extension via CFFI) +uv sync # Run all tests -python -m pytest +uv run pytest # Run a single test file / single test -python -m pytest test/test_parse.py -python -m pytest test/test_parse.py::test_long_parse +uv run pytest test/test_parse.py +uv run pytest test/test_parse.py::test_long_parse # Lint (CI runs this) -ruff check src/reynir +uv run ruff check src/reynir -# Type check (config in mypy.ini, targets Python 3.9) -mypy src/reynir +# Type check (config in pyproject.toml [tool.mypy]; carries a handful of +# known pre-existing errors, so it is not a CI gate) +uv run mypy src/reynir ``` -Note: `pip install -e .` must be re-run after changing the C++ sources -(`eparser.cpp`, `eparser.h`) or `eparser_build.py`, since the `_eparser` -CFFI extension is compiled at install time (see `setup.py`, which exists only -for the `cffi_modules` hook; all other metadata is in `pyproject.toml`). +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 @@ -92,8 +98,9 @@ The public API is defined by the exports in `src/reynir/__init__.py`. `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 on - Python 3.9–3.14 and PyPy 3.11. Wheels are built via cibuildwheel on tag push - (abi3 wheel for CPython, version-specific for PyPy). +- 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/README.md b/README.md index e9a5525d..0615445e 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, diff --git a/pyproject.toml b/pyproject.toml index 965beaef..03ca3dfc 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,10 +35,10 @@ 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", @@ -51,9 +50,16 @@ 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'", + "setuptools", +] # *** Configuration of tools *** @@ -78,7 +84,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/uv.lock b/uv.lock new file mode 100644 index 00000000..46a39926 --- /dev/null +++ b/uv.lock @@ -0,0 +1,546 @@ +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" }, +] + +[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" }, +] + +[[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 = "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" }, +] From db8b3ef36df2ce3840baeb7106bcb4f8a03608c0 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Mon, 13 Jul 2026 22:40:57 +0000 Subject: [PATCH 03/12] Fix all mypy errors and make mypy a CI gate - bindb.py: stop reusing 'm' for both List[BIN_Tuple] and the Optional[BIN_Tuple] result of StaticPhrases.lookup() - reynir.py: annotate _Sentence._tree (and the local in parse()) as Optional[Node] instead of letting mypy infer type None - Add types-cffi to the dev dependency group so eparser_build.py type-checks - Run mypy in CI on all non-PyPy jobs now that it is clean Co-Authored-By: Claude Fable 5 --- .github/workflows/python-package.yml | 4 ++++ pyproject.toml | 1 + src/reynir/bindb.py | 8 ++++---- src/reynir/reynir.py | 4 ++-- uv.lock | 23 +++++++++++++++++++++++ 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3a861bf1..d5bbcb72 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -32,6 +32,10 @@ jobs: run: uv sync --locked - name: Lint with ruff 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: uv run pytest - name: Slack notification diff --git a/pyproject.toml b/pyproject.toml index 03ca3dfc..8529fbcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dev = [ "pytest", "ruff", "mypy; implementation_name != 'pypy'", + "types-cffi", "setuptools", ] diff --git a/src/reynir/bindb.py b/src/reynir/bindb.py index 68639e06..1266ff88 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/reynir.py b/src/reynir/reynir.py index bd65c6d3..e76ce02e 100644 --- a/src/reynir/reynir.py +++ b/src/reynir/reynir.py @@ -131,7 +131,7 @@ def __init__(self, job: "_Job", s: TokenList) -> 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 +154,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) diff --git a/uv.lock b/uv.lock index 46a39926..f497e230 100644 --- a/uv.lock +++ b/uv.lock @@ -425,6 +425,7 @@ dev = [ { name = "pytest" }, { name = "ruff" }, { name = "setuptools" }, + { name = "types-cffi" }, ] [package.metadata] @@ -442,6 +443,7 @@ dev = [ { name = "pytest" }, { name = "ruff" }, { name = "setuptools" }, + { name = "types-cffi" }, ] [[package]] @@ -536,6 +538,27 @@ wheels = [ { 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" From 6b018129d73d64a741c0528ce1397b9551482091 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Mon, 13 Jul 2026 22:48:05 +0000 Subject: [PATCH 04/12] Fix tests workflow trigger to match branch names containing '/' A single '*' in a GitHub Actions branch glob does not match '/', so pushes to branches like feat/modernization never triggered the tests workflow; use '**' instead. Co-Authored-By: Claude Fable 5 --- .github/workflows/python-package.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d5bbcb72..11a43371 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: From cf171b636cbe9777e9c0890b16568008f59a4901 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Mon, 13 Jul 2026 22:48:59 +0000 Subject: [PATCH 05/12] Pin setup-uv to exact tag v8.3.2 astral-sh/setup-uv has no floating 'v8' major tag, so @v8 fails to resolve in Actions. Co-Authored-By: Claude Fable 5 --- .github/workflows/python-package.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 11a43371..4adc6044 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,7 +26,8 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v8 + # 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 From 5bad54d6367ab62e33f9580e76d16ecdbc8b02b1 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Mon, 13 Jul 2026 23:03:54 +0000 Subject: [PATCH 06/12] Harden the parser core (eparser.cpp/.h, fastparser.py) C++ core: - Catch all C++ exceptions (out-of-memory in particular) in the extern "C" entry points newGrammar(), newParser() and earleyParse(), returning NULL instead of letting an exception propagate through the C ABI boundary into CFFI (undefined behavior) - Validate the binary grammar in readBinary(): sanity-check symbol counts, require a root index within the nonterminal range, check the previously unchecked production read, and bounds-check every production item; a corrupt or truncated .bin now fails cleanly with newGrammar() == NULL instead of crashing later during parsing. Also fix Nonterminal leaks on the readBinary() error paths. - Compare Labels member-wise instead of memcmp() of the whole struct, which silently relied on the absence of padding - Make the diagnostic allocation counters std::atomic (relaxed), so concurrent parses in multiple threads cause no data races - Add -std=c++11 to the macOS build flags (needed for ; Linux already had it, MSVC supports it natively) Python side: - Cap the token/terminal matching cache at 25,000 entries (~125 MB) to bound memory growth in long-running processes - Convert the interior-node coalescing in Node.from_c_node() from recursion to an explicit stack, removing the recursion depth limit on long coalescible node chains Add test_binary_grammar.py with regression tests for corrupt, truncated and minimal binary grammar files. Co-Authored-By: Claude Fable 5 --- src/reynir/eparser.cpp | 104 +++++++++++++++++++++++------ src/reynir/eparser.h | 31 ++++++--- src/reynir/eparser_build.py | 2 +- src/reynir/fastparser.py | 81 ++++++++++++++--------- test/test_binary_grammar.py | 126 ++++++++++++++++++++++++++++++++++++ 5 files changed, 281 insertions(+), 63 deletions(-) create mode 100644 test/test_binary_grammar.py diff --git a/src/reynir/eparser.cpp b/src/reynir/eparser.cpp index 09a2d2e6..a2968125 100644 --- a/src/reynir/eparser.cpp +++ b/src/reynir/eparser.cpp @@ -388,7 +388,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; @@ -733,18 +734,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 +773,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 @@ -1083,7 +1119,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,7 +1385,7 @@ 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()); @@ -1371,18 +1407,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 +1441,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) @@ -1442,7 +1494,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 6087bc71..08857778 100644 --- a/src/reynir/eparser.h +++ b/src/reynir/eparser.h @@ -45,6 +45,7 @@ #include #include #include +#include // Assert macro @@ -80,10 +81,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 +99,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 +258,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; + } }; diff --git a/src/reynir/eparser_build.py b/src/reynir/eparser_build.py index 26f6b4e6..75ee2a08 100644 --- a/src/reynir/eparser_build.py +++ b/src/reynir/eparser_build.py @@ -130,7 +130,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 17af3ecd..634aa8ec 100644 --- a/src/reynir/fastparser.py +++ b/src/reynir/fastparser.py @@ -339,45 +339,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 @@ -681,6 +686,12 @@ def _load_binary_grammar(cls) -> Any: # 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 # 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) @@ -780,6 +791,12 @@ 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: clear it. + # The cost is only that subsequent parses need to re-match + # tokens against terminals as they are encountered again. + self._matching_cache.clear() + # Use the context manager protocol to guarantee that the parse job # handle will be properly deleted even if an exception is thrown diff --git a/test/test_binary_grammar.py b/test/test_binary_grammar.py new file mode 100644 index 00000000..ed4a1c04 --- /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)) From fc7767924bc6825ee32c078b2ce7e1c3e24b5a80 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Mon, 13 Jul 2026 23:18:54 +0000 Subject: [PATCH 07/12] Raise TypeError on unknown options passed to the Greynir constructor Previously, unrecognized keyword options (e.g. a typo, or max_sent_tokens which belongs to the parse methods) were silently ignored. The constructor now validates options against a class-level frozenset of the known Greynir/bintokenizer/tokenizer options; derived classes accepting additional options can extend the set. Co-Authored-By: Claude Fable 5 --- src/reynir/reynir.py | 36 ++++++++++++++++++++++++++++++++++++ test/test_reynir.py | 15 +++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/reynir/reynir.py b/src/reynir/reynir.py index e76ce02e..629bb5ac 100644 --- a/src/reynir/reynir.py +++ b/src/reynir/reynir.py @@ -36,6 +36,7 @@ from typing import ( Any, + FrozenSet, Iterable, Iterator, Optional, @@ -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_reynir.py b/test/test_reynir.py index d684f34a..d73b70a3 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() From 2c06e89879e58a9d2f9c9537abefaeac65be0389 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Mon, 13 Jul 2026 23:43:16 +0000 Subject: [PATCH 08/12] Add design sketch for moving token/terminal matching into C++ Based on profiling showing ~40% of fresh-text parse time spent in C-to-Python matching callbacks. Key findings grounding the design: terminal variants already reduce to a 39-bit space (VariantHandler vbits/fbits), token meanings map into the same space via get_fbits(), and 4,893 of 6,012 grammar terminals are literals whose matching is pure interned-string identity plus bit tests. Proposes a TerminalSpec table and per-token MeaningRec arrays with a T_PYTHON escape hatch, GreynirCorrect-compatible gating, and a phased rollout with a parity harness. Co-Authored-By: Claude Fable 5 --- doc/cpp-matching-design.md | 187 +++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 doc/cpp-matching-design.md diff --git a/doc/cpp-matching-design.md b/doc/cpp-matching-design.md new file mode 100644 index 00000000..080657d5 --- /dev/null +++ b/doc/cpp-matching-design.md @@ -0,0 +1,187 @@ +# Design sketch: moving token/terminal matching into the C++ core + +Status: DRAFT for discussion — 2026-07-13 + +## 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. From 978c6d0f9efc9b06a4a36468ad7b448058f77487 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Tue, 14 Jul 2026 00:22:43 +0000 Subject: [PATCH 09/12] Implement native (C++) token/terminal matching (Phases 0-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the majority of token/terminal match decisions from Python callbacks into the C++ Earley core, per doc/cpp-matching-design.md. - binparser.py: build_matching_table() classifies each terminal into a native matching kind (strong/lemma literals, default-category, noun, adjective, adverb, masked abfn/pfn, töl) or T_PYTHON for semantics that stay in Python (verbs, prepositions, proper names, ending constraints, etc.); encode_token_matching_data() packs each token's BÍN meanings into MeaningRec arrays sharing the existing VBIT/fbits bit space. Literal lemmas/forms are interned so literal matching reduces to integer identity. - eparser.cpp/.h: Parser::evalMatch() decides matches natively from the TerminalSpec table and per-column TokenRec data (fetched once per column via the new MeaningsFunc callback and cached per token key); Column::matches() falls back to the Python callback for T_PYTHON terminals and non-word tokens. A parity mode double-checks every native decision against the Python matcher. - fastparser.py: install the table at parser construction, gated so that subclasses which override token wrapping (e.g. GreynirCorrect) automatically keep pure Python matching; also disable via the _USE_CPP_MATCHING class attribute or GREYNIR_DISABLE_CPP_MATCHING=1. Parity mode via GREYNIR_MATCHING_PARITY=1. Phase 0 measurement: 69% of match queries are natively answerable. Measured effect: ~89% of matching callbacks eliminated; typical fresh-text parsing 2.2x faster on CPython 3.13 (1.40s -> 0.65s for the benchmark set), test suite ~20% faster, PyPy ~20% faster cold; warm-cache and long-sentence workloads unchanged. Query-level parity: zero discrepancies over the test corpus. Incidental pre-existing finding, documented in the design doc: reduction of exact score ties is unstable across repeated parses, so the new equivalence test compares forests, not reduced trees. Co-Authored-By: Claude Fable 5 --- doc/cpp-matching-design.md | 34 ++++- src/reynir/binparser.py | 252 +++++++++++++++++++++++++++++++++++ src/reynir/eparser.cpp | 206 +++++++++++++++++++++++++++- src/reynir/eparser.h | 117 ++++++++++++++++ src/reynir/eparser_build.py | 6 + src/reynir/fastparser.py | 114 +++++++++++++++- test/test_native_matching.py | 135 +++++++++++++++++++ 7 files changed, 858 insertions(+), 6 deletions(-) create mode 100644 test/test_native_matching.py diff --git a/doc/cpp-matching-design.md b/doc/cpp-matching-design.md index 080657d5..8846bcff 100644 --- a/doc/cpp-matching-design.md +++ b/doc/cpp-matching-design.md @@ -1,6 +1,38 @@ # Design sketch: moving token/terminal matching into the C++ core -Status: DRAFT for discussion — 2026-07-13 +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 diff --git a/src/reynir/binparser.py b/src/reynir/binparser.py index dbd8de1b..ed733518 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 a2968125..07a16e94 100644 --- a/src/reynir/eparser.cpp +++ b/src/reynir/eparser.cpp @@ -174,6 +174,7 @@ 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 @@ -463,6 +464,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++; @@ -506,8 +508,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) @@ -517,6 +523,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) @@ -607,7 +614,30 @@ 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()); + 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; @@ -1035,14 +1065,164 @@ 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; +} + +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; + } + this->m_nSpecs = 0; + this->m_pMeaningsFunc = NULL; + this->m_bParity = bParity; + this->m_nParityMismatches = 0; + memset(&this->m_masks, 0, sizeof(MatchMasks)); + if (!pSpecs || !nSpecs || !fpMeanings || !pMasks) + return; + this->m_pSpecs = new TerminalSpec[nSpecs]; + 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) @@ -1456,6 +1636,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) diff --git a/src/reynir/eparser.h b/src/reynir/eparser.h index 08857778..2ef00e3b 100644 --- a/src/reynir/eparser.h +++ b/src/reynir/eparser.h @@ -62,6 +62,7 @@ typedef wchar_t WCHAR; typedef char CHAR; typedef unsigned char BYTE; typedef bool BOOL; +typedef unsigned long long UINT64; class Production; @@ -322,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 @@ -341,6 +416,14 @@ 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 + UINT m_nParityMismatches; + void push(UINT nHandle, State*, Column*, State*&, StateChunk*); Node* makeNode(State* pState, UINT nEnd, Node* pV, NodeDict& ndV); @@ -365,6 +448,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++; } + UINT getParityMismatches(void) const + { return this->m_nParityMismatches; } + + // 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); @@ -385,6 +493,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 75ee2a08..9d976da7 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); """ diff --git a/src/reynir/fastparser.py b/src/reynir/fastparser.py index 634aa8ec..9983166c 100644 --- a/src/reynir/fastparser.py +++ b/src/reynir/fastparser.py @@ -81,6 +81,9 @@ from .binparser import ( BIN_Parser, BIN_Token, + MatchingTable, + build_matching_table, + encode_token_matching_data, simplify_terminal, augment_terminal, Tok, @@ -122,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 @@ -129,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 @@ -150,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() @@ -176,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: @@ -183,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 @@ -202,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 @@ -227,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, @@ -692,6 +741,29 @@ def _load_binary_grammar(cls) -> Any: # 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) @@ -770,6 +842,23 @@ def _initialize(self, verbose: bool, root: Optional[str]) -> None: # 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, + ) + self._matching_table = table def __enter__(self): """Python context manager protocol""" @@ -796,12 +885,18 @@ def go(self, tokens: Iterable[Tok], *, root: Optional[str] = None) -> Node: # The cost is only that subsequent parses need to re-match # tokens against terminals as they are encountered again. self._matching_cache.clear() + self._meanings_cache.clear() # 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 @@ -850,6 +945,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/test/test_native_matching.py b/test/test_native_matching.py new file mode 100644 index 00000000..949fcb15 --- /dev/null +++ b/test/test_native_matching.py @@ -0,0 +1,135 @@ +""" + + 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 reynir import Greynir +from reynir.binparser import BIN_Token +from reynir.fastparser import Fast_Parser + +# A corpus exercising a variety of token and terminal kinds: +# regular words, literal terminals, abbreviations, numbers, amounts, +# dates, person names, entities, unknown words and punctuation +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.", +] + + +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_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: most of the corpus should actually parse + assert sum(1 for r in native if r is not None) >= len(SENTENCES) - 1 + + +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() From b690b0d65f46e9002f0b792d76429d86a895d873 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Tue, 14 Jul 2026 00:30:51 +0000 Subject: [PATCH 10/12] Add allocation counters for native matching tables Instrument the TerminalSpec table allocations in setMatchingTable() with an AllocCounter, following the codebase convention, so that printAllocationReport() shows the table balance. Also count native match evaluations separately, so the report shows the native/Python split of matching calls. Co-Authored-By: Claude Fable 5 --- src/reynir/eparser.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/reynir/eparser.cpp b/src/reynir/eparser.cpp index 07a16e94..ae9dcdf5 100644 --- a/src/reynir/eparser.cpp +++ b/src/reynir/eparser.cpp @@ -180,6 +180,7 @@ friend class AllocReporter; static AllocCounter ac; static AllocCounter acMatches; + static AllocCounter acNativeMatches; protected: @@ -457,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), @@ -626,6 +631,7 @@ BOOL Column::matches(UINT nHandle, UINT nTerminal) const (((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 @@ -1076,8 +1082,10 @@ Parser::Parser(Grammar* p, MatchingFunc pMatchingFunc, AllocFunc pAllocFunc) Parser::~Parser(void) { - if (this->m_pSpecs) + if (this->m_pSpecs) { delete [] this->m_pSpecs; + acMatchingTables--; + } } void Parser::setMatchingTable(const BYTE* pSpecs, UINT nSpecs, @@ -1089,6 +1097,7 @@ void Parser::setMatchingTable(const BYTE* pSpecs, UINT nSpecs, if (this->m_pSpecs) { delete [] this->m_pSpecs; this->m_pSpecs = NULL; + acMatchingTables--; } this->m_nSpecs = 0; this->m_pMeaningsFunc = NULL; @@ -1098,6 +1107,7 @@ void Parser::setMatchingTable(const BYTE* pSpecs, UINT nSpecs, 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)); @@ -1571,6 +1581,8 @@ void AllocReporter::report(void) const 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 } From 4a846b88ba3d92070103ed507e5cc690033c46a4 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Tue, 14 Jul 2026 00:39:26 +0000 Subject: [PATCH 11/12] Expand the native matching test corpus for full token kind coverage Grow the parity/equivalence corpus from 10 to 39 sentences, covering all producible token kinds (verified by a new coverage test asserting 24 required kinds) and all families of token/terminal matching: literal terminals, category terminals, verb frames with impersonal/ oblique subjects, middle voice, past participle and expletives, pronouns, degrees and subject-case adjectives, abbreviations, unknown/foreign words, street and person names, and the non-word token kinds (amounts, percentages, dates, times, timestamps, measurements, e-mail, URLs, domains, hashtags, usernames, telephone numbers, molecules, companies and entities). Co-Authored-By: Claude Fable 5 --- test/test_native_matching.py | 118 +++++++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 5 deletions(-) diff --git a/test/test_native_matching.py b/test/test_native_matching.py index 949fcb15..04911df2 100644 --- a/test/test_native_matching.py +++ b/test/test_native_matching.py @@ -13,13 +13,24 @@ 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 a variety of token and terminal kinds: -# regular words, literal terminals, abbreviations, numbers, amounts, -# dates, person names, entities, unknown words and punctuation +# 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.", @@ -31,8 +42,91 @@ "Þ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. @@ -74,14 +168,28 @@ def test_native_matching_enabled_by_default(): 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: most of the corpus should actually parse - assert sum(1 for r in native if r is not None) >= len(SENTENCES) - 1 + # 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(): From fb2d28632468c7bd704a2f86aee576dcfada1b75 Mon Sep 17 00:00:00 2001 From: Vilhjalmur Thorsteinsson Date: Tue, 14 Jul 2026 01:04:34 +0000 Subject: [PATCH 12/12] Address Copilot review comments on PR #62 - fastparser.py: replace the shared matching/meanings cache dicts instead of clearing them in place when the size cap is hit; in-flight parse jobs 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 (avoiding a potential use-after-free) - eparser.h/.cpp: make the parity mismatch counter a relaxed atomic, consistent with the other counters, since it is incremented from within concurrent parses - CLAUDE.md: update stale guidance - mypy is now clean and runs as a CI gate on non-PyPy jobs Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 ++-- src/reynir/eparser.cpp | 2 +- src/reynir/eparser.h | 8 +++++--- src/reynir/fastparser.py | 10 +++++++--- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c5029cbc..e828fc42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,8 @@ 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]; carries a handful of -# known pre-existing errors, so it is not a CI gate) +# 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 ``` diff --git a/src/reynir/eparser.cpp b/src/reynir/eparser.cpp index ae9dcdf5..83d0d625 100644 --- a/src/reynir/eparser.cpp +++ b/src/reynir/eparser.cpp @@ -1102,7 +1102,7 @@ void Parser::setMatchingTable(const BYTE* pSpecs, UINT nSpecs, this->m_nSpecs = 0; this->m_pMeaningsFunc = NULL; this->m_bParity = bParity; - this->m_nParityMismatches = 0; + this->m_nParityMismatches.store(0, std::memory_order_relaxed); memset(&this->m_masks, 0, sizeof(MatchMasks)); if (!pSpecs || !nSpecs || !fpMeanings || !pMasks) return; diff --git a/src/reynir/eparser.h b/src/reynir/eparser.h index 2ef00e3b..a9b5ae8a 100644 --- a/src/reynir/eparser.h +++ b/src/reynir/eparser.h @@ -422,7 +422,9 @@ class Parser { MeaningsFunc m_pMeaningsFunc; MatchMasks m_masks; BOOL m_bParity; // Parity checking mode - UINT m_nParityMismatches; + // 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*); @@ -466,9 +468,9 @@ class Parser { BOOL parityMode(void) const { return this->m_bParity; } void countParityMismatch(void) - { this->m_nParityMismatches++; } + { this->m_nParityMismatches.fetch_add(1, std::memory_order_relaxed); } UINT getParityMismatches(void) const - { return this->m_nParityMismatches; } + { 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&); diff --git a/src/reynir/fastparser.py b/src/reynir/fastparser.py index 9983166c..2387278f 100644 --- a/src/reynir/fastparser.py +++ b/src/reynir/fastparser.py @@ -881,11 +881,15 @@ def go(self, tokens: Iterable[Tok], *, root: Optional[str] = None) -> Node: result: Optional[Node] = None if len(self._matching_cache) > self._MAX_MATCHING_CACHE_SIZE: - # The matching cache has grown too large: clear it. + # 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. - self._matching_cache.clear() - self._meanings_cache.clear() + # 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