diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index aacd72a7..943f2ee9 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -61,3 +61,40 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml fail_ci_if_error: false + + # The optional nameparser[ja] segmenter (#272). Its own job, not a + # matrix entry: the matrix above must keep proving that the package + # installs and passes with NO third-party dependency at all, which is + # the promise the extra is carved out of. Everything the extra adds + # is under tests/v2/ (the ja pack's integration tests, which skip + # without it). + ja-extra: + runs-on: ubuntu-latest + # 3.14, not the matrix's newest: namedivider-python pulls lightgbm, + # which pulls scipy/numpy, and those have no 3.15 wheels yet (the + # sdist build fails). 3.15 is pre-release and this matrix is ahead + # of the ecosystem; revisit when it goes final. + env: + UV_PYTHON: "3.14" + steps: + - uses: actions/checkout@v7 + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --group dev --extra ja + - name: Run Tests + # The import check first: every test the extra adds is guarded by + # a find_spec("namedivider") skipif, so a job where the extra + # silently failed to install is a job where all of them SKIP and + # the step still passes green. This line is what makes that + # failure loud -- without it the job proves nothing on the day it + # matters. + run: | + uv run python -c "import namedivider" + uv run pytest tests/v2/ -q diff --git a/AGENTS.md b/AGENTS.md index 6cbb4b9a..29633d59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ logging.getLogger('HumanName').setLevel(logging.DEBUG) The library has two layers: `nameparser/config/` (data) and `nameparser/parser.py` (logic). -**Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule above is about Latin *transliteration*, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Three defaults fall out of it. Wholly-Han and wholly-Hangul names read family-first (`Policy.script_orders`) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (`config/surnames.py` ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Han segmentation stays OPT-IN (`locales.ZH`) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎 (Japanese needs #272's pluggable segmenter). Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes. +**Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule above is about Latin *transliteration*, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Four defaults fall out of it. Wholly-Han, wholly-Hangul and kana-licensed names read family-first (`Policy.script_orders`) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (`config/surnames.py` ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Hiragana licenses Japanese (#272) — a name whose characters stay inside Han∪kana while carrying at least one kana cannot be Chinese (the kana rules it out) and is not a transcription (foreign names are transcribed in katakana ALONE, マイケル has no kanji), so 高橋みなみ and 山田 エミ read family-first too; mechanically they resolve to the HIRAGANA entry, the license's carrier key. PURE katakana is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order. The nakaguro belongs to the same doctrine but is decided a layer down: U+30FB and its halfwidth twin U+FF65 separate tokens like whitespace, unconditionally and in tokenize, so neither policy opt-out (`script_orders={}`, `segment_scripts=()`) reaches it — the codepoints are CJK-only and appear in no other script's names, which is what licenses a tokenize-level rule where U+00B7 (also the Catalan punt volat, interior to Gal·la) would need a guard. Han segmentation stays OPT-IN (`locales.ZH` for Chinese, `locales.JA` for Japanese) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎; no surname list divides a kanji name at all, so `locales.JA` activates the stage and a pluggable `Parser(segmenter=...)` does the dividing. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes. ### Configuration layer (`nameparser/config/`) @@ -118,7 +118,7 @@ The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These - **Module layout**: every new module is underscore-private (`_types.py`, `_lexicon.py`, `_policy.py`, `_locale.py`, `_render.py`, `_pipeline/`, `_parser.py`, plus the facade layer: `_facade.py`, `_config_shim.py`). The public import surface is exactly `nameparser` and `nameparser.locales`; `nameparser/__init__.py` holds re-exports and `__all__` only — no logic. Since the M11 swap, the old paths are import-path-preserving re-exports: `nameparser.parser` re-exports the `_facade` `HumanName`, `nameparser.config` re-exports the `_config_shim` names (`Constants`, `CONSTANTS`, `SetManager`, `TupleManager`, `RegexTupleManager`); the `config/` DATA modules stay the vocabulary source through 2.x. The whole facade layer is deleted in 3.0. - **Facade layer** (`_facade.py`, `_config_shim.py`): the v1-compat `HumanName`/`Constants` over the core. Key mechanisms: `Constants._generation` dirty-tracking (every mutation bumps; facades resolve their `Parser` lazily via `_cached_parser(lexicon, policy)`); `Constants._snapshot()` mirrors `_lexicon._default_lexicon()` (equality-pinned); the facade pickles v1-SHAPED state (component lists, one `__setstate__` path for 1.4 and 2.x blobs; components rebuild via `replace()`, never a re-parse); `_V1_HOOKS` overrides warn once per subclass (#280). The compat contract is the migration spec's promise: warning-free 1.4 code behaves identically except release-log-classified fixes — `tools/differential/` (dev-only, not shipped) verifies this against 1.4-on-PyPI over a checked-in corpus of ~650 names (no exact count here: `corpus_issues.jsonl` grows whenever it is regenerated, and the run prints its own per-file totals) (two files: `corpus.jsonl` from the v1 test banks at a pinned ref, `corpus_issues.jsonl` harvested from the issue tracker; `compare.py` globs `corpus*.jsonl` and fails loudly if none match). `parser.py:NNNN` citations throughout the 2.0 code refer to the PRE-swap v1 file, deleted at the M11 swap; resolve them with `git show 2d5d8c2:nameparser/parser.py`. -- **Layering is enforced by `tests/v2/test_layering.py`** (exact-module matching; `if TYPE_CHECKING:` imports don't count): `_types` imports nothing internal at module level — its rendering delegates import `_render` at call time; `_lexicon` and `_policy` sit above `_types` independently (`_lexicon` may import `nameparser.config.*` DATA modules only — vocabulary is single-sourced from the v1 data modules through 2.x, e.g. `config/maiden_markers.py`); `_locale` sits on `_lexicon`+`_policy` (plus `_types` for the shared pickle mixin); `_render` imports `_types` and `_lexicon` (for `Lexicon.default()` and `_normalize`); `_pipeline/*` imports `_types`+`_lexicon`+`_policy` plus in-package `_pipeline` helpers; `_parser` sits on everything except `_render`; the facade layer (`_facade`, `_config_shim`, `parser`, `config/__init__`, `__main__`) may import anything public plus `_render`; locale pack modules (`locales/*.py`) import `_locale`/`_lexicon`/`_policy` only, and the `locales/__init__` additionally lazy-imports its packs (PEP 562). Extend the test's `ALLOWED` table when adding a module. +- **Layering is enforced by `tests/v2/test_layering.py`** (exact-module matching; `if TYPE_CHECKING:` imports don't count): `_types` imports nothing internal at module level — its rendering delegates import `_render` at call time; `_lexicon` and `_policy` sit above `_types` independently (`_lexicon` may import `nameparser.config.*` DATA modules only — vocabulary is single-sourced from the v1 data modules through 2.x, e.g. `config/maiden_markers.py`); `_locale` sits on `_lexicon`+`_policy` (plus `_types` for the shared pickle mixin); `_render` imports `_types` and `_lexicon` (for `Lexicon.default()` and `_normalize`); `_pipeline/*` imports `_types`+`_lexicon`+`_policy` plus in-package `_pipeline` helpers; `_parser` sits on everything except `_render`; the facade layer (`_facade`, `_config_shim`, `parser`, `config/__init__`, `__main__`) may import anything public plus `_render`; locale pack modules (`locales/*.py`) import `_locale`/`_lexicon`/`_policy`/`_types` only (`_types` joined the list in #272 — `locales/ja.py`'s segmenter factory constructs a `Segmentation`), and the `locales/__init__` additionally lazy-imports its packs (PEP 562). Extend the test's `ALLOWED` table when adding a module. - **Canonical field order** — the seven roles in `Role` enum declaration order, defined once and derived everywhere (properties, `as_dict`, reprs, `comparison_key`). Never restate the order literally. `Role` is a `StrEnum`: members compare as their field-name strings, and `tokens_for()` coerces strings via `_coerce_enum`. - **Method organization**, fixed section order in every class: fields + `__post_init__` validation → alternative constructors → dunders (construction/equality → protocol → operators) → properties → public methods by concern (access → editing → comparison → rendering delegates) → private helpers last, except a helper serving exactly one section may sit at that section's head. Sanctioned deviation, facade layer only: `HumanName` and the shim `Constants` organize by v1 concern groups (`# -- render defaults --`, `# -- config / parsing --`, `# -- fields --`, ..., dunders and pickle last) — the classes mirror v1's own surface and die in 3.0; the canonical order still binds every core type. - **Validation is eager and fail-loud**: every `raise` states the offending value, the expected form, and the fix. Exception taxonomy: wrong type — including wrong element type inside a collection, bare `str` where an iterable of strings is expected, or a `Mapping` where a plain iterable is expected — raises `TypeError`; well-typed but unacceptable values raise `ValueError`; failed enum lookups stay `ValueError` for any input (stdlib `EnumType` precedent). @@ -131,7 +131,8 @@ The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These - **The shim TRANSLATES; it never raises on a config v1 accepted, and never silently changes the parse**: `Constants._snapshot()` is a translation boundary between v1's model and v2's invariants, and every transformation there carries its v1-reachability argument in a comment. Four exist today — `first_name_titles` re-folded per word (v1 joins-then-`lc`, v2 normalizes-then-joins), `suffix_acronyms_ambiguous ∩ acronyms` (a provable no-op), `suffix_words − ambiguous` (v1 already accepts the word via the acronym branch, so the addition is inert there), and `particles_ambiguous ∪ (bound ∩ particles)` (a pinned deviation, `test_bound_never_given_prefix_deviates_on_two_pieces`). When a v1 config cannot satisfy a v2 invariant, work out what v1 actually *does* with it — usually nothing — and reproduce that; weakening the invariant or letting the raise through are both wrong. **Test the case the translation decides**, not one where both branches agree: a test using an input v1 parses identically with and without the config pins nothing. - **Reprs are bounded**: render which fields deviate from a named baseline and by how much, never contents (`Lexicon(default + titles: +2)`). `PolicyPatch`'s repr shows only set (non-UNSET) fields; `_order_repr` must never raise even on an unvalidated patch's garbage `name_order` (PolicyPatch defers validation to apply time); the sweep test in `tests/v2/test_reprs.py` pins that no config repr leaks the UNSET sentinel. - **Typing/docs**: `from __future__ import annotations`; `frozen=True, slots=True` on every public dataclass; strict-profile mypy flags via per-module overrides in pyproject (`strict = true` itself is not valid per-module). Docstrings state contracts in prose with **no doctest blocks** — `--doctest-modules` makes every example a test; behavior examples go to unit tests per the lean-docs rule. **Document the positive direction of a partial property**: "a non-empty `ambiguities` is a signal to act on" is checkable, while "an empty one means no fork occurred" is a universal negative needing exhaustive verification -- that claim was written twice and falsified twice, at sites the author had not audited. -- **Pickling**: v2 types must round-trip (`Parser` will be picklable by construction, and it holds a `Lexicon`). Every frozen type assigns `_guarded_getstate`/`_guarded_setstate` (`_types.py`) in its class body (`@dataclass(slots=True)` would override inherited pickle methods) — unpickling fails at the LOAD site on field-layout skew, and values are deliberately NOT re-validated (pickle is not a security boundary; canonical state comes from a validated instance). `Lexicon` keeps its own copy of the guard (layering) plus the `mappingproxy` slot rebuild; a new unpicklable slot type needs the same treatment plus a round-trip test. +- **The segmenter contract**: the optional `Parser(segmenter=...)` hook is parse-totality's ONE exception (locales spec section 4). Everything inside that exception is a bug in USER CODE, never a fact about the name, so it is surfaced rather than absorbed: the segmenter's own exceptions propagate, and the two protocol violations the stage can detect for itself — an answer of the wrong type, and one cutting at or past the end of the token it was handed — raise `TypeError`/`ValueError` from `_script_segment` for the same reason. The line to hold when adding a check there: a protocol violation by the segmenter's AUTHOR raises, while an adapter's defense against its own third-party library (`locales/ja.py`'s repertoire, length, reconstruction and score guards) declines with `None`, because what those catch is a fact about the content. +- **Pickling**: v2 types must round-trip (`Parser` is picklable by construction, and it holds a `Lexicon`; the one qualifier is that a `Parser` pickles iff its segmenter does — see the segmenter bullet above). Every frozen type assigns `_guarded_getstate`/`_guarded_setstate` (`_types.py`) in its class body (`@dataclass(slots=True)` would override inherited pickle methods) — unpickling fails at the LOAD site on field-layout skew, and values are deliberately NOT re-validated (pickle is not a security boundary; canonical state comes from a validated instance). `Lexicon` keeps its own copy of the guard (layering) plus the `mappingproxy` slot rebuild; a new unpicklable slot type needs the same treatment plus a round-trip test. - **One sanctioned global**: the (future) cached default `Parser`. Lazily cached FROZEN singletons (`Lexicon.default()`'s `functools.cache`, the future default parser) are constants, not state; any second piece of module-level MUTABLE state requires amending the conventions doc, on purpose, in review. Sanctioned exceptions, facade layer only: `_config_shim.CONSTANTS` (the v1 shared singleton, mutable by design) and `_facade._WARNED_SUBCLASSES` (the once-per-subclass hook-warning dedup set) — both deleted with the layer in 3.0. - **Tests**: all v2 tests live in the `tests/v2/` package (its `conftest.py` overrides the v1 dual-run fixture — v2 code never reads shared `CONSTANTS`), one test module per source module plus the cross-cutting ones (`test_reprs.py`, `test_layering.py`, `test_contracts.py`, `test_properties.py`, `test_benchmark.py`, the `cases.py`/`test_cases.py` table, and `test_regex_sync.py`, which pins every hand-copied pattern or codepoint table against its source wherever the copy lives — including copies outside the package), names stating behavior. Never assert `Lexicon.default()` contents; the narrow sourcing spot-checks in `test_default_sources_v1_vocabulary` that pin the v1→v2 migration contract (e.g. the flipped `particles_ambiguous` model) are the sanctioned exception. diff --git a/docs/concepts.rst b/docs/concepts.rst index dda5d825..25b612b9 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -146,7 +146,11 @@ Honest ambiguity Parsing never raises. Pass in a string that doesn't look like a name at all, and you get back a :class:`~nameparser.ParsedName` with empty fields, not an exception. The parser's job is to make a reasonable -call on real-world text, not to reject it. +call on real-world text, not to reject it. The single exception is +code you supplied yourself: a :data:`~nameparser.Segmenter` passed to +``Parser(segmenter=...)`` runs inside the parse, and its own +exceptions propagate rather than being swallowed — a failure there is +a bug in your callable, not a fact about the name. Some calls are irreducibly ambiguous — both readings are legitimate, and no amount of rule-tuning resolves them without breaking some other diff --git a/docs/customize.rst b/docs/customize.rst index 126fa409..2f29d704 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -307,12 +307,12 @@ East Asian defaults, and turning them off ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Two defaults key on the *script* a name is written in rather than on -anything you set: a name written wholly in Han or Hangul is assigned -family-first (``script_orders``), and an unspaced hangul name is split -into surname and given name against the shipped Korean census list -(``segment_scripts``). :ref:`east-asian-names` explains the naming -conventions both rest on — this section is how to switch them off, -which you can do separately: +anything you set: a name written wholly in Han or Hangul — or one +mixing kanji with kana — is assigned family-first (``script_orders``), +and an unspaced hangul name is split into surname and given name +against the shipped Korean census list (``segment_scripts``). +:ref:`east-asian-names` explains the naming conventions both rest on — +this section is how to switch them off, which you can do separately: .. doctest:: @@ -345,6 +345,18 @@ Chinese surnames are deliberately absent from that default set, because splitting Han text requires knowing Chinese from Japanese; :doc:`locales` covers the opt-in ``zh`` pack that supplies them. +The Japanese behaviors ride these same two fields, so they need no +switches of their own: ``script_orders={}`` clears the kana-licensed +entry along with the Han and Hangul ones, and ``segment_scripts=()`` +deactivates every script at once, which also stops a parser consulting +whatever segmenter it was given. The segmenter has an off-switch as +well — ``Parser(segmenter=None)``, which is the default; see +:ref:`segmenter-contract` for what one is expected to do with text it +does not handle. One Japanese behavior is not a policy field at all: +the katakana middle dot ・ separates tokens the way a space does, +decided in tokenization, so it applies however these two fields are +set. + .. note:: Both fields are annotated with their canonical *storage* type diff --git a/docs/locales.rst b/docs/locales.rst index 8dba47ba..300a2671 100644 --- a/docs/locales.rst +++ b/docs/locales.rst @@ -61,17 +61,18 @@ background (covered fully under :ref:`east-asian-names` in :doc:`usage`): Chinese, Japanese, and Korean names put the family name first in native script and are usually written with no space between the parts. Both defaults follow from facts the script alone -establishes. A name written wholly in Han or Hangul is assigned +establishes. A name written wholly in Han or Hangul — or one mixing +kanji with kana, a combination only Japanese produces — is assigned family-first, because every language written in those scripts orders -names that way — no language guess is involved. An unspaced hangul +names that way; no language guess is involved. An unspaced hangul name is additionally split into surname and given name, because hangul is written by nothing but Korean and Korean surnames are a closed census set that ships as default vocabulary. Splitting an unspaced *Han* name is the one behavior the script cannot license — the same characters could be a Chinese or a Japanese name, and a Chinese surname list splits Japanese names in the wrong place — so it is not a -default: the ``zh`` pack below turns it on when you can declare the -data Chinese. +default: the ``zh`` and ``ja`` packs below turn it on for data whose +language you can declare. A pack is for something different: a *structural* rule, like reordering a patronymic, that vocabulary alone can't express. @@ -109,7 +110,7 @@ takes: .. doctest:: >>> locales.available() - ('ru', 'tr_az', 'zh') + ('ja', 'ru', 'tr_az', 'zh') >>> locales.get("ru") is locales.RU True @@ -123,6 +124,14 @@ parsing, equivalent to ``parser_for(locales.get("ru"))``. * - Code - Turns on + * - ``ja`` + - Japanese segmentation — activates division for unspaced + Japanese names, which needs a segmenter to act: install + ``nameparser[ja]`` and pass + ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` + (``山田太郎`` → family ``山田``, given ``太郎``). The pack ships + no surname list, because no list divides a kanji name, and no + order: a Japanese name already reads family-first by default. * - ``ru`` - East Slavic patronymic order — detects a formal given/patronymic/family shape (Cyrillic and transliterated @@ -139,9 +148,9 @@ parsing, equivalent to ``parser_for(locales.get("ru"))``. name order: native-script Han already reads family-first without a pack. -``ru`` and ``tr_az`` are policy-only — they carry no vocabulary of -their own. ``zh`` is both halves at once: a surname list, plus the one -policy field that turns segmentation on for the script it covers. See +``ja``, ``ru`` and ``tr_az`` are policy-only — they carry no vocabulary +of their own. ``zh`` is both halves at once: a surname list, plus the +one policy field that turns segmentation on for the script it covers. See :doc:`concepts` for how that split (language vocabulary vs. behavior) is drawn, and `Contributing a pack to nameparser`_ for which half a new naming rule belongs in. @@ -167,6 +176,47 @@ new naming rule belongs in. mixes traditions, parse the subsets separately with different parsers rather than enabling a pack over all of it. +.. _segmenter-contract: + +Segmenters +----------- + +``ja`` is policy-only in a second sense: it turns division on for +Japanese text without supplying anything to divide with. That job goes +to a **segmenter**, which is passed to +:func:`~nameparser.parser_for` rather than carried by the pack — a +:class:`~nameparser.Locale` is pure data, and a third-party callable is +neither pure nor data. :func:`~nameparser.locales.ja_segmenter` is the +shipped one; writing your own is worth it for any script whose +divisions you know better than a surname list does. + +A :data:`~nameparser.Segmenter` is any callable taking a token's text +and returning a :class:`~nameparser.Segmentation` — the interior +offsets to cut at, plus how confident you are — or ``None`` to decline, +leaving the token whole. Declining is the load-bearing half of the +contract, because ``segment_scripts`` unions across packs: your +segmenter is offered every token of every activated script, not only +the ones its own pack turned on. Recognize the text you can actually +read and return ``None`` for the rest, rather than answering for a +script you never meant to handle. Exceptions are the one thing that +does not stay inside the parse — a segmenter is your code, so its +errors propagate out of ``parse()`` instead of being absorbed as +content errors. + +The pack half of that arrangement carries no words at all, which makes +it the shortest kind of pack there is: + +.. doctest:: + + >>> from nameparser import Lexicon, Locale, PolicyPatch, Script + >>> mine = Locale(code="myscript", lexicon=Lexicon.empty(), + ... policy=PolicyPatch( + ... segment_scripts=frozenset({Script.HAN}))) + +``nameparser/locales/ja.py`` is the shipped example of exactly that +shape: activation is the pack's entire contribution, and a pack +applied without a segmenter simply divides nothing. + Creating your own Locale ------------------------- @@ -237,11 +287,14 @@ by ``tests/v2/test_locales.py``: needs at least one name exercising every alternation branch of every regex it defines — ``test_rotators_cover_every_marker_branch`` fails until each branch is hit. A pack declaring by *codepoint range* - (``zh``) has no branches to sweep and drops out of that test, so its - rotators have to carry the same weight by hand: the unspaced names - the pack must split, one per shape of the vocabulary it ships — - single surname, compound surname, and any spelling variant it means - to cover. + (``zh``, ``ja``) has no branches to sweep and drops out of that + test, so its rotators have to carry the same weight by hand: the + unspaced names the pack must split, one per shape of the vocabulary + it ships — single surname, compound surname, and any spelling + variant it means to cover. A pack that ships no vocabulary lists + the shapes its *segmenter* must divide instead, and marks the + rotator tests to skip when the optional dependency is absent, so + the contract tests still run everywhere. #. Keep the non-interference gate green over the shared corpus plus your rotators: every name the packed parser parses differently from the default must be one your ``DEVIATES`` predicate flags — no @@ -256,16 +309,17 @@ by ``tests/v2/test_locales.py``: language its script does not* — a Chinese surname list, which silently mangles the Japanese names written in the same characters — belongs in the pack, where asking for it is the declaration. - ``ru`` and ``tr_az`` need no vocabulary at all and ship an empty - :class:`~nameparser.Lexicon`; ``nameparser/locales/zh.py`` is the - template for one that does. + ``ja``, ``ru`` and ``tr_az`` need no vocabulary at all and ship an + empty :class:`~nameparser.Lexicon`; ``nameparser/locales/zh.py`` is + the template for one that does. #. Curate vocabulary conservatively, the same rule as :doc:`customize`: when you're unsure whether a word or a marker belongs, leave it out. ``nameparser/locales/ru.py`` is the reference implementation for a policy-only pack, ``nameparser/locales/zh.py`` for one that carries -vocabulary. Packs still in progress are tracked in issues `#272 -`_ (Japanese) -and `#146 `_ +vocabulary, and ``nameparser/locales/ja.py`` for one whose whole +contribution is turning a stage on. Packs still in progress are +tracked in issue `#146 +`_ (Vietnamese). diff --git a/docs/migrate.rst b/docs/migrate.rst index 9760b37b..3ede3878 100644 --- a/docs/migrate.rst +++ b/docs/migrate.rst @@ -340,10 +340,11 @@ custom suffix delimiter configured, a no-space delimiter group renders whole (``"RN/CRNA"``) where 1.x split it (``"RN, CRNA"``) — the role assignment is identical, only the rendered string differs. -2.1 adds two more, and unlike most of the 2.0 API these do reach -``HumanName``: a name written wholly in Han or Hangul is read -family-first, and an unspaced Korean name is split into surname and -given name. +2.1 adds three more, and unlike most of the 2.0 API these do reach +``HumanName``: a name written in East Asian script is read +family-first, an unspaced Korean name is split into surname and given +name, and the katakana middle dot separates tokens the way a space +does. .. doctest:: @@ -377,7 +378,58 @@ Both were ``first`` under 1.4. If you feed unspaced CJK through reach you, and it is silent — the string is intact, just in the other field. +Japanese kana carries the same order rule, which widens both shapes +past the wholly-Han text described above. A name that mixes kanji with +hiragana or katakana is read family-first, and a lone kana-bearing +token moves from ``first`` to ``last`` exactly as ``毛泽东`` does: + +.. doctest:: + + >>> HumanName("高橋 みなみ").last, HumanName("高橋 みなみ").first + ('高橋', 'みなみ') + >>> HumanName("山田 エミ").last + '山田' + >>> HumanName("高橋みなみ").last, HumanName("高橋みなみ").first + ('高橋みなみ', '') + +1.4 read the spaced ones as ``first="高橋"``/``last="みなみ"`` and +``first="山田"``/``last="エミ"``, and put the unspaced one whole in +``first``. The spaced shapes change what the name renders as too: +``str(HumanName("高橋 みなみ"))`` was ``"高橋 みなみ"`` and is now +``"みなみ 高橋"``. A name written *wholly* in katakana is deliberately +left alone — it is usually a transcribed foreign name already in +given-first order — so ``HumanName("マイケル ジャクソン")`` reads +``first="マイケル"``/``last="ジャクソン"`` on both versions. + +One more shape changes for a different reason: the katakana middle dot +``・``, which divides the parts of such a transcription, is now a token +separator rather than an ordinary character. 1.4 saw one token and put +it in ``first``; 2.1 sees two: + +.. doctest:: + + >>> HumanName("マイケル・ジャクソン").first + 'マイケル' + >>> HumanName("マイケル・ジャクソン").last + 'ジャクソン' + >>> HumanName("高橋・一郎").last, HumanName("高橋・一郎").first + ('高橋', '一郎') + +The two divide the same way and land in opposite fields, because the +katakana pair keeps its source order while the kanji pair takes the +family-first rule. Separating also changes the rendered string, the +same way the Korean split does: the dot comes back as a space, so +``str(HumanName("マイケル・ジャクソン"))`` was ``"マイケル・ジャクソン"`` +and is now ``"マイケル ジャクソン"``. That reaches delimited content +too — the nickname in ``"山田 太郎 (マイケル・ジャクソン)"`` was +``"マイケル・ジャクソン"`` under 1.4 and is ``"マイケル ジャクソン"`` +now. + ``Constants`` has no switch for any of this — the v1 configuration surface is frozen for 2.x — so the way out is the 2.0 API: ``Parser(policy=Policy(script_orders={}, segment_scripts=()))`` -restores 1.4's reading of all three shapes. +restores 1.4's reading of every shape above that turns on order or +splitting. The middle dot is the one exception: it is decided in +tokenization rather than by policy, so a name written with one still +divides at the dot, and still renders with a space, whatever those two +fields are set to. diff --git a/docs/modules.rst b/docs/modules.rst index 8c8e7f60..7f50deea 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -14,6 +14,25 @@ Parsing .. autofunction:: nameparser.parser_for +.. autoclass:: nameparser.Segmentation + :members: + +.. py:data:: nameparser.Segmenter + :value: Callable[[str], Segmentation | None] + + The type of the optional ``Parser(segmenter=...)`` hook: a callable + given one token's text, returning a + :class:`~nameparser.Segmentation` that divides it, or ``None`` to + decline and leave it whole. An alias, not a class — any callable of + that shape qualifies, and nothing needs to be subclassed or + registered. It is consulted only for tokens whose script is listed + in :attr:`Policy.segment_scripts + `, and only where the surname + vocabulary declined first; see :ref:`segmenter-contract` for what a + segmenter owes its caller. + :func:`~nameparser.locales.ja_segmenter` is the shipped + implementation. + Results ~~~~~~~ @@ -106,10 +125,15 @@ because only these three orders have defined assignment semantics. e.g. Vietnamese full-name order. .. py:data:: nameparser.DEFAULT_SCRIPT_ORDERS - :value: ((Script.HAN, FAMILY_FIRST), (Script.HANGUL, FAMILY_FIRST)) + :value: ((Script.HAN, FAMILY_FIRST), (Script.HANGUL, FAMILY_FIRST), (Script.HIRAGANA, FAMILY_FIRST)) The default :attr:`~nameparser.Policy.script_orders` table: a name - written wholly in Han or Hangul reads family-first. A matching + written wholly in Han or Hangul reads family-first, and so does a + Japanese name mixing kanji with kana, which resolves to the + ``HIRAGANA`` entry whichever of the two syllabaries it actually + uses — that member is the license's carrier key, not a claim about + the characters present. A name written wholly in katakana has no + entry on purpose and stays positional. A matching entry in this table takes precedence over ``name_order``, including a ``name_order`` you set explicitly — ``name_order`` governs only the names no entry matches. The values are drawn from the same @@ -147,6 +171,8 @@ Locales .. automodule:: nameparser.locales :members: get, available +.. autofunction:: nameparser.locales.ja_segmenter + 1.x compatibility layer ------------------------ diff --git a/docs/release_log.rst b/docs/release_log.rst index 95b9a354..0cae824a 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -4,18 +4,26 @@ Release Log **East Asian name support** - - Add the Chinese locale pack ``locales.ZH`` — opt-in Han segmentation for unspaced names like ``毛泽东``, with the surname vocabulary it needs. A pack rather than a default because a Chinese surname list corrupts the Japanese names written in the same characters (``高橋一郎`` would split ``高`` + ``橋一郎``); Japanese needs the pluggable segmenter staged in #272. It sets no name order, since native-script Han already reads family-first without it. ``locales.available()`` is now ``('ru', 'tr_az', 'zh')`` (#271) + - Add the Chinese locale pack ``locales.ZH`` — opt-in Han segmentation for unspaced names like ``毛泽东``, with the surname vocabulary it needs. A pack rather than a default because a Chinese surname list corrupts the Japanese names written in the same characters (``高橋一郎`` would split ``高`` + ``橋一郎``); Japanese data goes through ``locales.JA`` and its segmenter instead. It sets no name order, since native-script Han already reads family-first without it (#271) - Add ``Lexicon.surnames``, ``Policy.script_orders``, ``Policy.segment_scripts``, the ``Script`` enum and the ``DEFAULT_SCRIPT_ORDERS`` constant to the public API. This is the first behavior nameparser keys on the script a name is written in; it is allowed only where the script itself settles a convention, never as a proxy for guessing the language (#271) - Add ``AmbiguityKind.SEGMENTATION``, reported when a surname split had a vocabulary-supported alternative: ``"남궁민수"`` is 남궁 + 민수 by the compound surname but 남 + 궁민수 by the single-syllable one, and longest-match had to pick. A name with only one possible split decided nothing and reports nothing (#271) + - Add the Japanese locale pack ``locales.JA`` and the segmenter factory ``locales.ja_segmenter()``, which together divide an unspaced Japanese name: ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` reads ``山田太郎`` as family ``山田``, given ``太郎``. The two halves are separate because no surname list can do this job — family and given names draw on the same kanji and the reading, not the spelling, decides most divisions — so the pack activates the stage and a third-party divider performs it. ``ja_segmenter()`` wraps `namedivider-python `_, installed with the new ``nameparser[ja]`` extra; the core stays dependency-free, and ``ja_segmenter(gbdt=True)`` selects namedivider's more accurate gradient-boosted model, which downloads its data on first use. With both packs registered, ``locales.available()`` is now ``('ja', 'ru', 'tr_az', 'zh')`` (closes #272) + - Add ``Segmentation`` and the ``Segmenter`` type alias to the public API, plus the keyword-only ``Parser(segmenter=...)`` hook they describe: any callable from a token's text to a ``Segmentation`` (the interior offsets to cut at, and a confidence) or ``None`` to decline. It is consulted only for scripts listed in ``Policy.segment_scripts``, and only where the surname vocabulary declined first, so ``parser_for(locales.ZH, locales.JA, segmenter=...)`` composes — a listed Chinese surname wins, the segmenter takes the rest. Read that composition with the zh pack's own warning still attached: vocabulary-first means a Japanese kanji name opening on a listed Chinese surname never reaches the segmenter, so ``高橋一郎`` still splits ``高`` + ``橋一郎`` under the stack exactly as it does under ``locales.ZH`` alone. The two packs are alternatives, one per corpus; stack them only for genuinely mixed data that accepts that trade (#272) + - Add the ``Script`` members ``HIRAGANA`` and ``KATAKANA``. Two members rather than one ``KANA`` because the parser treats them differently: hiragana never transcribes a foreign name, while a wholly-katakana name usually is one (#272) **Breaking Changes** - Change the pickle compatibility of the 2.0 API's ``Policy`` and ``Lexicon``: the new fields change the field layout their guarded ``__setstate__`` checks, so a pickle written by 2.0.0 raises ``ValueError`` naming the missing fields instead of loading. Re-pickle after upgrading. ``HumanName`` pickles are unaffected — the facade pickles v1-shaped component state, not these objects + - Change the pickle compatibility of ``Parser`` in the same way and for the same reason: the new ``segmenter`` field changes the layout its guarded ``__setstate__`` checks, so a ``Parser`` pickled by 2.0.x raises ``ValueError`` naming the missing field rather than loading silently with the field absent. That guard is the design, not a regression — re-pickle after upgrading. A ``Parser`` carrying a segmenter pickles only if that segmenter does, which a module-level function does and a closure or lambda does not (#272) + - Change one thing about parse totality: ``parse()`` still never raises on any input, but a user-supplied ``Parser(segmenter=...)`` runs inside the parse and its own exceptions propagate rather than being absorbed. A failure there is a bug in your callable, not a fact about the name, and hiding it would only make it harder to find (#272) **Behavior Changes** - Fix names written wholly in Han or Hangul parsing given-first: native-script CJK now reads family-first by default, through the new ``Policy.script_orders`` table, so ``"毛 泽东"`` gives family ``毛`` where 1.x gave family ``泽东``. A name that is a single unspaced token moves the same way — ``"毛泽东"`` and ``"山田太郎"`` now land in ``family``/``last`` where 1.x put them in ``given``/``first``, with the string itself untouched, which makes it the easiest form of this change to miss — a lone token renders the same whichever field holds it, whereas the spaced form does show up in the output (``str(HumanName("毛 泽东"))`` is now ``"泽东 毛"``). No language detection is involved: Chinese and Japanese both write the family name first in native script, so the script settles the order without anyone having to know which language it is. Latin-script and mixed-script names are never affected, and an explicit comma still wins. **Default-on: changes parse output for wholly-CJK names**, through ``HumanName`` as well as the 2.0 API (closes #271) - Fix unspaced Korean names not splitting: the census surname list now ships as default vocabulary (``Lexicon.surnames``) with hangul segmentation on by default (``Policy.segment_scripts``), so ``"김민준"`` parses family ``김``, given ``민준`` where 1.x returned the whole string as ``first``. Rendering follows the split, so ``str(HumanName("김민준"))`` is now ``"민준 김"`` where 1.x echoed the input back unchanged. Nothing but Korean is written in hangul and its surnames are a closed census set, which is what makes the split safe as a default rather than a pack. **Default-on**, and it reaches ``HumanName`` too. ``Policy(segment_scripts=())`` turns the split off; ``Policy(script_orders={})`` separately restores the positional reading; clearing both restores 2.0 behavior exactly (#271) + - Fix Japanese names carrying kana parsing given-first: the family-first rule above extends to any name whose characters stay within kanji and kana while carrying at least one kana character, so ``"高橋 みなみ"`` gives family ``高橋`` and ``"山田 エミ"`` family ``山田`` where 1.x read both the other way round, and a lone such token (``"高橋みなみ"``, ``"みなみ"``) lands in ``family``/``last`` where 1.x put it in ``given``/``first``. The reasoning is the one hangul already uses: hiragana never transcribes a foreign name, and a transcription is kana ALONE, so kanji-plus-kana is a Japanese person's name written in Japanese order. A name written **wholly in katakana** is deliberately excluded and stays positional — it is predominantly a transcribed foreign name (``"マイケル ジャクソン"``) already in given-first order. **Default-on: changes parse output for kana-bearing Japanese names**, through ``HumanName`` as well as the 2.0 API, and the spaced forms change what the name renders as (``str(HumanName("高橋 みなみ"))`` is now ``"みなみ 高橋"``). ``Policy(script_orders={})`` clears this entry along with the Han and Hangul ones (#272) + - Fix the katakana middle dot ``・`` (U+30FB, and its halfwidth twin U+FF65) being read as part of a name rather than as the divider it is: it now separates tokens exactly as a space does. A transcribed foreign name therefore divides into its parts and, being wholly katakana, keeps its source order — ``"マイケル・ジャクソン"`` gives given ``マイケル``, family ``ジャクソン``, where 1.x left the whole string in ``first`` — while a kanji pair written the same way takes the family-first rule (``"高橋・一郎"`` → family ``高橋``). Native Japanese names never contain this character and no other script's names use it, so the separation is unconditional, which also means it is **not** covered by the two policy opt-outs. Rendering follows, the way the Korean split's does: the dot comes back as a space, so ``str(HumanName("マイケル・ジャクソン"))`` is now ``"マイケル ジャクソン"``, and that reaches delimited content too — the nickname in ``"山田 太郎 (マイケル・ジャクソン)"`` renders ``"マイケル ジャクソン"``. The Chinese interpunct ``·`` is deliberately NOT included: U+00B7 is also the Catalan punt volat and appears inside legitimate names (``Gal·la``) (#272) + - Fix NFD-decomposed input missing the East Asian defaults entirely: script classification now normalizes to NFC before deciding, so a Korean or Japanese name typed on macOS — where decomposed text is routine — gets the same order rule as its composed twin, which it silently did not before. Segmentation MATCHING deliberately stays raw, so an unspaced NFD hangul name is ordered correctly but not split, rather than being split in the wrong place. One gotcha worth stating plainly: parse output preserves the encoding it was given, so for NFD input ``name.family == "김"`` is ``False`` even though it is the same name — compare NFC-normalized text when comparing across encodings (#272) * 2.0.0 - July 27, 2026 diff --git a/docs/usage.rst b/docs/usage.rst index 7eed0661..61f249ad 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -52,9 +52,9 @@ given-then-family: 'Doe' For family-first input *without* a comma — common outside Europe — set -``name_order``; see :doc:`customize`. Names written in Han or Hangul -are the exception that needs no setting at all: see `East Asian -names`_ below. +``name_order``; see :doc:`customize`. Names written in Han or Hangul, +and Japanese names written in kanji and kana, are the exception that +needs no setting at all: see `East Asian names`_ below. Words that attach to their neighbors -------------------------------------- @@ -180,11 +180,7 @@ does not identify the language: 高橋一郎 is a Japanese name whose family name is 高橋, but 高 alone is a common Chinese surname, so a Chinese surname list would split it in the wrong place. Declaring the language is up to you. When you know the data is Chinese, apply the -``zh`` locale pack; Japanese needs a dictionary-backed segmenter and -is tracked as `#272 -`_ — until -then an unspaced Japanese name stays whole, in the family field per -the assignment rule above: +``zh`` locale pack, which carries the surname list the split needs: .. doctest:: @@ -192,18 +188,132 @@ the assignment rule above: >>> parser_for(locales.ZH).parse("毛泽东").family '毛' -Three boundaries on all of the above. Romanized names ("Kim Min-jun") -are Latin script and follow the ordinary positional rules — order -genuinely varies in romanized data, so nothing script-based applies. -A comma disables both behaviors, on the reasoning ``name_order`` -already follows: whoever wrote the comma has already said where the -family name ends. And when an unspaced name has more than one -vocabulary-supported split — ``남궁민수`` is 남궁 + 민수 by the -two-syllable surname but 남 + 궁민수 by the single-syllable one — the -longest surname wins and the parse records the decision as an -``AmbiguityKind.SEGMENTATION``, described under `When the parser had -to guess`_; a name with only one possible split reports nothing. -:doc:`customize` covers turning either behavior off. +Japanese +~~~~~~~~~ + +Japanese writes a name in three scripts at once. Family names and most +given names are kanji — the same characters Chinese uses — but a given +name is often written in one of the two kana syllabaries instead: +hiragana (高橋みなみ) or katakana (山田エミ). The two syllabaries carry +different information about whose name it is. Hiragana never +transcribes a foreign name, so a name that mixes kanji and kana is a +Japanese person's name, written in Japanese order. Katakana is +ambiguous: native given names use it, but katakana is also how +Japanese text writes a *foreign* name — マイケル・ジャクソン is Michael +Jackson — and a transcription keeps the source language's order, given +name first, its parts divided by the middle dot ・ (the nakaguro, +U+30FB) rather than by a space. + +Two behaviors follow from that without any configuration. A name whose +characters stay within kanji and kana and carry at least one kana is +assigned family-first, like any other native-script East Asian name: it +cannot be Chinese, and it is not a transcription, because a +transcription would have been kana alone. + +.. doctest:: + + >>> minami = parse("高橋 みなみ") + >>> minami.family, minami.given + ('高橋', 'みなみ') + >>> parse("山田 エミ").family + '山田' + +And the middle dot separates tokens the way a space does, so a +transcribed foreign name divides into its parts — which, being wholly +katakana, keep the order they were written in: + +.. doctest:: + + >>> michael = parse("マイケル・ジャクソン") + >>> michael.given, michael.family + ('マイケル', 'ジャクソン') + +Dividing an *unspaced* Japanese name is a separate matter, and one no +surname list can settle: family and given names draw on the same +kanji, both sides run one to four characters, and the reading rather +than the spelling decides most divisions. nameparser therefore takes a +**segmenter** — a callable from token text to a division — and ships a +factory wrapping `namedivider-python +`_, an optional +dependency installed with the ``ja`` extra: + +.. code-block:: console + + $ pip install "nameparser[ja]" + +.. code-block:: python + + from nameparser import locales, parser_for + + parser = parser_for(locales.JA, segmenter=locales.ja_segmenter()) + parser.parse("山田太郎").family # '山田' + +Both halves are required, and they do different jobs: the ``ja`` pack +activates division for Japanese text, and the segmenter performs it. +``ja_segmenter()`` wraps namedivider's ``BasicNameDivider``, which +reads data bundled in the installed package; +``ja_segmenter(gbdt=True)`` selects its gradient-boosted divider +instead, which is more accurate and downloads its model and surname +files from the network on first use — worth knowing before deploying +it somewhere sandboxed or air-gapped. + +Boundaries +~~~~~~~~~~~ + +Several boundaries apply to all of the above. Romanized names ("Kim +Min-jun", "Yamada Taro") are Latin script and follow the ordinary +positional rules — order genuinely varies in romanized data, so +nothing script-based applies. A name written wholly in katakana stays +positional for the reason given above, pack or no pack: it is +predominantly a transcription, and a transcription is already in the +order it should be read in. And a comma disables the script behaviors +entirely, on the reasoning ``name_order`` already follows: whoever +wrote the comma has already said where the family name ends. + +A division the parser had to choose is reported rather than hidden. +When an unspaced name has more than one vocabulary-supported split — +``남궁민수`` is 남궁 + 민수 by the two-syllable surname but 남 + 궁민수 +by the single-syllable one — the longest surname wins and the parse +records the decision as an ``AmbiguityKind.SEGMENTATION``, described +under `When the parser had to guess`_; a name with only one possible +split reports nothing. A segmenter's answer is reported on the same +kind whenever its confidence falls below the stage's floor, naming the +division and the score in the report's ``detail``:: + + "'山田太郎' splits as '山田' + '太郎' on a segmenter answer scoring 0.44, under the 0.9 confidence floor" + +With namedivider that line separates its two kinds of answer. A +division it states as a rule — the kanji-to-kana boundary in 高橋みなみ +— scores 1.0 and reports nothing; a division read off kanji statistics +scores far below the floor and always reports. Read the report as a +statement about the *kind* of answer, not as a measure of how likely +this particular one is to be wrong. + +A lone two-character kanji name divides one character to each side, on +namedivider's rule for that length. A name that short carries no +evidence of where its own boundary falls, and one character each way is +the presumption Japanese practice makes; that is an accepted +presumption, not a measurement, so a two-character token is the shape +to check first if a division looks wrong. + +A segmenter that answers outside the token it was given — a cut at or +past the end of the text — has violated the protocol, and the parse +says so rather than hiding it: it raises ``ValueError`` naming the +offending offset and the token's length, the way an answer of the +wrong type raises ``TypeError``. Declining silently would leave an +off-by-one segmenter invisible; every answer it gave would vanish and +the name would merely look undivided. The shipped ``ja_segmenter()`` +does decline — returns ``None``, token left whole — for the cases that +are *not* protocol violations: text outside the Japanese repertoire, +text too short to divide, an answer that fails to reconstruct its +input, and a score outside [0, 1]. Exceptions are the one thing that +does *not* stay inside the parse: a segmenter is your code, so its +errors propagate rather than being absorbed as content errors. + +The command line takes the pack but not the segmenter: ``python -m +nameparser --locale ja`` has no way to attach one, so it activates +nothing by itself. :doc:`customize` covers turning any of these +behaviors off. Aggregate views ---------------- diff --git a/nameparser/__init__.py b/nameparser/__init__.py index a433a76e..19afbd93 100644 --- a/nameparser/__init__.py +++ b/nameparser/__init__.py @@ -27,6 +27,8 @@ AmbiguityKind, ParsedName, Role, + Segmentation, + Segmenter, Span, Token, ) @@ -36,7 +38,7 @@ "HumanName", # v2 core "Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName", - "STABLE_TAGS", + "STABLE_TAGS", "Segmentation", "Segmenter", "Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "Script", "UNSET", "GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST", "DEFAULT_NICKNAME_DELIMITERS", "DEFAULT_SCRIPT_ORDERS", "Locale", diff --git a/nameparser/_parser.py b/nameparser/_parser.py index 6389ce6b..d7d73a71 100644 --- a/nameparser/_parser.py +++ b/nameparser/_parser.py @@ -11,17 +11,17 @@ import dataclasses import functools import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field from nameparser._lexicon import Lexicon from nameparser._locale import Locale from nameparser._pipeline import run from nameparser._pipeline._assemble import assemble from nameparser._pipeline._state import ParseState -from nameparser._policy import UNSET, Policy, PolicyPatch, apply_patch +from nameparser._policy import UNSET, Policy, PolicyPatch, _Unset, apply_patch from nameparser._types import ( - FOLDED_TAG, ParsedName, Token, _guarded_getstate, _guarded_setstate, - _validated_field_strings, + FOLDED_TAG, ParsedName, Segmenter, Token, _guarded_getstate, + _guarded_setstate, _validated_field_strings, ) @@ -30,18 +30,37 @@ class Parser: """A configured name parser: a :class:`Lexicon` (vocabulary) plus a :class:`Policy` (behavior), both defaulted when omitted. Build one when you need non-default configuration, build it once, and - call :meth:`parse` many times -- it is immutable, thread-safe, and - picklable by construction: all validity checking happens at + call :meth:`parse` many times -- it is immutable and thread-safe. + + An optional keyword-only ``segmenter`` (a :data:`~nameparser.Segmenter`) + plugs in outside knowledge of where an unspaced CJK token divides -- + Japanese kanji names, which no bundled list can settle. It is + consulted only for a token the segmentation stage gates in and the + vocabulary DECLINES, so a locale pack's surnames always win where + they match; returning None declines in turn and the token stays + whole. Two promises narrow when one is supplied (locales spec §4): + parse-totality gains its one exception -- an exception raised by + the segmenter propagates, because a user-supplied callable's own + error is a user-code error, not a content error -- and this Parser + pickles only if its segmenter does (a module-level function + pickles; a lambda or closure does not). With no segmenter, both + promises hold unconditionally: all validity checking happens at construction, so a Parser that constructs successfully cannot fail at parse time on any str content. (The None field defaults resolve in __post_init__; after - construction both fields are always non-None -- the annotations - state the steady-state truth, hence the assignment ignores on the - defaults.)""" + construction lexicon and policy are always non-None -- the + annotations state the steady-state truth, hence the assignment + ignores on the defaults.)""" lexicon: Lexicon = None # type: ignore[assignment] # None -> default() policy: Policy = None # type: ignore[assignment] # None -> Policy() + #: An optional hook supplying outside knowledge of where an unspaced + #: token divides -- see the class docstring; None leaves such tokens + #: whole. Keyword-only, so the reserved growth stays additive + #: (locales spec §4): positional construction keeps its two-argument + #: shape. + segmenter: Segmenter | None = field(default=None, kw_only=True) # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate @@ -58,16 +77,32 @@ def __post_init__(self) -> None: elif not isinstance(self.policy, Policy): raise TypeError( f"policy must be a Policy or None, got {self.policy!r}") + if self.segmenter is not None and not callable(self.segmenter): + raise TypeError( + f"segmenter must be callable or None, got {self.segmenter!r}") def __repr__(self) -> str: - # composes the two bounded component reprs (spec §2 reprs) - return f"Parser({self.lexicon!r}, {self.policy!r})" + # composes the two bounded component reprs (spec §2 reprs); the + # segmenter shows by name, and only when one is set, so the + # default Parser's repr is unchanged + seg = "" + if self.segmenter is not None: + # never repr() the callable itself: a partial reprs its + # bound arguments and a callable instance its address, both + # unbounded -- the class name is the bounded fallback + name = (getattr(self.segmenter, "__qualname__", None) + or type(self.segmenter).__name__) + seg = f", segmenter={name}" + return f"Parser({self.lexicon!r}, {self.policy!r}{seg})" def parse(self, text: str) -> ParsedName: """Parse one name string into a :class:`ParsedName`. Never raises on string content (unparseable input yields empty fields plus ambiguities); non-str raises TypeError eagerly, - with a decode hint for bytes (bytes support ended with 1.x).""" + with a decode hint for bytes (bytes support ended with 1.x). + The one exception to that totality is a configured + ``segmenter``, whose own exceptions propagate (see the class + docstring).""" if isinstance(text, bytes): raise TypeError( "parse() takes str, not bytes -- decode first, e.g. " @@ -75,7 +110,7 @@ def parse(self, text: str) -> ParsedName: if not isinstance(text, str): raise TypeError(f"parse() takes str, got {text!r}") state = ParseState(original=text, lexicon=self.lexicon, - policy=self.policy) + policy=self.policy, segmenter=self.segmenter) return assemble(run(state)) # -- editing ---------------------------------------------------------- @@ -151,14 +186,27 @@ def parse(text: str) -> ParsedName: return _default_parser().parse(text) -def parser_for(*locales: Locale, base: Parser | None = None) -> Parser: +def parser_for(*locales: Locale, base: Parser | None = None, + segmenter: Segmenter | None | _Unset = UNSET) -> Parser: """Lexicon fragments unioned left-to-right onto base's; policy patches applied left-to-right (later wins; set-valued fields union per the patch metadata). Validation errors raised while applying a pack are wrapped with that pack's identity (spec §4 amendment) -- PolicyPatch validates lazily, so with stacked packs the raw error would otherwise point at nothing. Two packs setting the same SCALAR - field is a declared conflict: UserWarning, later wins.""" + field is a declared conflict: UserWarning, later wins. + + A ``segmenter`` is passed straight through to the built Parser -- + ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` is how + a pack and a segmenter combine, since packs are pure data and + cannot supply one. The argument has THREE states, the same + :data:`~nameparser.UNSET` spelling a PolicyPatch field uses, because + None is a meaningful value here and not an absence: omitted (UNSET) + carries base's segmenter through unchanged; a callable OVERRIDES + base's (later wins, the rule scalar policy fields follow); and an + explicit ``None`` CLEARS base's, which is how you derive an + unsegmented parser from a segmented one without rebuilding its + lexicon and policy by hand.""" if base is not None and not isinstance(base, Parser): raise TypeError(f"base must be a Parser or None, got {base!r}") for loc in locales: @@ -166,6 +214,15 @@ def parser_for(*locales: Locale, base: Parser | None = None) -> Parser: raise TypeError(f"parser_for() takes Locale packs, got {loc!r}") lexicon = base.lexicon if base is not None else Lexicon.default() policy = base.policy if base is not None else Policy() + # Resolved here rather than at the return because the return builds + # a FRESH Parser: any field not listed there silently takes its + # default, and a dropped segmenter would be invisible. UNSET, not + # None, is what "not given" means -- None is the CLEAR request, and + # collapsing the two would make an explicit + # parser_for(..., segmenter=None) silently inherit the very + # segmenter it was asked to drop. + if segmenter is UNSET: + segmenter = base.segmenter if base is not None else None scalar_setters: dict[str, str] = {} for loc in locales: for f in dataclasses.fields(PolicyPatch): @@ -189,4 +246,4 @@ def parser_for(*locales: Locale, base: Parser | None = None) -> Parser: # a subclass with extra mandatory args would break this rewrap raise type(exc)( f"while applying locale {loc.code!r}: {exc}") from exc - return Parser(lexicon=lexicon, policy=policy) + return Parser(lexicon=lexicon, policy=policy, segmenter=segmenter) diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py index 5d5fa89c..36723418 100644 --- a/nameparser/_pipeline/_assign.py +++ b/nameparser/_pipeline/_assign.py @@ -3,9 +3,10 @@ Consumes: pieces + piece_tags (grouped), segments, structure, tokens. Produces: tokens with roles set on every main-stream token. Reads: Policy.name_order (#270) and Policy.script_orders (#271, which -overrides it when every name piece is written wholly in one script); -token/piece tags; Lexicon only through tags already applied by classify -(plus the leading-title period rule). +overrides it when every name piece is written wholly in one script, or +in the Han/Hiragana/Katakana repertoire the #272 kana license shares +across pieces); token/piece tags; Lexicon only through tags already +applied by classify (plus the leading-title period rule). Ports v1's assignment loops. NO_COMMA (per name_order): leading title pieces chain while no given-position name has been seen @@ -29,7 +30,9 @@ import dataclasses import re -from nameparser._pipeline._vocab import is_suffix_lenient, single_script +from nameparser._pipeline._vocab import ( + effective_script, is_suffix_lenient, resolve_script_set, +) from nameparser._pipeline._group import ( _is_suffix_piece, _is_title_piece, ) @@ -85,27 +88,43 @@ def _effective_order(policy: Policy, order governs the positional read; anything else -- Latin, mixed scripts, no entry -- falls back to name_order. Piece-level, after title/suffix peeling: 'Dr. 毛泽东' is a wholly-Han NAME under a - Latin title.""" + Latin title. Kana-licensed tokens (高橋みなみ, #272) resolve to + HIRAGANA the same way a wholly-Han or wholly-Hangul token resolves + to its own script -- and so does a kana-licensed NAME split across + separately single-script PIECES ('高橋 みなみ', Han piece plus + Hiragana piece): resolve_script_set generalizes the license from + one token's characters to the whole found-script set below, which + is why Han+Hangul ('毛 김') still declines even though both + individually read family-first -- the license is specific to the + Han/Hiragana/Katakana repertoire, not "the entries happen to + agree". + + Naming note, since the two are easy to conflate: THIS function + resolves the ORDER for a whole name; `_vocab.effective_script` + resolves the SCRIPT for a single token. This function calls that + one per token below. + """ if not policy.script_orders: return policy.name_order - # ONE script for the whole name, not "the entries all agree": two - # scripts that both read family-first still fall back, because a - # Han+Hangul name is not written in either tradition. Per token - # rather than per joined piece -- WorkToken text is never empty, so - # "the piece is wholly one script" is "every token in it is". - found: Script | None = None + # Collect every token's script rather than comparing pairwise as + # tokens are seen: the kana license needs the WHOLE set (a Han + # piece and a Hiragana piece only license together, never one at a + # time), so resolution is deferred to resolve_script_set below. + found: set[Script] = set() for piece in pieces: for i in piece: - script = single_script(tokens[i].text) + script = effective_script(tokens[i].text) if script is None: # Latin, mixed, or a script with no entry: never a key return policy.name_order - if found is None: - found = script - elif script is not found: - return policy.name_order - return next((order for s, order in policy.script_orders if s is found), - policy.name_order) + found.add(script) + resolved = resolve_script_set(found) + if resolved is None: + # e.g. Han+Hangul: two scripts, neither the kana license's + # Han/Hiragana/Katakana repertoire -- no single tradition + return policy.name_order + return next((order for s, order in policy.script_orders + if s is resolved), policy.name_order) def _name_positions(order: tuple[Role, Role, Role], diff --git a/nameparser/_pipeline/_script_segment.py b/nameparser/_pipeline/_script_segment.py index 992226fe..0365a769 100644 --- a/nameparser/_pipeline/_script_segment.py +++ b/nameparser/_pipeline/_script_segment.py @@ -1,29 +1,55 @@ -"""Stage: script_segment (#271). +"""Stage: script_segment (#271, #272). -Consumes: tokens, segments, structure. +Consumes: tokens, segments, structure, segmenter. Produces: tokens (the first activated-script token of the name -segment split in two), segments (index runs remapped past the -insertion), ambiguities (indices likewise remapped, plus a -SEGMENTATION report when more than one split was -vocabulary-supported). -Reads: Policy.segment_scripts, Lexicon.surnames. +segment split into n+1 sub-slices), segments (index runs remapped past +the insertions), ambiguities (indices likewise remapped, plus a +SEGMENTATION report when more than one split was vocabulary-supported, +or when a segmenter's answer scored under the confidence floor). +Reads: Policy.segment_scripts, Lexicon.surnames, ParseState.segmenter. Unspaced CJK names give tokenize no separator to find, so this stage inserts the missing token boundary by vocabulary: the first token -written wholly in an activated script is matched longest-first against +written in an activated script is matched longest-first against Lexicon.surnames, and a hit splits it in two. Compound-before-single ("夏侯惇" is 夏侯 + 惇, though 夏 is itself a surname) falls out of -longest-first. The split makes two sub-slices of the one token, -rewriting nothing -- spans still index the original exactly, so the -anti-#100 invariant holds by construction. +longest-first. The split makes sub-slices of the one token, rewriting +nothing -- spans still index the original exactly, so the anti-#100 +invariant holds by construction. + +Where the VOCABULARY declines -- no prefix matched -- an optional +Parser(segmenter=...) gets the token (#272 amendment 2026-07-29). +Vocabulary first, segmenter on decline, so parser_for(ZH, JA, +segmenter=...) composes MECHANICALLY: a listed surname is a +dictionary certainty and wins, and the segmenter takes what is left. +Composing is not a free lunch, and the docs qualify it where they +show the stack: the zh pack's own mis-split warning survives +unchanged, because a Japanese kanji name opening on a listed Chinese +surname never reaches the segmenter at all -- 高橋一郎 still splits +高 + 橋一郎 under ZH+JA, exactly as it does under ZH alone. The two +packs are corpus ALTERNATIVES, one per corpus; stacking them is for +genuinely mixed data that accepts that trade. Its Segmentation may +cut anywhere and any number of times, which is why the split path +below takes n cuts. One precondition guards it that the vocabulary +has no twin of: a segmenter answers where an UNDIVIDED name divides, +so it is consulted only when the gated token is the name part's ONLY +script-written one -- "山田 太郎" was divided by its writer and must +not have its family divided again (a Latin title or suffix draws no +such boundary). A segmenter's own exceptions PROPAGATE -- the single +declared exception to parse totality (locales spec section 4): a +user-supplied callable's error is a user-code error, not a content +error. Placed AFTER segment, on the comma doctrine that script-conditional behavior is ignored where a comma already decides the family (the rule script_orders follows): under FAMILY_COMMA the pre-comma text IS the family by declaration, and splitting it would invent a boundary the writer explicitly did not draw -- "남궁민수, 지훈" must render -family "남궁민수", not "남궁 민수". The post-comma side is given-name -text, no surname site either, so that structure opts out whole. +family "남궁민수", not "남궁 민수". That doctrine is about the input's +structure, not the split's source, so it covers the segmenter +identically: the opt-out runs before either is consulted. The +post-comma side is given-name text, no surname site either, so that +structure opts out whole. NO_COMMA and SUFFIX_COMMA still split, within segments[0] (the name part) only. Running after segment costs the index remaps below; running BEFORE it would have made the comma structure itself depend @@ -38,7 +64,13 @@ unambiguously Korean, and its surname set is closed and default-shipped), while HAN is opt-in via locales.ZH -- a Chinese surname list corrupts Japanese names ("高橋一郎" must not split as -高 + 橋一郎), which is #272's pluggable segmenter, not this table's. +高 + 橋一郎). Japanese divides through the segmenter path below under +locales.JA, not through this table's vocabulary. +The gate resolves each token through effective_script, the same +function order resolution uses, so kana-licensed composites (高橋みなみ +-> HIRAGANA) gate in under the JA pack's HIRAGANA entry while +pure-katakana tokens (-> KATAKANA, in no activation set by design -- +they are predominantly transcribed foreign names) never do. Only the FIRST activated-script token is considered, match or no match: family-first traditions put the surname at the front of the name, and a match deeper in the token stream would be a given name or @@ -50,25 +82,103 @@ import functools from nameparser._pipeline._state import ( - ParseState, PendingAmbiguity, Structure, + ParseState, PendingAmbiguity, Structure, WorkToken, ) -from nameparser._pipeline._vocab import single_script -from nameparser._types import AmbiguityKind, Span +from nameparser._pipeline._vocab import effective_script +from nameparser._types import AmbiguityKind, Segmentation, Span + +#: Segmenter answers scoring below this attach a SEGMENTATION report +#: (amendment 2026-07-29 section 3). Kept at the drafted 0.9 after +#: measuring namedivider 0.4.1 over 112 names (#272 Task 5), which +#: found a distribution the amendment did not anticipate: the scores +#: are BIMODAL, not clustered near 1. A rule-based division (the kana +#: boundary in 高橋みなみ, or a two-character name) scores exactly 1.0; +#: a kanji-statistics division scores a softmax over the candidate cut +#: positions, observed in 0.23-0.68 and driven more by name LENGTH +#: (median 0.61 at three characters, 0.37 at five -- the softmax is +#: over len-1 candidates) than by correctness: the wrong answers in +#: the sample scored 0.32/0.60/0.60/0.61, straddling the correct +#: median of 0.52. So no floor INSIDE that band separates error from +#: success, and the only cut the data supports is between a stated +#: certainty and a statistical guess -- which is what section 3's +#: epistemic argument asks for anyway. 0.9 sits in the empty gap +#: (0.68 to 1.0), far from both modes, and reads as "confident" for a +#: third-party segmenter with a calibrated score too. Consequence, +#: pinned by tests and stated so it can be checked: every +#: STATISTICALLY divided name carries a SEGMENTATION report, and every +#: RULE-divided one -- the kana boundary, a two-character name, +#: namedivider's specific-name rules -- carries none. +#: namedivider scores its own two-character rule 1.0, so this floor +#: keeps that division silent -- see locales/ja.py for why the +#: presumption is accepted as stated. +#: Not configurable in 2.x (YAGNI). +_SEGMENTER_CONFIDENCE_FLOOR = 0.9 -def _remap(run: tuple[int, ...], split_at: int) -> tuple[int, ...]: - """One segment run after token `split_at` split in two: the second - half joins the run immediately after it, and every later index - shifts.""" +def _remap(run: tuple[int, ...], split_at: int, + added: int) -> tuple[int, ...]: + """One segment run after token `split_at` split into `added` + 1 + pieces: the extra pieces join the run immediately after it, and + every later index shifts by as many.""" out: list[int] = [] for j in run: if j == split_at: - out.extend((split_at, split_at + 1)) + out.extend(range(split_at, split_at + added + 1)) else: - out.append(j + 1 if j > split_at else j) + out.append(j + added if j > split_at else j) return tuple(out) +def _pieces(text: str, splits: tuple[int, ...]) -> tuple[str, ...]: + """`text` cut at every offset in `splits`: n offsets, n+1 pieces.""" + cuts = (0, *splits, len(text)) + return tuple(text[a:b] for a, b in zip(cuts, cuts[1:])) + + +def _split(state: ParseState, i: int, splits: tuple[int, ...], + detail: str | None) -> ParseState: + """Cut token `i` at every offset in `splits`, recording `detail` as + a SEGMENTATION report when there is one. + + The offsets arrive non-empty, ascending and interior whatever chose + them. From a segmenter: non-empty is the caller's own check, + strictly ascending with each >= 1 is Segmentation.__post_init__'s, + and the last offset is < len(text) -- checked by the caller. From + the vocabulary: the single offset is >= 1 and < len(text) by the + range(cap, 0, -1) construction and its len-1 cap. + + The ONE split path: the vocabulary hit is the single-offset case + and the segmenter's answer the general one, so neither can drift + from the other's index arithmetic.""" + token = state.tokens[i] + base = token.span.start + parts: list[WorkToken] = [] + start = 0 + for piece in _pieces(token.text, splits): + end = start + len(piece) + parts.append(dataclasses.replace( + token, text=piece, span=Span(base + start, base + end))) + start = end + added = len(splits) + tokens = state.tokens[:i] + tuple(parts) + state.tokens[i + 1:] + # Every index the earlier stages recorded is now stale past the + # split point: the segment runs group's own iteration rests on, and + # the ambiguities from extract_delimited (resolved to indices by + # tokenize) and segment. An ambiguity ON the split token keeps + # pointing at the head. + segments = tuple(_remap(run, i, added) for run in state.segments) + ambiguities = tuple( + dataclasses.replace(a, indices=tuple( + j + added if j > i else j for j in a.indices)) + for a in state.ambiguities) + if detail is not None: + ambiguities += (PendingAmbiguity( + AmbiguityKind.SEGMENTATION, detail, + tuple(range(i, i + added + 1))),) + return dataclasses.replace(state, tokens=tokens, segments=segments, + ambiguities=ambiguities) + + @functools.lru_cache(maxsize=8) def _longest_entry(surnames: frozenset[str]) -> int: """The longest surname in a vocabulary, cached per-vocabulary @@ -80,8 +190,8 @@ def _longest_entry(surnames: frozenset[str]) -> int: without ever evicting in normal use. Callers must pass a NON-EMPTY vocabulary: max() of an empty set - raises, and the stage's own emptiness guard (`not surnames`) runs - first, so the only call site cannot reach it.""" + raises, and the only call site sits under the stage's `if surnames` + match guard, so it cannot reach that.""" return max(map(len, surnames)) @@ -92,8 +202,8 @@ def script_segment(state: ParseState) -> ParseState: # in any script's ranges return state scripts = state.policy.segment_scripts - surnames = state.lexicon.surnames - if not scripts or not surnames or not state.segments: + # an empty VOCABULARY deliberately does not bail here -- see below + if not scripts or not state.segments: return state if state.structure is Structure.FAMILY_COMMA: return state # the comma already drew the boundary @@ -103,11 +213,12 @@ def script_segment(state: ParseState) -> ParseState: # extracted nickname/maiden content is unreachable from here -- # no input can produce it, so no test pins it. i = next((i for i in state.segments[0] - if single_script(state.tokens[i].text) in scripts), None) + if effective_script(state.tokens[i].text) in scripts), None) if i is None: return state token = state.tokens[i] text = token.text + surnames = state.lexicon.surnames # A token that IS a surname never splits: a bare "남궁" must not # become 남 + 궁 just because the single-syllable surname also # matches -- there is nothing to split off, and a lone token's @@ -117,39 +228,95 @@ def script_segment(state: ParseState) -> ParseState: # Longest-first (compound-before-single falls out of it), capped # so the remainder is never empty. Direct membership, no # _normalize: the script gate admits only CJK text, which the - # storage fold stores unchanged. - cap = min(_longest_entry(surnames), len(text) - 1) - matches = [length for length in range(cap, 0, -1) - if text[:length] in surnames] - if not matches: + # storage fold stores unchanged. An empty vocabulary skips the + # match rather than bailing the stage (_longest_entry's max() has + # nothing to take): a surname-less lexicon declines every token, + # which is exactly the condition the segmenter is consulted on, so + # an early bail would make a configured segmenter silently inert + # under Lexicon.empty() -- the JA pack's own shape. + matches: list[int] = [] + if surnames: + cap = min(_longest_entry(surnames), len(text) - 1) + matches = [length for length in range(cap, 0, -1) + if text[:length] in surnames] + if matches: + take = matches[0] + detail = None + if len(matches) > 1: + # more than one vocabulary-supported split: longest-first + # DECIDED a fork, and the deciding stage records it. A + # single-match split chose nothing, so it stays silent -- + # a dictionary certainty and the statistical guess below + # are different epistemic states, and these two emission + # rules say so. + chosen = " + ".join(map(repr, _pieces(text, (take,)))) + other = " + ".join(map(repr, _pieces(text, (matches[1],)))) + detail = (f"{text!r} splits as {chosen} on the longest " + f"surname; {other} also reads") + return _split(state, i, (take,), detail) + if state.segmenter is None: return state # the first activated-script token decides - take = matches[0] - cut = token.span.start + take - head = dataclasses.replace(token, text=text[:take], - span=Span(token.span.start, cut)) - tail = dataclasses.replace(token, text=text[take:], - span=Span(cut, token.span.end)) - tokens = state.tokens[:i] + (head, tail) + state.tokens[i + 1:] - # Every index the earlier stages recorded is now stale past the - # split point: the segment runs group's own iteration rests on, - # and the ambiguities from extract_delimited (resolved to indices - # by tokenize) and segment. An ambiguity ON the split token keeps - # pointing at the head. - segments = tuple(_remap(run, i) for run in state.segments) - ambiguities = tuple( - dataclasses.replace(a, indices=tuple( - j + 1 if j > i else j for j in a.indices)) - for a in state.ambiguities) - if len(matches) > 1: - # more than one vocabulary-supported split: longest-first - # DECIDED a fork, and the deciding stage records it. A - # single-match split chose nothing, so it stays silent. - alt = matches[1] - ambiguities += (PendingAmbiguity( - AmbiguityKind.SEGMENTATION, - f"{text!r} splits as {text[:take]!r} + {text[take:]!r} " - f"on the longest surname; {text[:alt]!r} + " - f"{text[alt:]!r} also reads", - (i, i + 1)),) - return dataclasses.replace(state, tokens=tokens, segments=segments, - ambiguities=ambiguities) + # The segmenter's precondition, which the vocabulary has no twin of: + # it is asked where an UNDIVIDED name divides, so it may only be + # shown a token that is the whole name. Where the name part carries + # a second script-written token the writer already drew this + # stage's missing boundary -- "山田 太郎" is divided, and dividing + # its family again yields 山 + 田 + 太郎 (namedivider answers for + # any string, and scores a two-character one 1.0 by rule, so no + # confidence check would catch it). The neighbour counts whatever + # script it is written in, ACTIVATED or not -- effective_script is + # merely non-None -- because a katakana or hangul neighbour is a + # boundary its writer drew just as deliberately as a Han one: under + # the JA pack "山田太郎 マイケル" declines, though katakana is in no + # activation set. A Latin title or suffix is NOT such a boundary: + # it says nothing about where the CJK name splits, so "Dr 阿明日, + # Jr." still reaches the segmenter. Vocabulary keeps its own rule + # -- a listed surname is a certainty about that exact string, + # whoever else stands beside it. + if any(j != i and effective_script(state.tokens[j].text) is not None + for j in state.segments[0]): + return state + # No try/except around the call: the module docstring's totality + # exception. The two checks below are that same doctrine, curated, + # and they are where the line this module draws is easiest to state: + # a PROTOCOL VIOLATION BY THE SEGMENTER AUTHOR RAISES, while an + # ADAPTER'S DEFENSE AGAINST ITS LIBRARY DECLINES. Both checks here + # are the first kind -- a wrong answer TYPE and an answer indexing + # past the token it was handed are stage-detectable bugs in + # user-supplied code, inside the declared totality exception, so + # they get the same treatment the callable's own exceptions get. + # locales/ja.py is the second kind: its repertoire, length, + # reconstruction and score guards all return None, because what + # they defend against is namedivider answering a question nobody + # asked it, which is a fact about the CONTENT, not a broken + # protocol. Bounded like every message here: the type's NAME, never + # its contents. + answer = state.segmenter(text) + if answer is not None and not isinstance(answer, Segmentation): + # a duck-typed answer carrying a .splits of its own would + # otherwise wander into the split path and surface as a + # ValueError naming Token, pointing the reader at nameparser's + # insides instead of at their segmenter + raise TypeError( + f"segmenter must return Segmentation or None, got " + f"{type(answer).__name__}") + if answer is None or not answer.splits: + return state # declined, or confidently one token + # splits[-1] is the max -- Segmentation enforced ascending. The + # upper bound is the half Segmentation cannot check, since it never + # sees the text; an offset at or past the end would make an empty + # piece. Declining silently here (as this did before the review) + # made an off-by-one segmenter undebuggable: every answer it gave + # vanished, and the parse merely looked unsegmented. + if answer.splits[-1] >= len(text): + raise ValueError( + f"segmenter returned splits beyond the token: last offset " + f"{answer.splits[-1]}, token length {len(text)}") + conf = answer.confidence + detail = None + if conf is not None and conf < _SEGMENTER_CONFIDENCE_FLOOR: + reading = " + ".join(map(repr, _pieces(text, answer.splits))) + detail = (f"{text!r} splits as {reading} on a segmenter answer " + f"scoring {conf:.2f}, under the " + f"{_SEGMENTER_CONFIDENCE_FLOOR} confidence floor") + return _split(state, i, answer.splits, detail) diff --git a/nameparser/_pipeline/_state.py b/nameparser/_pipeline/_state.py index dcba3111..6cf05bd6 100644 --- a/nameparser/_pipeline/_state.py +++ b/nameparser/_pipeline/_state.py @@ -15,7 +15,7 @@ from nameparser._lexicon import Lexicon from nameparser._policy import Policy -from nameparser._types import AmbiguityKind, Role, Span +from nameparser._types import AmbiguityKind, Role, Segmenter, Span # The comma characters (ASCII/Arabic/fullwidth, #265). Shared here so @@ -68,9 +68,9 @@ class ParseState: extract_delimited -> extracted/masked; tokenize -> tokens (span- sorted)/comma_offsets; segment -> segments/structure; script_segment -> tokens and segments again (the one stage that - changes the token COUNT: an unspaced CJK token splits in two, - still as sub-slices of the original, and every later index in the - segment runs shifts); classify -> token tags; group -> + changes the token COUNT: an unspaced CJK token splits into n+1 + pieces, still as sub-slices of the original, and every later index + in the segment runs shifts by n); classify -> token tags; group -> pieces/piece_tags/dropped AND maiden token roles; assign/post_rules -> the remaining token roles. Ambiguities are recorded by every stage that DECIDES one -- extract (resolved to a @@ -79,11 +79,17 @@ class ParseState: different stages needs an emitter in each. Post-group, segments may retain indices of dropped tokens -- assign iterates pieces, never segments. This ownership map is pinned by - tests/v2/pipeline/test_state.py.""" + tests/v2/pipeline/test_state.py. + + segmenter belongs to no stage: like original/lexicon/policy it is + passed in at construction by Parser.parse and only ever READ (by + script_segment, for a token the vocabulary declined).""" original: str lexicon: Lexicon policy: Policy + #: The optional Parser(segmenter=...) hook; None = not configured. + segmenter: Segmenter | None = None extracted: tuple[tuple[Role, Span], ...] = () masked: tuple[Span, ...] = () tokens: tuple[WorkToken, ...] = () diff --git a/nameparser/_pipeline/_tokenize.py b/nameparser/_pipeline/_tokenize.py index ac19b163..0d4643d8 100644 --- a/nameparser/_pipeline/_tokenize.py +++ b/nameparser/_pipeline/_tokenize.py @@ -6,10 +6,13 @@ slice), comma_offsets (segmentation points; never tokens). Reads: Policy.strip_emoji, Policy.strip_bidi. -There is NO text-rewriting normalize stage: whitespace collapsing and -emoji/bidi stripping are character-classification rules here -- -ignorable characters act as separators and never enter a token, so -spans always index the original exactly as given. +There is NO text-rewriting normalize stage: whitespace collapsing, +emoji/bidi stripping, and the katakana name-dot split are all +character-classification rules here -- ignorable characters act as +separators and never enter a token, so spans always index the +original exactly as given. Whitespace and the name-dot are +unconditional; emoji/bidi stripping alone is policy-gated +(Policy.strip_emoji/strip_bidi). v1's squash_emoji/squash_bidi REMOVED the char and joined neighbors ('A\U0001f600B' -> 'AB'); here an ignorable char is a SEPARATOR @@ -38,14 +41,36 @@ (0x2600, 0x26FF), (0x2700, 0x27BF)) _BIDI = re.compile('[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+') +# The katakana middle dot and its halfwidth twin divide the parts of +# a foreign name transcribed into katakana (マイケル・ジャクソン) -- +# native names never contain them, so they separate unconditionally, +# like whitespace (amendment 2026-07-29 section 1b). The Chinese +# interpunct U+00B7 (威廉·莎士比亚) is deliberately NOT here: it is +# also the Catalan punt volat, which sits INSIDE a single name piece +# (Gal·la), so separating on it unconditionally would break names it +# has no business touching. Admitting it would need a flanked-by-CJK +# guard, which is a different rule from this set's "these codepoints +# are always separators". +_NAME_DOT_SEPARATORS = frozenset({"\u30FB", "\uFF65"}) + def _ignorable(ch: str, state: ParseState) -> bool: if ch.isspace(): return True if ch.isascii(): - # both strip classes are entirely non-ASCII (bidi >= U+061C, - # emoji >= U+2600): skip two failing regex calls per letter + # both strip classes and the name-dot separators are entirely + # non-ASCII (bidi >= U+061C, emoji >= U+2600, dots >= U+30FB): + # skip the checks below for every ASCII letter return False + # unconditional, like whitespace -- not policy-gated, so this sits + # ahead of the policy checks rather than in _tokenize_region beside + # COMMA_CHARS (commas RECORD an offset; these dots must not). The + # only load-bearing constraint is "after the isascii fast path" + # (both dots are non-ASCII); being ahead of the bidi/emoji checks + # below is NOT load-bearing -- the three sets are disjoint, so a + # future edit is free to reorder them. + if ch in _NAME_DOT_SEPARATORS: + return True if state.policy.strip_bidi and _BIDI.match(ch): return True if state.policy.strip_emoji: diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py index f43a1280..6638d8da 100644 --- a/nameparser/_pipeline/_vocab.py +++ b/nameparser/_pipeline/_vocab.py @@ -9,6 +9,8 @@ from __future__ import annotations import re +import unicodedata +from collections.abc import Iterable from nameparser._lexicon import Lexicon, _normalize from nameparser._policy import Script @@ -35,24 +37,63 @@ # per-char on the _EMOJI_RANGES precedent in _tokenize.py, on the # theory that a range test needs no regex; measured at token scale the # compiled regex wins by 3-9x, and by 89x on long tokens.) -# HAN: the URO plus Extension A, the compatibility block, -# and the supplementary-plane block (Ext B-I + CJK Compat Ideographs -# Supplement, 0x20000-0x323AF) -- rare surnames are the biggest real -# source of supplementary-plane hanzi in personal names (e.g. 𠮷田's -# 𠮷, U+20BB7), so leaving them out silently mis-orders those names; -# unassigned gaps inside the span are harmless, since no real name -# contains an unassigned codepoint. HANGUL: precomposed syllables -# only -- modern Korean text never writes names as bare jamo. Kana is -# DELIBERATELY absent: a kana token identifies Japanese, whose -# conventions are #272's segmenter, not this table's. The ranges -# below must stay mutually disjoint: single_script returns the FIRST -# covering entry (dict iteration order), so an overlapping future -# script (e.g. a ja entry that also covers Han) would make the result -# order-dependent instead of well-defined. +# HAN: the ideographic iteration mark U+3005, the URO plus Extension +# A, the compatibility block, and the supplementary-plane block +# (Ext B-I + CJK Compat Ideographs Supplement, 0x20000-0x323AF) -- +# rare surnames are the biggest real source of supplementary-plane +# hanzi in personal names (e.g. 𠮷田's 𠮷, U+20BB7), so leaving them +# out silently mis-orders those names; unassigned gaps inside the span +# are harmless, since no real name contains an unassigned codepoint. +# U+3005 々 is the block-vs-Script case, running the OPPOSITE way to +# U+30FB below: 々 already IS Script=Han under UAX #24 (Scripts.txt +# reads `3005 ; Han`), but it sits in CJK Symbols and Punctuation, +# outside every CJK ideograph block this table spans -- so the +# singleton entry is what a BLOCK table needs to reach a character the +# Script property would have classified correctly for free. It earns +# the reach: 々 repeats the preceding kanji and appears only inside +# Han-written names -- 佐々木 (Sasaki, a top-20 Japanese surname), +# 野々村, 奈々. Omitting it made 佐々木 a mixed-script token: the name +# reversed and never gated into segmentation. +# HANGUL: precomposed syllables only -- modern Korean +# text never writes names as bare jamo. +# HIRAGANA/KATAKANA (#272): the two kana blocks, each in full. There +# IS a supplementary-plane kana repertoire (Kana Supplement, Kana +# Extended-A/B, Small Kana Extension, U+1AFF0-U+1B16F, a few hundred +# assigned codepoints -- no exact count here, it moves with the +# Unicode version) but none of it is WORTH chasing the way Han's astral +# block is: those codepoints are hentaigana and other archaic/ +# phonetic-extension forms no modern Japanese name uses, unlike +# supplementary Han, which real surnames genuinely need. The Katakana +# Phonetic Extensions block (U+31F0-U+31FF, 16 small katakana for Ainu +# transcription) is excluded for the same reason -- no modern Japanese +# personal name uses them. Halfwidth kana (U+FF65-U+FF9F, including +# the voiced/semi-voiced sound marks U+FF9E/U+FF9F) is likewise +# deliberately excluded -- legacy bank/CSV data uses it, but it is a +# separate normalization problem; Task 2b's separator handling only +# touches the halfwidth DOT (U+FF65), not the rest of that block. +# This table classifies by Unicode BLOCK, not the UAX #24 Script +# property: U+30A0, U+30FB (the middle dot), and U+30FC (the +# prolonged sound mark) all carry Script=Common under UAX #24, and the +# four kana voicing marks U+3099-U+309C split two and two -- U+3099 +# and U+309A are the COMBINING forms (Script=Inherited), U+309B and +# U+309C the spacing ones (Script=Common) -- yet every one of them is +# needed here, and block membership, not the Script property, is what +# puts them in range. The katakana block's upper end (U+30FF) takes in +# the middle dot U+30FB, kept rather than carved out for a smaller +# reason than it looks: tokenize (#272 Task 2b) turns U+30FB into a +# token separator, so no real parse shows this classifier a string +# containing one. It is kept so that a DIRECT whole-string call -- +# effective_script("マイケル・ジャクソン"), which the unit tests make -- +# still classifies instead of returning None. The ranges below must stay +# mutually disjoint: single_script returns the FIRST covering entry +# (dict iteration order), so an overlapping future script would make +# the result order-dependent instead of well-defined. _SCRIPT_RANGES: dict[Script, tuple[tuple[int, int], ...]] = { - Script.HAN: ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), - (0x20000, 0x323AF)), + Script.HAN: ((0x3005, 0x3005), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), + (0xF900, 0xFAFF), (0x20000, 0x323AF)), Script.HANGUL: ((0xAC00, 0xD7A3),), + Script.HIRAGANA: ((0x3040, 0x309F),), + Script.KATAKANA: ((0x30A0, 0x30FF),), } # Derived, never hand-written: one character class per script, in the @@ -65,6 +106,22 @@ for script, ranges in _SCRIPT_RANGES.items() } +#: The Japanese repertoire: the union effective_script's kana license +#: quantifies over -- HAN, HIRAGANA, KATAKANA (HANGUL simply omitted). +#: A frozenset, not the tuple this started as: resolve_script_set +#: below is the "later task that needs membership" the tuple's +#: original comment anticipated. Membership doesn't care about order, +#: and the pattern built below doesn't either (a regex character +#: class matches the same set regardless of the order its ranges are +#: written in). +_JA_SCRIPTS = frozenset({Script.HAN, Script.HIRAGANA, Script.KATAKANA}) +_JA_PATTERN = re.compile( + "[" + + "".join(f"\\U{lo:08x}-\\U{hi:08x}" + for s in _JA_SCRIPTS + for lo, hi in _SCRIPT_RANGES[s]) + + "]+") + def is_initial(text: str) -> bool: """'A.' / 'j.' / bare capital -- v1's is_an_initial.""" @@ -162,18 +219,109 @@ def period_joined_vocab(text: str, lexicon: Lexicon) -> str | None: return None +def _normalized_for_script(text: str) -> str | None: + """The guard AND the NFC normalization single_script and + effective_script's license path both need, single-sourced so they + cannot drift: None for the two shapes neither ever classifies + (empty, and the common all-ASCII Latin token -- skipped before + normalizing, since ASCII is already NFC and every _SCRIPT_RANGES + entry is non-ASCII regardless), else an NFC-normalized copy. + + NFC, not raw: NFD input decomposes precomposed katakana onto a + base character plus a COMBINING mark (U+3099/U+309A, which sit in + the HIRAGANA block, not katakana's), so classifying raw NFD text + can hand a pure-katakana token the kana license by accident; NFD + also decomposes Hangul syllables onto bare jamo (U+1100-U+11FF), + entirely outside the HANGUL range, so raw NFD Korean input misses + the shipped family-first order rule rather than merely misfiring. + Normalizing first fixes both. This is classification-only and + read-only: the returned copy is never what gets tokenized, so + token text and spans stay exactly what the caller wrote. + + MATCHING (is_initial, suffix lookups, etc.) deliberately stays on + raw text elsewhere in this module -- unlike script classification, + NFD only ever costs a match there (a suffix word written NFD fails + to match its NFC vocabulary entry), never wrong-matches, so the + asymmetry is safe: one direction needs a fix, the other doesn't. + """ + if not text or text.isascii(): + return None + return unicodedata.normalize("NFC", text) + + +def _classify(normalized: str) -> Script | None: + """The FIRST _SCRIPT_PATTERNS entry covering all of `normalized` + (already NFC, via _normalized_for_script), else None. Shared by + both public classifiers so each of them normalizes exactly once.""" + for script, pattern in _SCRIPT_PATTERNS.items(): + if pattern.fullmatch(normalized): + return script + return None + + def single_script(text: str) -> Script | None: """The one Script whose ranges cover EVERY char of `text`, else None (mixed-script text has no well-defined convention to apply; - the caller falls back to the positional default).""" - if not text: - return None # the + below needs one char; "" belongs to no script - if text.isascii(): - # every _SCRIPT_RANGES entry is non-ASCII (lowest today is - # U+3400): skip the patterns for the overwhelmingly common - # Latin token (the _tokenize._ignorable ASCII-floor precedent) + the caller falls back to the positional default). Classifies an + NFC-normalized copy of `text` -- see _normalized_for_script. + Callers wanting the kana-mixed license (a kanji+kana composite + resolving to HIRAGANA) want effective_script, not this function.""" + normalized = _normalized_for_script(text) + if normalized is None: return None - for script, pattern in _SCRIPT_PATTERNS.items(): - if pattern.fullmatch(text): - return script + return _classify(normalized) + + +def effective_script(text: str) -> Script | None: + """single_script, extended by the kana license (#272 amendment): + a MIXED token wholly within Han∪hiragana∪katakana is Japanese -- + it necessarily contains kana (pure Han is not mixed), cannot be + Chinese, and is not a foreign transcription (those are + katakana-only: マイケル has no kanji, but さくらエミ -- hiragana + plus katakana -- is kana-only AND licensed) -- and resolves to the + HIRAGANA carrier entry. Pure-katakana stays KATAKANA + (single_script's answer): a lone katakana token is predominantly a + transcribed foreign name, so nothing defaults on it.""" + # None for both shapes _JA_PATTERN could never match anyway (empty + # text, or all-ASCII text): real work, not a leftover "if text" + # guard, since the ASCII case is one a bare emptiness check would + # let through. The single normalized copy then serves both the + # single-script answer and the license below. + normalized = _normalized_for_script(text) + if normalized is None: + return None + script = _classify(normalized) + if script is not None: + return script + if _JA_PATTERN.fullmatch(normalized): + return Script.HIRAGANA + return None + + +def resolve_script_set(scripts: Iterable[Script]) -> Script | None: + """Generalizes effective_script's kana license from one token's + CHARACTERS to a whole name's PIECES (#272): `scripts` is the + effective_script of every name token, already resolved + individually -- '高橋' (Han) and 'みなみ' (Hiragana) are two + separately single-script pieces (split by a space, not mixed + within one token), but together are exactly the repertoire + effective_script licenses inside a single token (高橋みなみ). A + single distinct script is returned as-is (the ordinary case, + including a lone wholly-katakana name, which callers key with no + table entry); more than one collapses to the HIRAGANA carrier + when confined to Han/Hiragana/Katakana, the same set + effective_script's license tests; any other mix (Han+Hangul, or + no scripts at all -- an empty `scripts`) returns None -- the + caller's cue to fall back to the positional default, exactly like + effective_script's own None. A non-None result reports what was + FOUND, not that a license fired: callers wanting to know whether + the kana license specifically was the reason must compare the + result against a specific Script (e.g. `is Script.HIRAGANA`), not + just its truthiness -- a lone wholly-Han name also returns + non-None here, licensing nothing.""" + found = frozenset(scripts) + if len(found) <= 1: + return next(iter(found), None) + if found <= _JA_SCRIPTS: + return Script.HIRAGANA return None diff --git a/nameparser/_policy.py b/nameparser/_policy.py index 8a01ab22..78a1fb53 100644 --- a/nameparser/_policy.py +++ b/nameparser/_policy.py @@ -41,10 +41,21 @@ class Script(StrEnum): #: Chinese Hanzi -- and Japanese Kanji: a pure-Han string cannot #: say which language it is, which is fine for ORDER (both write #: family-first natively) and exactly why Han SEGMENTATION is - #: opt-in (locales.ZH; Japanese is #272's pluggable segmenter). + #: opt-in, per language: locales.ZH brings the Chinese surname + #: list, locales.JA activates the same stage for a pluggable + #: segmenter to divide kanji names with. HAN = "han" #: Korean Hangul (precomposed syllables). Unambiguously Korean. HANGUL = "hangul" + #: Japanese hiragana. Never transcribes foreign names, so a mixed + #: kanji+kana token (高橋みなみ) is Japanese and resolves HERE -- + #: this member is the carrier key in script_orders/segment_scripts. + HIRAGANA = "hiragana" + #: Japanese katakana. A PURE-katakana token is predominantly a + #: transcribed foreign name in its original order (マイケル), so + #: no default behavior keys on this member; it exists so the + #: classifier can name what it deliberately declines. + KATAKANA = "katakana" # Order-spec constants (#270). Each reads as its contents because roles @@ -100,17 +111,27 @@ def _order_repr(value: tuple[Role, ...]) -> str: "parser_for(locales.RU) / locales.TR_AZ)" ) -#: Policy.script_orders' default: wholly-Han and wholly-Hangul names -#: read family-first. Public and named so opting out or extending -#: reads against a documented value (the DEFAULT_NICKNAME_DELIMITERS -#: precedent). The HAN entry is safe WITHOUT knowing Chinese from -#: Japanese: both write family-first in native script -- the -#: languages differ, the convention doesn't. Canonical form: sorted -#: (Script, order) pairs, matching the field's storage. +#: Policy.script_orders' default: wholly-Han, wholly-Hangul, and +#: kana-licensed names read family-first. Public and named so opting +#: out or extending reads against a documented value (the +#: DEFAULT_NICKNAME_DELIMITERS precedent). The HAN entry is safe +#: WITHOUT knowing Chinese from Japanese: both write family-first in +#: native script -- the languages differ, the convention doesn't. +#: HIRAGANA joins by the same rule as HANGUL (the kana license, +#: amendment 2026-07-29): a mixed Han-and-kana token cannot be +#: Chinese (it contains kana) and is not a foreign transcription +#: (transcriptions are katakana-only), so it is Japanese, written +#: family-first -- another default change in a minor, release-log- +#: classified fix, #294's mechanism. KATAKANA is deliberately absent: +#: a PURE-katakana token is predominantly a transcribed foreign name +#: kept in its source (usually given-first) order, so nothing should +#: default on it. Canonical form: sorted (Script, order) pairs, +#: matching the field's storage. DEFAULT_SCRIPT_ORDERS: tuple[ tuple[Script, tuple[Role, Role, Role]], ...] = ( (Script.HAN, FAMILY_FIRST), (Script.HANGUL, FAMILY_FIRST), + (Script.HIRAGANA, FAMILY_FIRST), ) #: Policy.nickname_delimiters' default. Public and named so @@ -399,9 +420,11 @@ class Policy: #: ("John Smith, Jr.") leaves name_order governing the name part. name_order: tuple[Role, Role, Role] = GIVEN_FIRST #: Per-script overrides of name_order (#271), consulted when every - #: name piece is written wholly in one script: {Script: order} - #: (constructor accepts a mapping; stored as sorted pairs). The - #: default reads wholly-Han/Hangul names family-first -- see + #: name piece is written wholly in one script, or in the + #: Han/Hiragana/Katakana repertoire the #272 kana license shares + #: across pieces: {Script: order} (constructor accepts a mapping; + #: stored as sorted pairs). The default reads wholly-Han/Hangul + #: names, and kana-licensed Japanese names, family-first -- see #: :data:`~nameparser.DEFAULT_SCRIPT_ORDERS`. Opt out with #: ``script_orders={}``. Latin-script and mixed-script input is #: never affected. Like name_order, ignored where a comma already @@ -411,11 +434,16 @@ class Policy: #: Scripts for which the unspaced-name segmentation stage is #: active (#271): the first token written wholly in an activated #: script is split by longest surname match against - #: :attr:`Lexicon.surnames `. - #: Default: {Script.HANGUL} -- hangul is unambiguously Korean and - #: Korean surnames are a closed default-shipped set. Han is NOT - #: default: a zh surname list corrupts Japanese names (高橋一郎 - #: must not split as 高+橋一郎), so it's opt-in via locales.ZH. + #: :attr:`Lexicon.surnames `, and, + #: where that vocabulary declines, by a + #: :data:`~nameparser.Segmenter` if one was given to the parser + #: (#272). Default: {Script.HANGUL} -- hangul is unambiguously + #: Korean and Korean surnames are a closed default-shipped set. + #: Han is NOT default: a zh surname list corrupts Japanese names + #: (高橋一郎 must not split as 高+橋一郎), so it's opt-in via + #: locales.ZH for Chinese and locales.JA -- which activates + #: Script.HIRAGANA alongside it, the kana license's carrier key -- + #: for Japanese. #: Opt out with ``segment_scripts=()``; note a PolicyPatch unions #: rather than replaces, so a pack can only add scripts, never #: disable one. diff --git a/nameparser/_types.py b/nameparser/_types.py index a33121dc..5b9a1784 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -14,7 +14,7 @@ from __future__ import annotations import dataclasses -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from enum import Enum, StrEnum from typing import TYPE_CHECKING, NamedTuple, NoReturn, TypeVar @@ -230,6 +230,96 @@ def __repr__(self) -> str: return f"Token({self.text!r} {where} {self.role.name}{tags})" +@dataclass(frozen=True, slots=True) +class Segmentation: + """A segmenter's answer for one unspaced token: the interior offsets + to split at (each offset begins a new piece, so + ``Segmentation((2,))`` cuts a three-character token into + ``token[:2]`` and ``token[2:]``; strictly ascending, each >= 1 -- an + index protocol, so a segmenter physically cannot invent, drop, or + rewrite characters) and an optional confidence in [0, 1]. + ``Segmentation(())`` means "confidently one token" -- distinct from + returning None, which DECLINES ("I don't know"). The upper bound + (< len(token)) is the half this class cannot check, never having + seen the text; the consuming stage checks it and RAISES + ``ValueError`` on a violation, the same call it makes on an answer + of the wrong type -- both are protocol bugs in the segmenter, not + facts about the name.""" + + #: Interior character offsets to split at, ascending. + splits: tuple[int, ...] + #: How sure the segmenter is, or None for "no opinion". + confidence: float | None = None + + # in the class body so @dataclass(slots=True) keeps them + __getstate__ = _guarded_getstate + __setstate__ = _guarded_setstate + + def __post_init__(self) -> None: + # Both guards Token.tags carries, for the same two reasons and + # a third of this field's own: a bare string is iterable, so + # Segmentation("") would sail through as "confidently one + # token" -- an opinion nobody stated -- and Segmentation("23") + # would fail one character deep naming '2' rather than the + # argument. A mapping would silently contribute only its keys, + # and a bare int would surface as an uncurated "not iterable" + # from the tuple() below. + if isinstance(self.splits, str): + raise TypeError( + "Segmentation.splits must be an iterable of integers, " + "not a bare string" + ) + if isinstance(self.splits, Mapping): + raise TypeError( + "Segmentation.splits must be an iterable of integers, " + "not a mapping" + ) + try: + iter(self.splits) + except TypeError: + raise TypeError( + f"Segmentation.splits must be an iterable of integers, " + f"got {self.splits!r}" + ) from None + # OUTSIDE the try on purpose: iter() cannot run a generator's + # body, but tuple() can, and a TypeError raised in there is the + # caller's own bug -- relabeling it "must be an iterable" would + # send the reader to the wrong place entirely. + splits = tuple(self.splits) + for offset in splits: + # bool is an int subclass: True as an offset is a comparison + # result leaking into an index slot, not a split point + if isinstance(offset, bool) or not isinstance(offset, int): + raise TypeError( + f"Segmentation.splits must be integers, got {offset!r}") + if offset < 1: + raise ValueError( + f"Segmentation.splits must be interior offsets " + f"(each >= 1), got {offset}") + if any(b <= a for a, b in zip(splits, splits[1:])): + raise ValueError( + f"Segmentation.splits must be strictly ascending, " + f"got {splits!r}") + object.__setattr__(self, "splits", splits) + conf = self.confidence + if conf is not None: + if isinstance(conf, bool) or not isinstance(conf, (int, float)): + raise TypeError( + f"Segmentation.confidence must be a float or None, " + f"got {conf!r}") + # stored as given, not coerced to float: an int 1 is a valid + # confidence and the range check is what the callers rely on + if not 0.0 <= conf <= 1.0: + raise ValueError( + f"Segmentation.confidence must be within [0, 1], " + f"got {conf!r}") + + +#: The segmenter hook's shape: token text in, :class:`Segmentation` out, +#: None to decline. Plug one in via ``Parser(segmenter=...)``. +Segmenter = Callable[[str], Segmentation | None] + + class AmbiguityKind(StrEnum): """The stable vocabulary of :class:`Ambiguity` kinds. A StrEnum: members ARE their string values, so ``kind == "particle-or-given"`` @@ -280,10 +370,18 @@ class AmbiguityKind(StrEnum): #: More comma-separated segments than any recognized name shape; #: the parse is best-effort over the extra segments. COMMA_STRUCTURE = "comma-structure" - #: An unspaced CJK token had more than one vocabulary-supported - #: surname split ("夏侯惇": 夏侯 + 惇 was taken, 夏 + 侯惇 also - #: matched); the longest surname won, and ``detail`` names both - #: readings. Points at the two tokens the split produced. (#271) + #: A division of an unspaced CJK token that the parse had to + #: choose, from either of the two things that can divide one. + #: A VOCABULARY fork: more than one surname-supported split + #: existed ("夏侯惇" was taken as 夏侯 + 惇, while 夏 + 侯惇 also + #: matched), longest-match picked, and ``detail`` names both + #: readings (#271). Or a SEGMENTER answer scoring under the + #: stage's confidence floor: only one reading was offered, but the + #: score says it was a statistical guess rather than a stated + #: certainty, and ``detail`` names the pieces and the score + #: (#272). Either way it points at ALL the tokens the division + #: produced -- two for a vocabulary split, n+1 for a segmenter + #: answer cutting n times. SEGMENTATION = "segmentation" diff --git a/nameparser/locales/__init__.py b/nameparser/locales/__init__.py index 40982b12..1f45ec9e 100644 --- a/nameparser/locales/__init__.py +++ b/nameparser/locales/__init__.py @@ -12,12 +12,17 @@ from __future__ import annotations import importlib +from typing import TYPE_CHECKING from nameparser._locale import Locale +if TYPE_CHECKING: + from nameparser._types import Segmenter + #: attribute name -> (module name, module attribute). Codes are the #: lowercase module names; attribute constants are uppercase (spec §2). _REGISTRY = { + "JA": ("nameparser.locales.ja", "JA"), "RU": ("nameparser.locales.ru", "RU"), "TR_AZ": ("nameparser.locales.tr_az", "TR_AZ"), "ZH": ("nameparser.locales.zh", "ZH"), @@ -51,6 +56,22 @@ def available() -> tuple[str, ...]: return tuple(sorted(attr.lower() for attr in _REGISTRY)) +def ja_segmenter(*, gbdt: bool = False) -> Segmenter: + """The Japanese segmenter factory (#272), needing the optional + ``pip install nameparser[ja]``; see :mod:`nameparser.locales.ja` for + what it wraps, what it declines, and its licensing. + + A real function rather than a _REGISTRY entry, because the registry + resolves LOCALES: __getattr__ is annotated to return one, so routing + a callable through it would type every pack access as "Locale or + Segmenter" for no gain. Laziness is preserved here instead -- the + pack module (and with it the optional dependency) loads on the + call, not on importing this package.""" + from nameparser.locales.ja import ja_segmenter as factory + + return factory(gbdt=gbdt) + + def get(code: str) -> Locale: """Dynamic lookup by code ('ru'); raises KeyError listing the available codes (spec §2).""" diff --git a/nameparser/locales/ja.py b/nameparser/locales/ja.py new file mode 100644 index 00000000..a0f9c26e --- /dev/null +++ b/nameparser/locales/ja.py @@ -0,0 +1,217 @@ +"""The Japanese locale pack (#272): activates segmentation for the +Japanese repertoire -- Han and the kana-licensed composites that +resolve to HIRAGANA -- so that an unspaced 山田太郎 can be divided at +all. Applying this pack IS the "my data is Japanese" declaration, the +same role locales.ZH plays for Chinese: a pure-Han string cannot say +which language it is written in (林 is Lin or Hayashi), so neither +activation may be a default. + +Unlike zh, this pack ships NO vocabulary and NO order: + +* No vocabulary, because no surname list divides Japanese names. Family + and given names draw on the same kanji, both sides run one to four + characters, and the reading -- not the spelling -- is what settles + most splits. That is what the pluggable segmenter is for, and it is + why this pack alone is inert: it activates a stage that then has + nothing to segment with. +* No order, because a kana-bearing Japanese name already reads + family-first by default (amendment 2026-07-29 §1: hiragana identifies + native Japanese as certainly as hangul identifies Korean), and wholly + Han names have read family-first since the 2026-07-27 amendment. + Pure KATAKANA is deliberately outside both the order rule and the + activation set above -- katakana is how Japanese writes FOREIGN names + (マイケル・ジャクソン), in their original given-first order. + +Data sources: none. The pack carries no vocabulary of its own, so +there is no list here to cite, extend or keep current -- the data that +divides a Japanese name belongs to the segmenter, and namedivider's +sources and their terms are set out under Licensing below. + +The segmenter is a FACTORY, ``ja_segmenter()``, not pack data: a Locale +is pure data that dissolves into a Parser at construction, and a +third-party callable is neither pure nor data. Combining the two is +explicit -- ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` +-- which also keeps the optional dependency out of every import path +that merely touches this module. + +Licensing (the optional dependency is never bundled; nameparser's own +LGPL is untouched): namedivider-python's source and its GBDT model are +MIT. Its surname data -- ``family_name_repository.pickle``, sourced +from Myoji-Yurai.net -- carries custom terms permitting commercial and +non-commercial use *for dividing names*, which is the only use any +caller of this module can put it to. Those terms bind only the opt-in +``ja_segmenter(gbdt=True)`` path, which is what loads that file; the +default BasicNameDivider reads namedivider's own bundled kanji.csv and +never touches it. Its BERT model for katakana division is CC-BY-SA and +is NOT used (katakana division is out of scope, amendment §6). + +Declared deviations (spec §2 authoring requirement 3): the pack sets +one union policy field, self-selecting by script, so it can only change +names containing characters of the Japanese repertoire -- DEVIATES +below declares exactly that, over-declaring within the repertoire (a +name also needs an unspaced token and a segmenter to actually change, +but per-character scanning is the safe direction, zh's precedent). +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from nameparser._lexicon import Lexicon +from nameparser._locale import Locale +from nameparser._policy import PolicyPatch, Script +from nameparser._types import Segmentation + +if TYPE_CHECKING: + from nameparser._types import Segmenter + +JA = Locale( + code="ja", + lexicon=Lexicon.empty(), + policy=PolicyPatch( + segment_scripts=frozenset({Script.HAN, Script.HIRAGANA})), +) + +# The Japanese repertoire's codepoint spans -- hiragana, katakana and +# Han -- kept in sync BY HAND with _pipeline/_vocab.py's _SCRIPT_RANGES +# entries for those three scripts (layering forbids a pack importing +# the pipeline; the sync test in tests/v2/test_locales.py pins the +# equality). Katakana is in the table even though the pack never +# ACTIVATES it: both consumers below quantify over the whole +# repertoire, because a katakana-bearing token can still be a +# kana-licensed composite the pack acts on (山田エミ) -- DEVIATES must +# declare it, and the adapter must not decline it. A pure-katakana +# token never reaches THE ADAPTER: KATAKANA is in no activation set, so +# the stage's own gate stops it before the segmenter is consulted. +# DEVIATES still declares one, and must: it is a predicate over any +# name at all, called before any gate runs, and over-declaring is its +# safe direction by design. +_JA_RANGES = ((0x3005, 0x3005), (0x3040, 0x309F), (0x30A0, 0x30FF), + (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), + (0x20000, 0x323AF)) + + +#: How far outside [0, 1] a namedivider score may stray and still count +#: as float noise around a real answer rather than a broken divider -- +#: see the adapter's own comment for why it is float32-sized. +_SCORE_EPSILON = 1e-6 + + +def _in_repertoire(char: str) -> bool: + return any(lo <= ord(char) <= hi for lo, hi in _JA_RANGES) + + +def DEVIATES(name: str) -> bool: + """True when this pack may parse `name` differently from the + default parser (the declared-deviation predicate the + non-interference gate consumes).""" + return any(_in_repertoire(c) for c in name) + + +def ja_segmenter(*, gbdt: bool = False) -> Segmenter: + """A :data:`~nameparser.Segmenter` wrapping namedivider-python, + installed with ``pip install nameparser[ja]``: + + parser = parser_for(locales.JA, segmenter=locales.ja_segmenter()) + + Wraps ``BasicNameDivider`` (99.3% accurate, ~4k names/sec, pure + Python) by default, which needs only data bundled in the installed + package; ``gbdt=True`` selects ``GBDTNameDivider`` (99.9%), which + asks more of the environment on two counts. It runs namedivider's + gradient-boosted model through lightgbm -- a hard dependency of + namedivider-python, so the extra installs it, but its wheel does + not bundle the native OpenMP runtime it loads at import (a missing + ``libomp`` surfaces as lightgbm's own OSError naming the library it + could not find). And the first such call DOWNLOADS its model and + surname files from the network into ``~/.cache/namedivider-python`` + (namedivider prints its own progress to stdout doing so), which + matters for offline, sandboxed or air-gapped deployments. + + The adapter declines -- returns None, leaving the token whole -- + for text outside the Japanese repertoire, for text too short to + divide, for any answer that fails to reconstruct its input, and for + any score outside [0, 1] by more than float noise (a divider + scoring 87.0 is broken, and its division is worth no more than its + score). An + answer that puts the whole token on one side comes back as + "confidently one token" rather than a decline: the divider read it + and found nothing to cut, which is not the same as having no + opinion. + Answers arrive with namedivider's own confidence, which the parsing + stage compares against its floor to decide whether to report a + SEGMENTATION ambiguity: namedivider scores a rule-based division + (the kana boundary in 高橋みなみ, or a two-character name) 1.0 and + a kanji-statistics division well below that, so every + statistically divided name carries the report and every + rule-divided one carries none. The two-character rule is the case + to know about: dividing 原恵 one character each way is a + presumption Japanese practice makes, not a measurement (usage.rst + says so), yet the 1.0 keeps it silent. That is namedivider's own + stated certainty, taken as given here; ``DividedName.algorithm`` + names which rule answered and is the hook if presumption-reporting + is ever wanted. + """ + try: + import namedivider + except ImportError as exc: + raise ImportError( + "the Japanese segmenter needs namedivider-python -- " + "install it with: pip install nameparser[ja]" + ) from exc + divider = (namedivider.GBDTNameDivider() if gbdt + else namedivider.BasicNameDivider()) + + def segment(text: str) -> Segmentation | None: + # segment_scripts UNIONS under pack composition, so this can + # receive tokens of any other activated script (hangul, under + # the default policy). Decline anything outside the Japanese + # repertoire rather than hand it to namedivider, which would + # answer for it regardless -- the same table DEVIATES scans. + if not all(map(_in_repertoire, text)): + return None + # namedivider RAISES below two characters ("Name length needs + # at least 2 chars"), and a segmenter's exceptions propagate by + # contract, so a one-character token -- routine in spaced input + # like "林 太郎" -- must be declined here, not passed on. + if len(text) < 2: + return None + result = divider.divide_name(text) + if result.family + result.given != text: + return None # defensive: never trust reconstruction + # float(): the score is a numpy scalar on the statistical path. + # Two different failures wear the same shape here, and they get + # different answers. Float error at the edge of namedivider's + # softmax is arithmetic noise around a real answer, so an + # epsilon either side of [0, 1] is tolerated and clamped -- + # Segmentation enforces the range and would otherwise raise + # mid-parse. A score OUTSIDE that epsilon is not noise: a + # divider reporting 87.0 confidence is broken, and its division + # is worth nothing either. Declining keeps the parse safe AND + # observable by absence -- an unconditional clamp mapped 87.0 + # to 1.0, i.e. "certain", silencing the SEGMENTATION report at + # exactly the point of maximum brokenness. NaN takes the same + # exit for free: every comparison against it is False, so it + # fails both bounds below and declines. + # + # _SCORE_EPSILON is sized for FLOAT32, not float64: numpy + # softmax output is commonly float32, whose eps is ~1.2e-7, and + # namedivider's rule-based answers score exactly 1.0 -- so a + # float64-sized 1e-9 band would decline a correct rule-based + # division that came back a single ulp high (1.0000001 is + # exactly that). A few float32 ulps is still five orders of + # magnitude below any score a broken divider would produce, so + # nothing is bought by tightening it. + score = float(result.score) + if not -_SCORE_EPSILON <= score <= 1.0 + _SCORE_EPSILON: + return None + confidence = min(1.0, max(0.0, score)) + # Defensive, the reconstruction check's twin: no namedivider + # 0.4.x path can return an empty side (every answer, rule or + # statistical, is built by slicing the input at an interior + # offset). If one ever did, it would mean "I read this as + # undivided" -- an empty Segmentation, which is a stated + # opinion, and not the None that declines to have one. + if not result.family or not result.given: + return Segmentation((), confidence=confidence) + return Segmentation((len(result.family),), confidence=confidence) + + return segment diff --git a/nameparser/locales/zh.py b/nameparser/locales/zh.py index 00e1f23e..1ac8cbe0 100644 --- a/nameparser/locales/zh.py +++ b/nameparser/locales/zh.py @@ -6,7 +6,10 @@ cannot say whether it is Chinese or Japanese (林 is Lin or Hayashi) and a zh surname list corrupts Japanese names (高橋一郎 would split 高 + 橋一郎). Applying this pack IS the "my data is Chinese" -declaration. Japanese is #272's pluggable segmenter. +declaration. Japanese segmentation is locales.JA's job instead: that +pack activates the same stage for the Japanese repertoire and defers +the division itself to a pluggable segmenter, since no surname list +divides a kanji name. Data sources: single-character surnames are the top-100 of the 2020 PRC census; traditional-script variant forms are included for names @@ -98,8 +101,8 @@ # _pipeline/_vocab.py's _SCRIPT_RANGES[Script.HAN] (layering forbids # a pack importing the pipeline; the sync test in # tests/v2/test_locales.py pins the equality). -_HAN_RANGES = ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), - (0x20000, 0x323AF)) +_HAN_RANGES = ((0x3005, 0x3005), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), + (0xF900, 0xFAFF), (0x20000, 0x323AF)) def DEVIATES(name: str) -> bool: diff --git a/pyproject.toml b/pyproject.toml index 66a5159a..2a95ea83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,13 @@ classifiers = [ ] dependencies = [] +[project.optional-dependencies] +# Japanese kanji-name segmentation (#272), wrapped by +# locales.ja_segmenter(). Optional and never bundled: the core stays +# zero-dependency. 0.4 is the floor its BasicNameDivider/DividedName +# API dates from. +ja = ["namedivider-python>=0.4"] + [build-system] requires = ["setuptools (>=82.0.1)"] build-backend = "setuptools.build_meta" @@ -53,6 +60,9 @@ packages = ["nameparser", "tests"] [[tool.mypy.overrides]] module = [ "dill.*", + # optional extras, unstubbed and absent from the dev environment: + # the ja pack imports namedivider lazily, inside the factory + "namedivider.*", ] ignore_missing_imports = true diff --git a/tests/v2/cases.py b/tests/v2/cases.py index fb6e50ba..db0d77ac 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -522,12 +522,12 @@ def __post_init__(self) -> None: "role = family"), Case("mixed_script_untouched_by_script_orders", "John 王", {"given": "John", "family": "王"}, - notes="single_script is None for a mixed name: script_orders " + notes="effective_script is None for a mixed name: script_orders " "declines and the positional default governs"), Case("two_han_scripts_untouched_by_script_orders", "毛 김", {"given": "毛", "family": "김"}, - notes="two scripts also decline -- ONE script for the whole " - "name"), + notes="two scripts also decline -- the rule is one script, or " + "the Han/kana repertoire the #272 license covers"), Case("zh_unspaced", "毛泽东", {"family": "毛", "given": "泽东"}, @@ -561,5 +561,74 @@ def __post_init__(self) -> None: "names mis-split (高橋 is the real surname). This is " "why Han segmentation is opt-in and why the gate " "cannot guard it (DEVIATES declares all Han); " - "Japanese is #272's pluggable segmenter"), + "Japanese data belongs under locales.JA and its " + "segmenter instead"), + + # -- #272: the kana license + nakaguro (amendment 2026-07-29). + # Segmenter-dependent divisions cannot live here (Case has no + # segmenter field); they are pinned in test_locales.py's + # integration tests instead. + Case("ja_kana_spaced_family_first", "高橋 みなみ", + {"family": "高橋", "given": "みなみ"}, + classification="fix(#272)", + notes="hiragana identifies Japanese as certainly as hangul " + "identifies Korean; kana-licensed names read " + "family-first by default"), + Case("ja_kanji_katakana_pieces", "山田 エミ", + {"family": "山田", "given": "エミ"}, + classification="fix(#272)", + notes="a kanji piece + a katakana piece cannot be a foreign " + "transcription (those are katakana-only): native, " + "licensed"), + Case("ja_unspaced_unsegmented_default", "高橋みなみ", + {"family": "高橋みなみ"}, + classification="fix(#272)", + notes="no segmenter by default: one token, family role via " + "the kana license"), + Case("ja_pure_katakana_positional", "マイケル ジャクソン", + {"given": "マイケル", "family": "ジャクソン"}, + notes="parity row guarding the license's boundary: " + "pure-katakana is predominantly transcribed foreign " + "names in original order -- never licensed"), + Case("ja_nakaguro_divides_the_transcription", "マイケル・ジャクソン", + {"given": "マイケル", "family": "ジャクソン"}, + classification="fix(#272)", + notes="the katakana middle dot is the transcription's own " + "part divider: it separates like whitespace, the " + "license declines each katakana token, and the " + "positional default keeps the source-language order"), + Case("ja_nakaguro_han_takes_the_han_order", "高橋・一郎", + {"family": "高橋", "given": "一郎"}, + classification="fix(#272)", + notes="the dot splits; both tokens are pure Han, so the HAN " + "family-first entry fires -- the katakana row's sibling " + "through the other outcome"), + Case("ja_lone_hiragana_takes_family", "みなみ", + {"family": "みなみ"}, + classification="fix(#272)", + notes="hiragana earns a script_orders entry in its own right: " + "a lone token takes the entry's first role"), + Case("ja_lone_katakana_stays_given", "マイケル", + {"given": "マイケル"}, + notes="parity: katakana deliberately has no entry, so the " + "positional default holds -- transcribed foreign names " + "keep source order"), + Case("ja_iteration_mark_is_han", "佐々木 太郎", + {"family": "佐々木", "given": "太郎"}, + classification="fix(#272)", + notes="々 (U+3005, the ideographic iteration mark) repeats " + "the preceding kanji and is Script=Han under UAX #24, " + "but sits outside every CJK ideograph BLOCK -- and the " + "classifier is a block table, so it needs a singleton " + "entry to count as Han; without one 佐々木 -- a top-20 " + "Japanese surname -- would be a mixed-script token and " + "reverse"), + Case("ja_nakaguro_inside_a_nickname", + "山田 太郎 (マイケル・ジャクソン)", + {"family": "山田", "given": "太郎", + "nickname": "マイケル ジャクソン"}, + classification="fix(#272)", + notes="delimited content tokenizes under the same separator " + "rules: the dot renders back as a space in the nickname " + "join -- a decision, not an accident"), ) diff --git a/tests/v2/pipeline/test_assign.py b/tests/v2/pipeline/test_assign.py index c0508907..69544c15 100644 --- a/tests/v2/pipeline/test_assign.py +++ b/tests/v2/pipeline/test_assign.py @@ -194,6 +194,22 @@ def test_script_order_declines_when_no_piece_has_a_script() -> None: assert _by_role(out, Role.FAMILY) == "Smith" +def test_kana_licensed_piece_resolves_via_hiragana_entry() -> None: + # a kanji+hiragana mixed piece set takes the license (#272) and + # resolves through the HIRAGANA table entry, family-first + out = _assigned("高橋 みなみ") + assert _by_role(out, Role.FAMILY) == "高橋" + assert _by_role(out, Role.GIVEN) == "みなみ" + + +def test_pure_katakana_piece_falls_back_to_name_order() -> None: + # katakana alone is not in the table (transcription ambiguity), so + # a pure-katakana piece set falls back to the positional default + out = _assigned("マイケル ジャクソン") + assert _by_role(out, Role.GIVEN) == "マイケル" + assert _by_role(out, Role.FAMILY) == "ジャクソン" + + def test_script_with_no_table_entry_falls_back() -> None: # A single, well-defined script the table simply does not list: # resolution must fall through to name_order rather than pick an diff --git a/tests/v2/pipeline/test_script_segment.py b/tests/v2/pipeline/test_script_segment.py index 22ad2d6e..bb14d772 100644 --- a/tests/v2/pipeline/test_script_segment.py +++ b/tests/v2/pipeline/test_script_segment.py @@ -1,6 +1,9 @@ -"""Stage: script_segment (#271) -- unspaced CJK surname splitting.""" +"""Stage: script_segment (#271, #272) -- unspaced CJK splitting, by +vocabulary and by the pluggable segmenter behind it.""" import dataclasses +import pytest + from nameparser import Parser from nameparser._lexicon import Lexicon from nameparser._pipeline._script_segment import script_segment @@ -10,10 +13,14 @@ ) from nameparser._pipeline._tokenize import tokenize from nameparser._policy import Policy, Script -from nameparser._types import AmbiguityKind +from nameparser._types import AmbiguityKind, Segmentation, Segmenter _HAN = Policy(segment_scripts=frozenset({Script.HAN})) _HANGUL = Policy() # HANGUL is the default activation +# the JA pack's own activation (amendment 2026-07-29): HIRAGANA is the +# kana license's carrier key, so a kanji+kana composite gates in +# through it; KATAKANA is deliberately absent from every set. +_JA = Policy(segment_scripts=frozenset({Script.HAN, Script.HIRAGANA})) # 欧 AND 欧阳 both present on purpose: compound-before-single must # pick 欧阳 (the issue's own acceptance example); same for 남/남궁. # "jr" so the comma cases can reach SUFFIX_COMMA. @@ -21,9 +28,10 @@ suffix_words=frozenset({"jr"})) -def _run(text: str, policy: Policy = _HAN, - lexicon: Lexicon = _LEX) -> ParseState: - state = ParseState(original=text, lexicon=lexicon, policy=policy) +def _run(text: str, policy: Policy = _HAN, lexicon: Lexicon = _LEX, + segmenter: Segmenter | None = None) -> ParseState: + state = ParseState(original=text, lexicon=lexicon, policy=policy, + segmenter=segmenter) return script_segment(segment(tokenize(state))) @@ -31,6 +39,33 @@ def _texts(state: ParseState) -> list[str]: return [t.text for t in state.tokens] +def _fake(splits: tuple[int, ...], + confidence: float | None = None) -> Segmenter: + """A segmenter that gives the same answer whatever it is handed: + every case below pins what the STAGE does with an answer, not how a + real segmenter arrives at one (no external dependency here).""" + def segmenter(text: str) -> Segmentation | None: + return Segmentation(splits, confidence=confidence) + return segmenter + + +def _capture( + splits: tuple[int, ...] = (2,)) -> tuple[list[str], Segmenter]: + """_fake, plus the list of texts it was handed: the two cases below + that pin WHETHER and WITH WHAT the stage consults a segmenter need + the same closure.""" + asked: list[str] = [] + + def segmenter(text: str) -> Segmentation | None: + asked.append(text) + return Segmentation(splits) + return asked, segmenter + + +def _declines(text: str) -> Segmentation | None: + return None + + def test_splits_leading_surname_and_spans_index_the_original() -> None: state = _run("毛泽东") assert _texts(state) == ["毛", "泽东"] @@ -187,3 +222,191 @@ def test_delimited_only_input_reaches_the_empty_segments_guard() -> None: # at all -- the guard the stage-level helper above cannot reach nick = Parser(lexicon=_LEX, policy=_HANGUL).parse("(민준)") assert nick.nickname == "민준" + + +# -- the pluggable segmenter, consulted on decline (#272) --------------- + + +def test_segmenter_splits_on_vocabulary_decline() -> None: + # the baseline: with no segmenter configured a declined token is + # simply left whole, so every split below is the hook's doing + assert _texts(_run("山田太郎", policy=_JA)) == ["山田太郎"] + # 山田太郎 has no vocabulary surname prefix at all, so the + # vocabulary declines and the token reaches the segmenter + state = _run("山田太郎", policy=_JA, segmenter=_fake((2,))) + assert _texts(state) == ["山田", "太郎"] + assert [(t.span.start, t.span.end) for t in state.tokens] == [ + (0, 2), (2, 4)] + assert all(state.original[t.span.start:t.span.end] == t.text + for t in state.tokens) + + +def test_vocabulary_wins_over_the_segmenter() -> None: + # precedence, and the reason parser_for(ZH, JA, segmenter=...) + # composes: a matched surname is a dictionary certainty, so the + # segmenter is not even asked + asked, seg = _capture() + state = _run("毛泽东", policy=_JA, segmenter=seg) + assert _texts(state) == ["毛", "泽东"] + assert asked == [] + + +def test_segmenter_decline_and_confident_whole_are_distinct() -> None: + # two different ANSWERS -- None is "I don't know", () is + # "confidently one token" -- with the same effect on the tokens: + # the difference is the protocol's to carry, not this stage's + for seg in (_declines, _fake(())): + out = _run("山田太郎", policy=_JA, segmenter=seg) + assert _texts(out) == ["山田太郎"] + assert out.ambiguities == () # nothing decided, nothing to report + + +def test_kana_licensed_token_gates_in_for_the_segmenter() -> None: + # the gate reads effective_script, not single_script: 高橋みなみ is + # mixed Han+hiragana, which the kana license resolves to the + # HIRAGANA carrier entry -- and HIRAGANA is in the JA activation + state = _run("高橋みなみ", policy=_JA, segmenter=_fake((2,))) + assert _texts(state) == ["高橋", "みなみ"] + + +def test_pure_katakana_never_gates_in() -> None: + # the license's other half: a lone katakana token is predominantly + # a transcribed foreign name, so KATAKANA sits in no activation set + # and no segmenter is consulted for one + out = _run("マイケル", policy=_JA, segmenter=_fake((2,))) + assert _texts(out) == ["マイケル"] + assert out.ambiguities == () + + +def test_kana_licensed_token_stays_whole_under_default_policy() -> None: + # the pack IS the language declaration: without it the default + # activation is HANGUL alone, so nothing gates in and the + # configured segmenter is inert + out = _run("高橋みなみ", segmenter=_fake((2,))) + assert _texts(out) == ["高橋みなみ"] + assert out.ambiguities == () + + +def test_segmenter_multi_split_produces_n_plus_one_tokens() -> None: + # n cuts make n+1 sub-slices, and every index the earlier stages + # recorded shifts by n rather than by one + state = segment(tokenize(ParseState( + original="山田太 X", lexicon=_LEX, policy=_JA, + segmenter=_fake((1, 2))))) + state = dataclasses.replace(state, ambiguities=( + PendingAmbiguity(AmbiguityKind.UNBALANCED_DELIMITER, + "synthetic", indices=(1,)),)) + out = script_segment(state) + assert _texts(out) == ["山", "田", "太", "X"] + assert [(t.span.start, t.span.end) for t in out.tokens] == [ + (0, 1), (1, 2), (2, 3), (4, 5)] + assert all(out.original[t.span.start:t.span.end] == t.text + for t in out.tokens) + assert out.segments == ((0, 1, 2, 3),) + assert out.ambiguities[0].indices == (3,) + + +def test_low_confidence_emits_segmentation_ambiguity() -> None: + # a statistical guess is a fork the stage decided, so a score under + # _SEGMENTER_CONFIDENCE_FLOOR is reported -- unlike a vocabulary + # split, which reports only when a SECOND surname also matched + state = _run("山田太郎", policy=_JA, + segmenter=_fake((2,), confidence=0.42)) + assert [a.kind for a in state.ambiguities] == [ + AmbiguityKind.SEGMENTATION] + assert "0.42" in state.ambiguities[0].detail + assert state.ambiguities[0].indices == (0, 1) + + +def test_high_confidence_and_no_confidence_emit_nothing() -> None: + # a confident answer is not a fork, and an opinionless one gives + # the stage nothing to judge -- neither is worth a report + # 0.9 is the floor itself: the comparison is strict, so an answer + # AT the floor is not under it + for conf in (0.99, 0.9, None): + state = _run("山田太郎", policy=_JA, + segmenter=_fake((2,), confidence=conf)) + assert _texts(state) == ["山田", "太郎"] + assert state.ambiguities == () + + +def test_out_of_bounds_split_raises() -> None: + # Segmentation cannot check the upper bound (it never sees the + # text), so the stage does -- and it RAISES, the same call the + # wrong-answer-type check beside it makes: both are protocol + # violations by the segmenter's author, inside the declared + # totality exception. Declining silently instead (as this did + # before the review) made an off-by-one segmenter undebuggable -- + # every answer vanished and the name merely looked undivided. + with pytest.raises(ValueError, + match=r"segmenter returned splits beyond the " + r"token: last offset 5, token length 2"): + _run("山田", policy=_JA, segmenter=_fake((5,))) + # 2 on a two-character token is the boundary itself: the check is + # >=, not >, since a cut at len(text) would leave an empty piece + with pytest.raises(ValueError, match="last offset 2, token length 2"): + _run("山田", policy=_JA, segmenter=_fake((2,))) + + +def test_family_comma_gates_the_segmenter_too() -> None: + # the comma doctrine is about the input's structure, not the + # split's source: the structural opt-out runs before any consult + out = _run("山田太郎, 花子", policy=_JA, segmenter=_fake((2,))) + assert out.structure is Structure.FAMILY_COMMA + assert _texts(out) == ["山田太郎", "花子"] + assert out.ambiguities == () + + +def test_empty_vocabulary_still_consults_the_segmenter() -> None: + # "consult on vocabulary decline" has no unstated exception for a + # lexicon that declines EVERYTHING: a surname-less vocabulary is + # the JA pack's own shape (the segmenter is its whole mechanism), + # so an emptiness bail here would make it silently inert + state = _run("山田太郎", policy=_JA, lexicon=Lexicon.empty(), + segmenter=_fake((2,))) + assert _texts(state) == ["山田", "太郎"] + + +def test_a_wrong_answer_type_raises_a_curated_error() -> None: + # a duck-typed lookalike would otherwise wander into the split + # path and surface as a ValueError naming Token -- the reader's + # bug is in their segmenter, and the message must say so + class NotASegmentation: + splits = (2,) + confidence = None + + def seg(text: str) -> Segmentation | None: + return NotASegmentation() # type: ignore[return-value] + + with pytest.raises(TypeError, + match="segmenter must return Segmentation or None, " + "got NotASegmentation"): + _run("山田太郎", policy=_JA, segmenter=seg) + + +def test_a_neighbour_in_an_UNACTIVATED_script_still_blocks_the_consult() -> None: + # The precondition counts a second script-written token whatever + # script it is written in -- effective_script merely non-None, NOT + # membership in the activation set. A katakana or hangul neighbour + # is a boundary its writer drew just as deliberately as a Han one, + # so the name is already divided and the segmenter must not be + # asked. Narrowing the check to `in scripts` would make both of + # these split 山 + 田太郎 while the writer's own boundary sat one + # token to the right. + for name in ("山田太郎 マイケル", "山田太郎 김민준"): + out = _run(name, policy=_JA, segmenter=_fake((1,))) + assert _texts(out) == name.split(), name + assert out.ambiguities == (), name + + +def test_the_segmenter_is_handed_the_gated_token_only() -> None: + # It is asked where ONE token divides, so it gets that token's text + # and nothing else -- not the original, not the name part joined + # back together. A leading Latin title is the case that can tell + # the difference: it is skipped by the gate (no script) and is not + # a neighbour for the precondition either, so the consult happens + # and the argument is observable. + asked, seg = _capture() + out = _run("Dr 山田太郎", policy=_JA, segmenter=seg) + assert asked == ["山田太郎"] + assert _texts(out) == ["Dr", "山田", "太郎"] diff --git a/tests/v2/pipeline/test_state.py b/tests/v2/pipeline/test_state.py index a02908df..fe5c1670 100644 --- a/tests/v2/pipeline/test_state.py +++ b/tests/v2/pipeline/test_state.py @@ -16,6 +16,7 @@ def test_state_defaults_are_empty() -> None: assert s.structure is Structure.NO_COMMA assert s.ambiguities == () and s.extracted == () and s.masked == () assert s.comma_offsets == () and s.dropped == () and s.piece_tags == () + assert s.segmenter is None def test_state_is_frozen_and_replace_works() -> None: @@ -51,12 +52,15 @@ def test_stage_field_ownership() -> None: # a character offset that tokenize resolves to a token index "tokenize": {"tokens", "comma_offsets", "ambiguities"}, "segment": {"segments", "structure", "ambiguities"}, - # script_segment splits one unspaced CJK token in two, so it + # script_segment splits one unspaced CJK token into n+1 pieces + # (n = 1 from the vocabulary, any n from a segmenter), so it # rewrites tokens and shifts every later index the earlier # stages recorded -- the segment runs included. It is # deliberately absent from token_ownership below, whose # token-count assert is the one contract this stage is exempt - # from. structure it only READS (the FAMILY_COMMA opt-out). + # from. structure and segmenter it only READS (the + # FAMILY_COMMA opt-out, and the hook it consults on a + # vocabulary decline). "script_segment": {"tokens", "segments", "ambiguities"}, # classify also emits SUFFIX_OR_NICKNAME: the delimiter escape # that decides it lives in extract_delimited, which has no token diff --git a/tests/v2/pipeline/test_tokenize.py b/tests/v2/pipeline/test_tokenize.py index 4dc47965..96b18c4a 100644 --- a/tests/v2/pipeline/test_tokenize.py +++ b/tests/v2/pipeline/test_tokenize.py @@ -89,3 +89,44 @@ def test_emoji_and_bidi_are_ignorable_by_policy() -> None: def test_empty_and_whitespace_yield_no_tokens() -> None: assert _tokenized("").tokens == () assert _tokenized(" ").tokens == () + + +def test_nakaguro_separates_and_enters_no_token() -> None: + # U+30FB divides transcribed foreign names (マイケル・ジャクソン); + # native names never contain it -- amendment 2026-07-29 section 1b + state = _tokenized("マイケル・ジャクソン") + assert [t.text for t in state.tokens] == ["マイケル", "ジャクソン"] + assert all(state.original[t.span.start:t.span.end] == t.text + for t in state.tokens) + # like whitespace, and unlike COMMA_CHARS: it never records an offset + assert state.comma_offsets == () + # leading/trailing dots are plain separators, not content + assert [t.text for t in _tokenized("・ジャクソン").tokens] == ["ジャクソン"] + + +def test_nakaguro_next_to_a_real_comma_still_only_the_comma_offsets() -> None: + # the dot and a real comma side by side must not blur together -- + # the comma still segments and records; the dot still separates + # and stays silent + state = _tokenized("マイケル・, X") + assert [t.text for t in state.tokens] == ["マイケル", "X"] + assert state.comma_offsets == (5,) + + +def test_halfwidth_nakaguro_separates_too() -> None: + # U+FF65 between halfwidth katakana -- build the string from + # escapes and VERIFY the codepoints (the U+F900 homoglyph lesson): + # マイケル = halfwidth マイケル (MA I KE RU), + # ・ = the halfwidth dot, ジャクソン = + # halfwidth ジャクソン (SI + voiced-sound-mark, small-YA, KU, SO, N) + text = ("\uff8f\uff72\uff79\uff99" # \uff8f\uff72\uff79\uff99 = halfwidth MA I KE RU + "\uff65" # \uff65 = halfwidth middle dot + "\uff7c\uff9e\uff6c\uff78\uff7f\uff9d") # halfwidth SI+voice, small-YA, KU, SO, N + # belt and braces: the escapes above must spell the halfwidth dot, + # not the fullwidth U+30FB it could be confused for by eye -- and + # this is exactly the check the un-escaped version was missing: + # it would still pass with U+30FB substituted for U+FF65 + assert "・" in text + state = _tokenized(text) + assert len(state.tokens) == 2 + assert "・" not in "".join(t.text for t in state.tokens) diff --git a/tests/v2/pipeline/test_vocab.py b/tests/v2/pipeline/test_vocab.py index 82ef6292..1ca664e8 100644 --- a/tests/v2/pipeline/test_vocab.py +++ b/tests/v2/pipeline/test_vocab.py @@ -1,7 +1,9 @@ +import unicodedata + from nameparser._lexicon import Lexicon from nameparser._pipeline._vocab import ( - _SCRIPT_RANGES, is_initial, is_suffix_lenient, is_suffix_strict, - single_script, + _SCRIPT_RANGES, effective_script, is_initial, is_suffix_lenient, + is_suffix_strict, resolve_script_set, single_script, ) from nameparser._policy import Script @@ -51,7 +53,10 @@ def test_single_script_requires_every_char_in_one_script() -> None: assert single_script("Smith") is None assert single_script("毛zedong") is None # mixed assert single_script("毛김") is None # mixed CJK - assert single_script("イチロー") is None # kana: not HAN + # kana classifies as its own script (#272), not HAN -- was None + # before kana had a table entry; single_script's job is telling + # kana apart from Han, not lumping the two together + assert single_script("イチロー") is Script.KATAKANA def test_single_script_range_edges() -> None: @@ -82,3 +87,111 @@ def test_no_script_range_reaches_ascii() -> None: # it fails here rather than silently going unclassified. assert all(lo >= 0x80 for ranges in _SCRIPT_RANGES.values() for lo, _ in ranges) + + +def test_kana_singles_classify() -> None: + assert single_script("みなみ") is Script.HIRAGANA + assert single_script("エミ") is Script.KATAKANA + assert single_script("ー") is Script.KATAKANA # prolonged mark, in-block + + +def test_effective_script_kana_license() -> None: + # pure single-script tokens pass through unchanged + assert effective_script("山田") is Script.HAN + assert effective_script("みなみ") is Script.HIRAGANA + assert effective_script("マイケル") is Script.KATAKANA + # the license: a MIXED token wholly in Han∪kana is Japanese and + # resolves to the HIRAGANA carrier entry + assert effective_script("高橋みなみ") is Script.HIRAGANA # kanji+hira + assert effective_script("山田エミ") is Script.HIRAGANA # kanji+kata + assert effective_script("さくらエミ") is Script.HIRAGANA # hira+kata + # outside the license: anything beyond the JA repertoire + assert effective_script("毛김") is None + assert effective_script("山田x") is None + # NOT None: U+30FB sits INSIDE the katakana block (verified by + # codepoint), so this stays a PURE-katakana token, same as + # "マイケル" above -- the license only ever fires on a MIXED + # token, and this one isn't mixed. It has no order-default entry + # (DEFAULT_SCRIPT_ORDERS carries no KATAKANA key), which is where + # "declines the license" actually shows up. tokenize (#272 Task 2b) + # now turns U+30FB into a token separator, so this exact string + # arrives at effective_script as two tokens during a real parse -- + # this unit test calls effective_script directly on the whole + # string, bypassing tokenize, so it still sees the dot and must + # keep passing unchanged. + assert effective_script("マイケル・ジャクソン") is Script.KATAKANA + assert effective_script("") is None + + +def test_resolve_script_set_generalizes_the_license_across_pieces() -> None: + # a single script passes through as-is, including a script with no + # order-default entry (KATAKANA): the caller decides what to do + # with that, this function only reports what was found + assert resolve_script_set([Script.HAN]) is Script.HAN + assert resolve_script_set([Script.HAN, Script.HAN]) is Script.HAN + assert resolve_script_set([Script.KATAKANA]) is Script.KATAKANA + # the license, generalized: Han + Hiragana across two SEPARATE, + # individually single-script pieces (never mixed within one + # token) is exactly the repertoire effective_script licenses + # inside a single token, so it collapses to the carrier key + assert resolve_script_set([Script.HAN, Script.HIRAGANA]) \ + is Script.HIRAGANA + assert resolve_script_set([Script.HAN, Script.KATAKANA]) \ + is Script.HIRAGANA + assert resolve_script_set([Script.HIRAGANA, Script.KATAKANA]) \ + is Script.HIRAGANA + # outside the license: Han+Hangul is two real scripts, neither + # kana -- no single tradition, so this declines like the caller's + # own None (Latin, mixed, empty) + assert resolve_script_set([Script.HAN, Script.HANGUL]) is None + assert resolve_script_set([]) is None + + +def test_nfd_katakana_still_classifies_and_declines_the_license() -> None: + # Built via normalize(), never pasted as decomposed literals -- + # NFD "ガガ" is base katakana カ + COMBINING VOICED SOUND MARK + # (U+3099) twice; U+3099 sits in the HIRAGANA block, not + # katakana's, so classifying raw NFD text would see one char from + # each block and either call it mixed (single_script: None) or + # wrongly grant the kana license (effective_script: HIRAGANA) for + # what is really one pure-katakana token. NFC-normalizing first + # (the #272 amendment's NFC decision) recomposes it back to ガガ, + # which reads as ordinary katakana either way -- the license + # still correctly declines, because this token isn't mixed. + nfd = unicodedata.normalize("NFD", "ガガ") + assert nfd != "ガガ" # sanity: confirms the decomposition actually ran + assert single_script(nfd) is Script.KATAKANA + assert effective_script(nfd) is Script.KATAKANA + + +def test_nfd_hangul_still_classifies() -> None: + # NFD decomposes each precomposed syllable onto 2-3 jamo + # (U+1100-U+11FF), entirely outside the HANGUL range -- raw NFD + # input would silently miss the shipped family-first order rule + # rather than merely misclassify. Built via normalize(), not + # pasted decomposed literals, same reason as above. + nfd = unicodedata.normalize("NFD", "김민준") + assert nfd != "김민준" # sanity: confirms the decomposition actually ran + assert single_script(nfd) is Script.HANGUL + + +def test_script_ranges_are_pairwise_disjoint() -> None: + # The table's own comment states this invariant and nothing + # checked it: single_script returns the FIRST covering entry (dict + # iteration order), so any overlap would make the answer depend on + # declaration order instead of being well-defined. Compared over + # every entry of every script rather than script by script, + # because the adjacent blocks are exactly where a future edit + # would go wrong -- HIRAGANA ends at U+309F and KATAKANA opens at + # U+30A0 with no gap at all, and HAN's singleton U+3005 sits just + # below both. + spans = [(lo, hi, script) + for script, ranges in _SCRIPT_RANGES.items() + for lo, hi in ranges] + assert len(spans) > 1 # never passes vacuously + for i, (lo, hi, script) in enumerate(spans): + assert lo <= hi, f"{script} range ({lo:#x}, {hi:#x}) is inverted" + for other_lo, other_hi, other in spans[i + 1:]: + assert hi < other_lo or other_hi < lo, ( + f"{script} range ({lo:#x}, {hi:#x}) overlaps {other} " + f"range ({other_lo:#x}, {other_hi:#x})") diff --git a/tests/v2/test_cli.py b/tests/v2/test_cli.py index 5b4811f3..a75530bc 100644 --- a/tests/v2/test_cli.py +++ b/tests/v2/test_cli.py @@ -60,3 +60,16 @@ def test_cli_locale_empty_string_errors_not_silent_default() -> None: proc = _run("John Smith", "--locale", "") assert proc.returncode != 0 assert "ru" in proc.stderr and "tr_az" in proc.stderr + + +def test_cli_locale_ja_activates_nothing_by_itself() -> None: + # usage.rst's claim, pinned at the layer it is about: the CLI takes + # the pack but has no way to attach a segmenter, and the ja pack + # ships no vocabulary -- so --locale ja is byte-identical to the + # default parser on the very name the pack exists to divide. A + # future --segmenter flag, or a pack that grew a surname list, + # would land here first. + packed = _run("山田太郎", "--locale", "ja") + default = _run("山田太郎") + assert packed.returncode == 0 + assert packed.stdout == default.stdout diff --git a/tests/v2/test_facade.py b/tests/v2/test_facade.py index 7f50c7a1..31a93ac0 100644 --- a/tests/v2/test_facade.py +++ b/tests/v2/test_facade.py @@ -573,6 +573,11 @@ def test_facade_reads_wholly_han_names_family_first() -> None: assert HumanName("毛泽东").first == "" +def test_facade_reads_kana_licensed_names_family_first() -> None: + n = HumanName("高橋 みなみ") + assert (n.last, n.first) == ("高橋", "みなみ") + + def test_facade_parses_unspaced_korean_by_default() -> None: # the default-behavior fix flows through the v1 facade (its # lexicon mirrors Lexicon.default() via the shim snapshot) diff --git a/tests/v2/test_layering.py b/tests/v2/test_layering.py index 8739bf11..f4495e76 100644 --- a/tests/v2/test_layering.py +++ b/tests/v2/test_layering.py @@ -19,8 +19,8 @@ "_pipeline/_classify.py", "_pipeline/_group.py", "_pipeline/_assign.py", "_pipeline/_post_rules.py", "_pipeline/_assemble.py", "_parser.py", "_facade.py", - "_config_shim.py", "locales/__init__.py", "locales/ru.py", - "locales/tr_az.py"} + "_config_shim.py", "locales/__init__.py", "locales/ja.py", + "locales/ru.py", "locales/tr_az.py", "locales/zh.py"} _PIPELINE_STAGE_ALLOWED = ( "nameparser._types", "nameparser._lexicon", "nameparser._policy", @@ -29,11 +29,16 @@ "nameparser._pipeline.", ) -# a locale pack: pure data over the three base types, no pipeline or -# config access (locales spec §2) -- one contract shared by every pack, -# like _PIPELINE_STAGE_ALLOWED above, so tightening it is a one-line edit +# a locale pack: pure data over the base types, no pipeline or config +# access (locales spec §2) -- one contract shared by every pack, like +# _PIPELINE_STAGE_ALLOWED above, so tightening it is a one-line edit. +# _types joined the three in #272: the ja pack's segmenter factory +# constructs a Segmentation, and _types is the bottom of the graph +# (Locale itself imports it), so this widens the base types, not the +# reach. _LOCALE_PACK_ALLOWED = ( "nameparser._lexicon", "nameparser._locale", "nameparser._policy", + "nameparser._types", ) # module -> prefixes it may import from within nameparser @@ -91,8 +96,10 @@ # "nameparser.locales." prefix documents that reach for humans even # though the walker never exercises it. "locales/__init__.py": ("nameparser._locale", "nameparser.locales."), + "locales/ja.py": _LOCALE_PACK_ALLOWED, "locales/ru.py": _LOCALE_PACK_ALLOWED, "locales/tr_az.py": _LOCALE_PACK_ALLOWED, + "locales/zh.py": _LOCALE_PACK_ALLOWED, # v1 import-path preservation: thin re-exports of the facade/shim "parser.py": ("nameparser._facade",), "config/__init__.py": ("nameparser._config_shim",), @@ -167,6 +174,7 @@ def test_lexicon_never_imports_config_package_root_or_parser() -> None: def test_public_exports() -> None: expected = { "Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName", + "Segmentation", "Segmenter", "Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "Script", "UNSET", "GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST", "DEFAULT_NICKNAME_DELIMITERS", "DEFAULT_SCRIPT_ORDERS", "Locale", diff --git a/tests/v2/test_locales.py b/tests/v2/test_locales.py index b15385d1..9796eae1 100644 --- a/tests/v2/test_locales.py +++ b/tests/v2/test_locales.py @@ -1,8 +1,10 @@ """The locale pack layer (locales spec §2-3): lazy access, the shipped packs, composition, and the non-interference gate.""" import functools -import importlib +import importlib.util import re +import sys +import types from collections.abc import Callable, Iterable from types import ModuleType @@ -13,6 +15,7 @@ from nameparser._pipeline._vocab import _SCRIPT_RANGES from nameparser._policy import UNSET, PatronymicRule, Policy, Script from nameparser._types import AmbiguityKind +from nameparser.locales import ja as _ja from nameparser.locales import ru as _ru from nameparser.locales import tr_az as _tr_az from nameparser.locales import zh as _zh @@ -36,11 +39,46 @@ def _pack_modules() -> dict[str, ModuleType]: _PACKS = _pack_modules() +# ja is the one pack whose behavior is not pack data: it activates +# segmentation and supplies nothing to segment WITH, so every parse +# through it needs the optional nameparser[ja] segmenter. Probed once +# here; without the extra the ja code has no _PACKED entry, so every +# test that PARSES through the pack skips -- its rotator rows and its +# non-interference run included. What still runs either way is what +# needs no parse: the registry contract (DEVIATES and a rotator list +# exist), the range pin, the pack's contents, and the inertness test +# below, which is the unsegmented pack's whole observable behavior. +_JA_AVAILABLE = importlib.util.find_spec("namedivider") is not None +_needs_ja = pytest.mark.skipif(not _JA_AVAILABLE, reason="needs nameparser[ja]") + # shared across the gate and rotator tests: the default-parser baseline # is identical everywhere (only the packed side varies per test), so # parse each name once for the whole module instead of once per test _DEFAULT_PARSER = Parser() -_PACKED = {code: parser_for(locales.get(code)) for code in _PACKS} + + +def _packed_parsers() -> dict[str, Parser]: + out: dict[str, Parser] = {} + for code in _PACKS: + if code != "ja": + out[code] = parser_for(locales.get(code)) + elif _JA_AVAILABLE: + out[code] = parser_for( + locales.JA, segmenter=locales.ja_segmenter()) + return out + + +_PACKED = _packed_parsers() + + +def _require_packed(code: str) -> Parser: + """The pack's parser, skipping the test when it needs an optional + extra that is not installed (ja only, today).""" + parser = _PACKED.get(code) + if parser is None: + pytest.skip(f"the {code!r} pack needs nameparser[{code}], " + f"which is not installed") + return parser @functools.cache @@ -58,9 +96,13 @@ def test_locales_module_attribute_access() -> None: def test_locales_get_and_available() -> None: assert locales.get("ru") is locales.RU - assert set(locales.available()) == {"ru", "tr_az", "zh"} - with pytest.raises(KeyError, match="ru, tr_az, zh"): + assert set(locales.available()) == {"ja", "ru", "tr_az", "zh"} + with pytest.raises(KeyError, match="ja, ru, tr_az, zh"): locales.get("xx") + # the segmenter FACTORY is not a locale: it is an ordinary function + # on the package, so it must not appear among the codes + assert "ja_segmenter" not in locales.available() + assert callable(locales.ja_segmenter) def test_tr_az_pack_contents() -> None: @@ -156,6 +198,300 @@ def test_zh_han_ranges_stay_in_sync_with_vocab() -> None: assert _zh._HAN_RANGES == _SCRIPT_RANGES[Script.HAN] +def test_ja_pack_contents() -> None: + assert locales.JA.code == "ja" + # segmentation activation ONLY: no vocabulary (no list settles a + # kanji name) and no order (the kana license already reads + # Japanese family-first by default, amendment 2026-07-29 §1) + assert locales.JA.policy.segment_scripts == frozenset( + {Script.HAN, Script.HIRAGANA}) + assert locales.JA.policy.name_order is UNSET + assert locales.JA.policy.script_orders is UNSET + assert locales.JA.lexicon == Lexicon.empty() + + +def test_ja_segmenter_without_extra_raises_helpfully() -> None: + if _JA_AVAILABLE: + pytest.skip("namedivider installed; the error path is untestable") + with pytest.raises(ImportError, match=r"nameparser\[ja\]"): + locales.ja_segmenter() + + +class _FakeDivided: + """The shape of namedivider's DividedName, minus namedivider — + lets the adapter's guard stack run in the plain (no-extra) suite, + including the two defensive branches the real 0.4.x library can + never reach (reconstruction failure, an empty side).""" + + def __init__(self, family: str, given: str, score: float) -> None: + self.family, self.given, self.score = family, given, score + + +def _fake_namedivider(monkeypatch: pytest.MonkeyPatch, + divide: "Callable[[str], _FakeDivided]") -> list[str]: + """Install a stub namedivider module and return the gbdt-call log + (empty unless GBDTNameDivider was constructed).""" + constructed: list[str] = [] + + class _Basic: + def __init__(self) -> None: + constructed.append("basic") + + def divide_name(self, text: str) -> _FakeDivided: + return divide(text) + + class _GBDT(_Basic): + def __init__(self) -> None: + constructed.append("gbdt") + + stub = types.ModuleType("namedivider") + stub.BasicNameDivider = _Basic # type: ignore[attr-defined] + stub.GBDTNameDivider = _GBDT # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "namedivider", stub) + return constructed + + +def test_ja_adapter_guard_stack_against_a_stub( + monkeypatch: pytest.MonkeyPatch) -> None: + # The adapter's whole contract without the extra: this is what the + # coverage-bearing CI job sees, so the guards are pinned here and + # not only behind the nameparser[ja] integration tests. + divide = _fake_namedivider( + monkeypatch, lambda text: _FakeDivided(text[:2], text[2:], 0.5)) + seg = locales.ja_segmenter() + assert divide == ["basic"] + answer = seg("山田太郎") + assert answer is not None + assert answer.splits == (2,) and answer.confidence == 0.5 + assert seg("김민준") is None # outside the repertoire + assert seg("林") is None # namedivider raises below 2 + + +def test_ja_adapter_defensive_branches_against_a_stub( + monkeypatch: pytest.MonkeyPatch) -> None: + # reconstruction failure -> decline; an empty side -> the stated + # "confidently undivided" opinion; a score a hair over 1 -> clamped, + # so Segmentation's [0, 1] validation cannot raise mid-parse + _fake_namedivider( + monkeypatch, lambda text: _FakeDivided("外", "れ", 0.5)) + assert locales.ja_segmenter()("山田太郎") is None + _fake_namedivider( + monkeypatch, lambda text: _FakeDivided(text, "", 1.0000001)) + whole = locales.ja_segmenter()("山田太郎") + assert whole is not None + assert whole.splits == () and whole.confidence == 1.0 + + +def test_ja_adapter_declines_a_garbage_score( + monkeypatch: pytest.MonkeyPatch) -> None: + # The other half of the score handling, and the half the old + # unconditional clamp got wrong: float noise at the edge of a + # softmax is arithmetic around a real answer and clamps (above), + # but 87.0 is not noise -- a divider scoring that is broken, and + # its DIVISION is worth no more than its score. Clamping mapped it + # to 1.0, i.e. "certain", which silenced the SEGMENTATION report at + # exactly the point of maximum brokenness. Declining leaves the + # token whole: safe, and observable by the report's absence. + _fake_namedivider( + monkeypatch, lambda text: _FakeDivided(text[:2], text[2:], 87.0)) + assert locales.ja_segmenter()("山田太郎") is None + _fake_namedivider( + monkeypatch, lambda text: _FakeDivided(text[:2], text[2:], -0.5)) + assert locales.ja_segmenter()("山田太郎") is None + + +def test_ja_adapter_declines_a_nan_score( + monkeypatch: pytest.MonkeyPatch) -> None: + # NaN takes the garbage-score exit for free rather than needing one + # of its own: every comparison against NaN is False, so it fails + # BOTH bounds of the epsilon band. Worth a test precisely because + # it is a property of the comparison rather than a written branch + # -- under the old clamp, min/max propagated it straight into + # Segmentation, whose own range check would then have raised + # mid-parse. + _fake_namedivider( + monkeypatch, + lambda text: _FakeDivided(text[:2], text[2:], float("nan"))) + assert locales.ja_segmenter()("山田太郎") is None + + +def test_ja_adapter_gbdt_flag_selects_the_gbdt_divider( + monkeypatch: pytest.MonkeyPatch) -> None: + divide = _fake_namedivider( + monkeypatch, lambda text: _FakeDivided(text[:1], text[1:], 1.0)) + locales.ja_segmenter(gbdt=True) + assert divide == ["gbdt"] + + +def test_ja_ranges_stay_in_sync_with_vocab() -> None: + # the twin of the zh pin above: ja.py hand-copies the three + # Japanese-repertoire scripts' spans for DEVIATES and for the + # adapter's own repertoire check + assert set(_ja._JA_RANGES) == { + span for script in (Script.HAN, Script.HIRAGANA, Script.KATAKANA) + for span in _SCRIPT_RANGES[script]} + + +def test_ja_pack_alone_is_inert() -> None: + # The pack's own claim, and the reason it is safe to register + # without the extra: activating segmentation while shipping nothing + # to segment WITH changes no parse at all. Pure pack data, so this + # runs whether or not nameparser[ja] is installed -- and it is the + # unsegmented pack's entire observable behavior. + p = parser_for(locales.JA) + for name in _ROTATORS["ja"]: + assert p.parse(name).as_dict() == _default_parse(name), name + + +@_needs_ja +def test_ja_end_to_end() -> None: + p = _PACKED["ja"] + n = p.parse("山田太郎") + assert (n.family, n.given) == ("山田", "太郎") + # the case a zh surname list corrupts (高 + 橋一郎), done right + n = p.parse("高橋一郎") + assert (n.family, n.given) == ("高橋", "一郎") + # kana-licensed composite: gated in through the HIRAGANA entry + n = p.parse("高橋みなみ") + assert (n.family, n.given) == ("高橋", "みなみ") + # the iteration mark 々 gates in as Han (Script=Han under UAX #24, + # but outside the ideograph blocks the classifier spans, so it + # rides in on a singleton range entry) and namedivider reads the + # repeated character correctly -- verified against 0.4.1 rather + # than assumed, since a divider could plausibly have choked on it + # or cut beside it + n = p.parse("佐々木健") + assert (n.family, n.given) == ("佐々木", "健") + + +@_needs_ja +def test_ja_keeps_a_transcribed_name_in_its_source_order() -> None: + # pure katakana is excluded from the pack's activation by design + # (amendment §1: katakana is how Japanese writes FOREIGN names), so + # the nakaguro form divides on the dot and keeps its given-first + # source order instead of being read family-first + n = _PACKED["ja"].parse("マイケル・ジャクソン") + assert (n.given, n.family) == ("マイケル", "ジャクソン") + + +@_needs_ja +def test_ja_composes_with_zh() -> None: + # vocabulary first, segmenter on decline (amendment §2) + p = parser_for(locales.ZH, locales.JA, segmenter=locales.ja_segmenter()) + assert p.parse("毛泽东").family == "毛" # zh surname wins + assert p.parse("山田太郎").family == "山田" # zh declines, segmenter + # ...and a Korean name still splits on the default vocabulary, + # never reaching the segmenter (which would decline it anyway) + assert p.parse("김민준").family == "김" + + +@_needs_ja +def test_ja_segmenter_declines_what_it_cannot_read() -> None: + # The adapter's repertoire guard, which only a token that actually + # REACHES the adapter can pin: a listed Korean surname is split by + # the vocabulary first (the second case below), so this hangul + # deliberately opens with a syllable no surname list carries. It + # gates in on script, declines vocabulary, and reaches the adapter, + # which must decline it rather than let namedivider divide Korean + # -- without the guard it comes back as family 쿄 carrying a bogus + # SEGMENTATION report. + p = _PACKED["ja"] + n = p.parse("쿄쿄쿄") + assert (n.family, n.given) == ("쿄쿄쿄", "") + assert n.ambiguities == () + # ...and the vocabulary-wins case, which never reaches the adapter + assert p.parse("김민준").family == "김" + + +@_needs_ja +def test_ja_segmenter_declines_a_lone_character() -> None: + # namedivider RAISES below two characters, and a segmenter's + # exceptions propagate by contract, so the adapter's length guard + # is what keeps this parse total. It takes a LONE token to reach: + # in "林 太郎" the stage's precondition stops the segmenter first. + p = _PACKED["ja"] + n = p.parse("林") + assert (n.family, n.given) == ("林", "") + assert n.ambiguities == () + + +@_needs_ja +def test_ja_leaves_spaced_names_to_the_default_order() -> None: + # the segmenter's precondition (see test_parser.py): a spaced name + # is already divided, and the kana/Han family-first defaults read + # it correctly with no segmenter involved. Without this the pack + # would wreck the commonest written form -- 山田 + 太郎 divided + # again into 山 + 田 + 太郎. + p = _PACKED["ja"] + for name in ("山田 太郎", "高橋 みなみ", "高橋 エミ", "山田 太郎 Jr."): + assert p.parse(name).as_dict() == _default_parse(name), name + assert p.parse("山田 太郎").family == "山田" + + +@_needs_ja +def test_ja_reports_statistical_divisions_and_not_rule_based_ones() -> None: + # the confidence floor's measured consequence (_script_segment.py's + # constant): namedivider scores a rule-based division 1.0 and a + # kanji-statistics division far below the floor, so the ambiguity + # report separates exactly those two epistemic states + p = _PACKED["ja"] + assert [a.kind for a in p.parse("山田太郎").ambiguities] == [ + AmbiguityKind.SEGMENTATION] + assert p.parse("高橋みなみ").ambiguities == () # kana boundary rule + assert p.parse("原恵").ambiguities == () # two-character rule + + +@_needs_ja +def test_ja_divides_a_lone_hiragana_token() -> None: + # The whole-name presumption, applied to hiragana: at token level a + # bare given name is indistinguishable from a full name, so the + # stage gates みなみ in and namedivider divides it -- み + なみ, + # which is wrong if the writer meant the given name alone and right + # if they meant a (rare) all-kana full name. Exactly the acceptance + # 田中 -> 田 + 中 already carries for kanji, pinned here so the kana + # path's version is a recorded consequence rather than a surprise. + # The statistical score puts a SEGMENTATION report on it, which is + # the caller's handle for routing it to review. + n = _PACKED["ja"].parse("みなみ") + assert (n.family, n.given) == ("み", "なみ") + assert [a.kind for a in n.ambiguities] == [AmbiguityKind.SEGMENTATION] + + +@_needs_ja +def test_ja_divides_an_astral_kanji_name() -> None: + # 𠮷 (U+20BB7, the "tsuchiyoshi" 吉) is a real surname character in + # the supplementary plane -- the reason _SCRIPT_RANGES carries the + # astral Han span at all. It has to survive the whole chain as one + # character and not two: the script gate, the adapter's repertoire + # scan, namedivider itself, and the offset arithmetic that turns + # the answer into spans (Python indexes by codepoint, so an offset + # of 2 here spans four UTF-16 units -- a surrogate-pair bug would + # land the split in the middle of the character). + n = _PACKED["ja"].parse("𠮷田太郎") + assert (n.family, n.given) == ("𠮷田", "太郎") + assert [a.kind for a in n.ambiguities] == [AmbiguityKind.SEGMENTATION] + + +@_needs_ja +def test_ja_stacked_on_zh_pays_the_zh_packs_documented_cost() -> None: + # Stacking composes MECHANICALLY (the test above) but not for free, + # and this pins the cost so it stays a documented trade rather than + # a bug report. Vocabulary runs first, so a Japanese kanji name + # opening on a listed CHINESE surname never reaches the segmenter: + # 高 is a common Chinese surname, so 高橋一郎 splits 高 + 橋一郎 + # under ZH+JA exactly as it does under ZH alone -- the same reading + # cases.py's zh_japanese_kanji_tradeoff row records, and the reason + # the docs call the two packs corpus alternatives and qualify the + # stack (see _script_segment.py's module docstring, usage.rst and + # the release log). JA alone reads it correctly (test_ja_end_to_end). + p = parser_for(locales.ZH, locales.JA, segmenter=locales.ja_segmenter()) + n = p.parse("高橋一郎") + assert (n.family, n.given) == ("高", "橋一郎") + # and silently: one vocabulary match is not a fork, so nothing here + # flags the mis-split -- the pack choice is what has to be right + assert n.ambiguities == () + + def test_pack_vocabulary_entries_are_single_words() -> None: # The shipped-vocabulary guard (test_lexicon.py's # test_default_lexicon_builds_warning_free), extended to the OTHER @@ -394,6 +730,21 @@ def _default_corpus() -> list[str]: "諸葛亮", # traditional-script compound "萧红", # the 肖/萧 merger's second simplified spelling ] +# ja has no marker regexes and no vocabulary either: its rotators cover +# the shapes only the pack PLUS its segmenter changes. Every row is +# UNSPACED on purpose -- a spaced kana-licensed name (高橋 みなみ) now +# reads family-first by DEFAULT (amendment 2026-07-29 §1), so it would +# not deviate and would fail the must-deviate assertion below. +_ROTATORS["ja"] = [ + "山田太郎", # 2-kanji family + 2-kanji given, the common shape + "高橋一郎", # the name a zh surname list corrupts (高 + 橋一郎) + "田中花子", # ditto (田 is a listed zh surname) + "原辰徳", # 1-kanji family + 2-kanji given: the other fork + "高橋みなみ", # kanji + hiragana: the kana license's carrier case + "山田エミ", # kanji + katakana: licensed as a MIXED token, where + # pure katakana (マイケル) is deliberately not + "佐々木健", # the iteration mark 々 inside a top-20 surname +] def _marker_regexes(module: ModuleType) -> list[re.Pattern]: @@ -478,25 +829,33 @@ def test_rotator_deviates_and_declares(code: str, name: str) -> None: # every branch of the pack's marker regexes both FIRES (packed parse # differs from default) and is DECLARED (DEVIATES says so) -- the # per-name proof behind the gate's declared-count floor below - assert _PACKED[code].parse(name).as_dict() != _default_parse(name) + packed = _require_packed(code) + assert packed.parse(name).as_dict() != _default_parse(name) assert _PACKS[code].DEVIATES(name) @pytest.mark.parametrize("code", sorted(_PACKS)) def test_non_interference_each_pack(code: str) -> None: + packed = _require_packed(code) corpus = _default_corpus() + _ROTATORS[code] declared = _assert_non_interference( - _PACKED[code], _PACKS[code].DEVIATES, corpus) + packed, _PACKS[code].DEVIATES, corpus) # the positive side: every synthetic rotator (plus the corpus's own # in-scope names) must actually flow through the declared branch assert declared >= len(_ROTATORS[code]) def test_non_interference_all_packs_combined() -> None: - all_rotators = [n for code in sorted(_ROTATORS) + # Every pack is applied whether or not its extra is installed (the + # ja pack's policy patch is pure data); only the ROTATORS of a pack + # that cannot act are dropped, since the count assertion below + # requires each listed name to actually deviate. + all_rotators = [n for code in sorted(_ROTATORS) if code in _PACKED for n in _ROTATORS[code]] corpus = _default_corpus() + all_rotators - packed = parser_for(*(locales.get(code) for code in sorted(_PACKS))) + packed = parser_for( + *(locales.get(code) for code in sorted(_PACKS)), + segmenter=locales.ja_segmenter() if _JA_AVAILABLE else None) declared = _assert_non_interference( packed, lambda n: any(m.DEVIATES(n) for m in _PACKS.values()), diff --git a/tests/v2/test_parser.py b/tests/v2/test_parser.py index a2612646..08c64fad 100644 --- a/tests/v2/test_parser.py +++ b/tests/v2/test_parser.py @@ -1,13 +1,16 @@ import dataclasses import pickle +import unicodedata import pytest -from nameparser import Lexicon, Locale, Parser, Policy, PolicyPatch, parse, parser_for +from nameparser import ( + Lexicon, Locale, Parser, Policy, PolicyPatch, locales, parse, parser_for, +) from nameparser._policy import ( FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, PatronymicRule, ) -from nameparser._types import AmbiguityKind, Role +from nameparser._types import AmbiguityKind, Role, Segmentation def test_parser_defaults_and_properties() -> None: @@ -451,6 +454,96 @@ def test_wholly_cjk_names_read_family_first_by_default() -> None: assert parse("毛泽东").family == "毛泽东" +def test_nfd_korean_input_still_reads_family_first() -> None: + # fix(#271) classification, landed via the #272 NFC-classification + # amendment: NFD decomposes each Hangul syllable onto bare jamo, + # entirely outside the HANGUL range, so raw NFD input used to miss + # script_orders' family-first rule and fall back to the positional + # default -- a live gap in #294's shipped behavior until + # single_script started classifying an NFC-normalized copy. + n = parse(unicodedata.normalize("NFD", "김 민준")) + # classification-only: the rendered text is exactly what was + # typed (still NFD, spans untouched), so compare NFC-normalized -- + # the point under test is the ORDER (family first), not encoding + assert (unicodedata.normalize("NFC", n.family), + unicodedata.normalize("NFC", n.given)) == ("김", "민준") + # pinned, not just stated: a future tokenize-level normalize (the + # tempting over-fix, and an anti-#100 violation) must fail here + assert n.family == unicodedata.normalize("NFD", "김") + + +def test_kana_licensed_names_read_family_first_by_default() -> None: + # the #272 amendment: hiragana identifies Japanese as certainly + # as hangul identifies Korean -- release-log-classified fix + n = parse("高橋 みなみ") + assert (n.family, n.given) == ("高橋", "みなみ") + n = parse("山田 エミ") # kanji piece + katakana piece: native + assert (n.family, n.given) == ("山田", "エミ") + # lone kana-licensed token takes the family role, unsplit + assert parse("高橋みなみ").family == "高橋みなみ" + + +def test_iteration_mark_counts_as_han() -> None: + # 々 (U+3005) repeats the preceding kanji. It is Script=Han under + # UAX #24 already, but it lives outside every CJK ideograph BLOCK, + # and the classifier is a block table -- so without its singleton + # entry a 佐々木 token was in no script at all: the name reversed, + # and the token never reached the segmentation gate. The Script + # property would have got this one right unaided; the block table + # is what needed the special case. + n = parse("佐々木 太郎") + assert (n.family, n.given) == ("佐々木", "太郎") + n = parse("野々村 真") + assert (n.family, n.given) == ("野々村", "真") + # and a lone one takes the family role like any other Han token + assert parse("奈々").family == "奈々" + + +def test_pure_katakana_stays_positional() -> None: + # transcribed foreign names keep their original (usually Western) + # order; katakana alone licenses nothing + n = parse("マイケル ジャクソン") + assert (n.given, n.family) == ("マイケル", "ジャクソン") + + +def test_katakana_transcription_parses_by_its_divider() -> None: + # the dot tells us where the parts meet; transcriptions keep the + # source language's order, which the positional default provides + n = parse("マイケル・ジャクソン") + assert (n.given, n.family) == ("マイケル", "ジャクソン") + + +def test_nakaguro_split_han_tokens_take_the_han_order() -> None: + # different code path from the katakana case above: splitting on + # the dot here produces two PURE-HAN tokens (no kana involved), so + # script_orders' family-first HAN entry fires, same as any other + # two-token Han name -- the dot only decides where the split falls + n = parse("高橋・一郎") + assert (n.family, n.given) == ("高橋", "一郎") + + +def test_halfwidth_nakaguro_splits_at_parse_level_too() -> None: + # decision, not accident: halfwidth kana classify as no script at + # all (_SCRIPT_RANGES only covers the fullwidth blocks), so this + # is order-agnostic positional fallback, not a script-order rule -- + # the dot still divides the tokens regardless + text = "マイケル・ジャクソン" + n = parse(text) + assert (n.given, n.family) == ( + "マイケル", "ジャクソン") + + +def test_nakaguro_inside_a_nickname_still_splits() -> None: + # decision, not accident: delimited content tokenizes under the + # same separator rules as the main stream, so a dot inside a + # nickname still divides it -- and re-rendering a token stream + # necessarily uses the render join, so the dot comes back as a + # space, same as any other separator + n = parse("山田 太郎 (マイケル・ジャクソン)") + assert n.nickname == "マイケル ジャクソン" + assert (n.family, n.given) == ("山田", "太郎") + + def test_latin_names_are_untouched_by_script_orders() -> None: n = parse("John Smith") assert (n.given, n.family) == ("John", "Smith") @@ -481,9 +574,9 @@ def test_three_cjk_pieces_take_the_script_order_middles() -> None: def test_two_cjk_scripts_fall_back_even_though_both_read_family_first() -> None: - # The rule is ONE script for the whole name, not "the entries - # agree": a Han+Hangul name is written in neither tradition, so it - # takes the positional default. + # The rule is one script, or the Han/kana repertoire the #272 + # license covers -- not "the entries agree": Han+Hangul is written + # in neither tradition, so it takes the positional default. n = parse("毛 김") assert (n.given, n.family) == ("毛", "김") @@ -518,3 +611,130 @@ def test_unspaced_korean_names_parse_by_default() -> None: assert (n.family, n.given) == ("김", "민준") n = parse("남궁민수") # two-syllable surname beats single 남 assert (n.family, n.given) == ("남궁", "민수") + + +# -- the segmenter hook (#272) ------------------------------------------ + + +def _module_level_decline(text: str) -> Segmentation | None: + return None # module-level so pickle can find it + + +# Inert sentinels for the plumbing tests below, which only ever compare +# identity. Two of them, because the override test needs the loser and +# the winner to be DISTINCT objects or its assertion is vacuous. +def _decline_a(text: str) -> Segmentation | None: + return None + + +def _decline_b(text: str) -> Segmentation | None: + return None + + +def test_parser_segmenter_is_keyword_only_and_validated() -> None: + assert Parser().segmenter is None + with pytest.raises(TypeError, match="callable"): + Parser(segmenter=5) # type: ignore[arg-type] + with pytest.raises(TypeError, match="positional"): + Parser(Lexicon.default(), Policy(), None) # type: ignore[misc] # positional: rejected + + +def test_parser_for_carries_the_base_segmenter() -> None: + # not given: the base's carries through unchanged + p = parser_for(locales.get("zh"), base=Parser(segmenter=_decline_a)) + assert p.segmenter is _decline_a + + +def test_parser_for_rejects_a_non_parser_base() -> None: + with pytest.raises(TypeError, match="base must be a Parser"): + parser_for(locales.get("zh"), base=5) # type: ignore[arg-type] + + +def test_parser_for_takes_a_segmenter_keyword() -> None: + p = parser_for(locales.get("zh"), segmenter=_decline_a) + assert p.segmenter is _decline_a + assert parser_for(locales.get("zh")).segmenter is None + + +def test_parser_for_segmenter_keyword_overrides_the_base() -> None: + # later wins, the same rule scalar policy fields follow + p = parser_for(locales.get("zh"), base=Parser(segmenter=_decline_a), + segmenter=_decline_b) + assert p.segmenter is _decline_b + + +def test_parser_for_segmenter_none_clears_the_base() -> None: + # the third state UNSET buys (#272 review): None is a VALUE here, + # not an absence, so passing it explicitly drops the base's + # segmenter instead of inheriting the very thing it was asked to + # remove. Omitting the keyword is what carries the base's through + # (the test above), and this is how you derive an unsegmented + # parser from a segmented one without rebuilding its lexicon and + # policy by hand. + base = Parser(segmenter=_decline_a) + pack = locales.get("zh") + assert parser_for(pack, base=base, segmenter=None).segmenter is None + # ...and the base is untouched: parser_for builds a fresh Parser + assert base.segmenter is _decline_a + + +def test_parser_picklability_is_conditional_on_the_segmenter() -> None: + # declared contract (locales spec section 4): Parser pickles iff + # its segmenter does -- like any callable-holding object + p = pickle.loads(pickle.dumps(Parser(segmenter=_module_level_decline))) + assert p.segmenter is _module_level_decline + unpicklable = Parser(segmenter=lambda t: None) # constructs fine + with pytest.raises(Exception): # pickle's exception type varies + pickle.dumps(unpicklable) # only pickling fails + + +def test_segmenter_exceptions_propagate() -> None: + # the ONE exception to parse-totality (locales spec section 4, + # declared 2026-07-11): a user-supplied callable's own error is a + # user-code error, not a content error, and must not be swallowed + def boom(text: str) -> Segmentation | None: + raise RuntimeError("segmenter bug") + + p = parser_for(locales.get("zh"), base=Parser(segmenter=boom)) + with pytest.raises(RuntimeError, match="segmenter bug"): + p.parse("阿明") # zh pack active, 阿 unmatched by vocabulary -> + # the stage consults the segmenter + + +def test_the_segmenter_sees_only_an_undivided_name() -> None: + # its precondition (#272 Task 5): a segmenter answers where an + # UNDIVIDED name divides, so a name part carrying a second + # script-written token is already divided and the segmenter is not + # consulted -- otherwise "山田 太郎" would have its family divided + # again. A Latin title/suffix is not such a boundary. + def always(text: str) -> Segmentation | None: + return Segmentation((1,), confidence=1.0) + + p = parser_for(locales.get("zh"), base=Parser(segmenter=always)) + assert p.parse("阿明").family == "阿" # one token: consulted + n = p.parse("阿明 日月") # already divided + assert (n.family, n.given) == ("阿明", "日月") + assert p.parse("Dr 阿明").family == "阿" # a title is not a split + + +def test_a_segmenter_split_reaches_the_fields() -> None: + # end to end: the sub-slices the stage makes are ordinary tokens + # from there on, so the pack's family-first order reads them like + # any vocabulary split, and a low-confidence answer surfaces + def two(text: str) -> Segmentation | None: + return Segmentation((1,), confidence=0.5) + + p = parser_for(locales.get("zh"), base=Parser(segmenter=two)) + n = p.parse("阿明") + assert (n.family, n.given) == ("阿", "明") + assert [a.kind for a in n.ambiguities] == [AmbiguityKind.SEGMENTATION] + + # two cuts, three pieces -- and the token indices the comma + # structure recorded still name the right words afterwards + def three(text: str) -> Segmentation | None: + return Segmentation((1, 2)) + + q = parser_for(locales.get("zh"), base=Parser(segmenter=three)) + n3 = q.parse("Dr 阿明日, Jr.") + assert (n3.title, n3.family, n3.given, n3.middle, n3.suffix) == ( + "Dr", "阿", "明", "日", "Jr.") diff --git a/tests/v2/test_policy.py b/tests/v2/test_policy.py index d8fa5422..3fc69103 100644 --- a/tests/v2/test_policy.py +++ b/tests/v2/test_policy.py @@ -63,6 +63,7 @@ def test_name_order_rejects_plain_string_tuples() -> None: def test_script_values_are_the_public_names() -> None: assert Script.HAN == "han" and Script.HANGUL == "hangul" + assert Script.HIRAGANA == "hiragana" and Script.KATAKANA == "katakana" def test_patronymic_rules_coerce_and_reject() -> None: @@ -505,12 +506,20 @@ def test_patched_rejects_non_patch() -> None: def test_script_orders_default_and_canonical_storage() -> None: p = Policy() assert p.script_orders == DEFAULT_SCRIPT_ORDERS + # three entries now (#272 adds HIRAGANA), sorted by Script value: + # "han" < "hangul" < "hiragana" + assert p.script_orders == ( + (Script.HAN, FAMILY_FIRST), + (Script.HANGUL, FAMILY_FIRST), + (Script.HIRAGANA, FAMILY_FIRST), + ) # constructor tolerates a Mapping; storage is the sorted pair # tuple (hashability, the capitalization_exceptions precedent -- # which is also why the mapping spelling needs the ignore: the # field is annotated with what it STORES) q = Policy(script_orders={Script.HANGUL: FAMILY_FIRST, # type: ignore[arg-type] - Script.HAN: FAMILY_FIRST}) + Script.HAN: FAMILY_FIRST, + Script.HIRAGANA: FAMILY_FIRST}) assert q == p and hash(q) == hash(p) assert Policy(script_orders={}).script_orders == () # type: ignore[arg-type] diff --git a/tests/v2/test_properties.py b/tests/v2/test_properties.py index c17c6942..e66b4b4d 100644 --- a/tests/v2/test_properties.py +++ b/tests/v2/test_properties.py @@ -245,17 +245,23 @@ def _lexicons(draw: st.DrawFn) -> Lexicon: # script_orders' legal values are as restricted as name_order's (only # the three exported orders, keyed by Script), so they are sampled -# rather than generated. The four cover the axes that matter: the -# shipped default, the full opt-out, one script alone, and an order -# that disagrees with the default -- FAMILY_FIRST_GIVEN_LAST on hangul +# rather than generated. The five cover the axes that matter: the +# shipped default, the full opt-out, one script alone, an order that +# disagrees with the default -- FAMILY_FIRST_GIVEN_LAST on hangul # reads "김민준 수" differently from every other entry here, which is # what makes a script table that is merely PRESENT distinguishable -# from one that is actually consulted. +# from one that is actually consulted -- and a table keyed on the kana +# license's carrier (#272). That last row is the only one whose key is +# reached INDIRECTLY: a mixed kanji+kana name resolves to HIRAGANA +# rather than to any script it is literally written in, and pointing +# the carrier back at GIVEN_FIRST makes a consulted table visibly +# different from the default it would otherwise agree with. _SCRIPT_ORDER_TABLES = [ DEFAULT_SCRIPT_ORDERS, (), ((Script.HAN, FAMILY_FIRST),), ((Script.HANGUL, FAMILY_FIRST_GIVEN_LAST),), + ((Script.HIRAGANA, GIVEN_FIRST),), ] @@ -271,11 +277,11 @@ def _policies(draw: st.DrawFn) -> Policy: name_order=draw(st.sampled_from( [GIVEN_FIRST, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST])), script_orders=draw(st.sampled_from(_SCRIPT_ORDER_TABLES)), - # no max_size: Script has two members, so the unbounded draw - # already reaches every subset -- including the empty one, - # which is the documented segmentation opt-out, and the full - # one, which turns on the Han segmentation that only - # locales.ZH turns on in shipped configuration + # no max_size: Script has four members, so the unbounded draw + # still reaches every subset -- including the empty one, which + # is the documented segmentation opt-out, and the full one, + # which turns on the Han and kana activation that only + # locales.ZH and locales.JA turn on in shipped configuration segment_scripts=draw(st.frozensets(st.sampled_from(list(Script)))), patronymic_rules=draw(st.frozensets( st.sampled_from(list(PatronymicRule)), max_size=2)), diff --git a/tests/v2/test_regex_sync.py b/tests/v2/test_regex_sync.py index 68b7f6f6..6a9c72fd 100644 --- a/tests/v2/test_regex_sync.py +++ b/tests/v2/test_regex_sync.py @@ -23,6 +23,7 @@ from nameparser.config import regexes as _config from nameparser._pipeline import _assign, _post_rules, _tokenize, _vocab +from nameparser._policy import Script from nameparser import _render @@ -114,6 +115,9 @@ def test_initial_copies_agree_with_each_other_and_config() -> None: ("_tokenize", "_BIDI"): None, # re_bidi, not a REGEXES key # Mirrors _pipeline._state.COMMA_CHARS, not nameparser.config ("_render", "_COMMA_CHAR"): None, + # #272: derived from _SCRIPT_RANGES itself (like _SCRIPT_PATTERNS), + # not hand-copied from anywhere -- no config counterpart to pin. + ("_vocab", "_JA_PATTERN"): None, } _MODULES = {"_assign": _assign, "_post_rules": _post_rules, @@ -160,39 +164,73 @@ def test_comma_char_matches_the_pipeline_comma_set() -> None: def test_differential_cjk_rule_matches_the_script_ranges() -> None: - """The #271 rule in tools/differential/expected_changes.toml hand- - copies the CJK spans from _vocab._SCRIPT_RANGES into a character + """The CJK rule in tools/differential/expected_changes.toml hand- + copies the script spans from _vocab._SCRIPT_RANGES into a character class. A TOML file cannot import the constant, so this is the one copy with no possible alternative -- and the one whose divergence is quietest, because the harness is run by hand rather than in CI. Both failure directions matter, which is why this compares sets rather than checking coverage. A span MISSING from the class turns - an intended #271 change into an UNEXPLAINED diff (a release + an intended #271/#272 change into an UNEXPLAINED diff (a release blocker for the wrong reason); a span that should not be there silently classifies a real regression as intended, which is the failure the whole harness exists to prevent. - Han's astral block is out of scope on both sides. The rule omits - it deliberately -- no corpus name reaches it, see the comment - there -- so the comparison runs over the BMP spans only, and a new - BMP script added to _SCRIPT_RANGES still fails here until the rule - covers it. + Every table entry is in scope. The rule covered HAN and HANGUL + alone while the kana members existed only for classification, but + #272 gave HIRAGANA a default order entry and made the kana blocks + part of the same first/middle/last diff shape the rule explains, + so scoping it by issue no longer draws a real line. Comparing + against the whole table is also the stronger promise: a script + added to _SCRIPT_RANGES for ANY reason fails here until someone + decides, in writing, whether the rule should cover it. + + Han's astral block is the single exception, out of scope on both + sides. The rule omits it deliberately -- no corpus name reaches + it, see the comment there -- so the comparison runs over the BMP + spans only. + + The rule is also WIDER than the table by exactly one span, which + the equality has to know about or it would just fail forever. The + halfwidth middle dot U+FF65 changes parses without being + classified as anything: tokenize separates on it, so a halfwidth + transcription splits where 1.4 kept one token, while halfwidth + kana stays out of _SCRIPT_RANGES on purpose. Naming that span here + rather than relaxing the comparison to a subset check is what + keeps the pin honest in both directions: a THIRD source of + divergence still fails, and the one sanctioned difference has to + be written down to exist. """ toml_path = (Path(__file__).parents[2] / "tools" / "differential" / "expected_changes.toml") rules = tomllib.loads(toml_path.read_text())["change"] - matched = [r for r in rules if "#271" in r["issue"]] + matched = [r for r in rules + if "#271" in r["issue"] or "#272" in r["issue"]] assert len(matched) == 1, ( - f"expected exactly one #271 rule in {toml_path.name}, " + f"expected exactly one CJK rule in {toml_path.name}, " f"found {len(matched)}") + # A new table entry must force an explicit decision rather than + # quietly widening (or failing to widen) the rule above. + assert set(_vocab._SCRIPT_RANGES) == { + Script.HAN, Script.HANGUL, Script.HIRAGANA, Script.KATAKANA}, ( + "a Script joined _SCRIPT_RANGES: decide whether the " + f"differential rule in {toml_path.name} should cover it, then " + "update this assertion") declared = { (int(lo, 16), int(hi, 16)) for lo, hi in re.findall(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})", matched[0]["name_regex"])} + # the one span the rule carries that no Script claims (see above) + halfwidth_dot = (0xFF65, 0xFF65) + assert not any(lo <= halfwidth_dot[0] <= hi + for spans in _vocab._SCRIPT_RANGES.values() + for lo, hi in spans), ( + "U+FF65 is classified now; drop it from the sanctioned extras") expected = {span for spans in _vocab._SCRIPT_RANGES.values() - for span in spans if span[1] <= 0xFFFF} + for span in spans + if span[1] <= 0xFFFF} | {halfwidth_dot} assert declared == expected, ( - f"{toml_path.name}'s #271 name_regex declares {sorted(declared)}; " + f"{toml_path.name}'s CJK name_regex declares {sorted(declared)}; " f"_SCRIPT_RANGES' BMP spans are {sorted(expected)}") diff --git a/tests/v2/test_reprs.py b/tests/v2/test_reprs.py index 71ede1f5..b1c028b3 100644 --- a/tests/v2/test_reprs.py +++ b/tests/v2/test_reprs.py @@ -1,4 +1,5 @@ import dataclasses +import functools from typing import cast import pytest @@ -7,7 +8,13 @@ from nameparser._locale import Locale from nameparser._parser import Parser from nameparser._policy import FAMILY_FIRST, PatronymicRule, Policy, PolicyPatch -from nameparser._types import Ambiguity, AmbiguityKind, ParsedName, Role, Span, Token +from nameparser._types import ( + Ambiguity, AmbiguityKind, ParsedName, Role, Segmentation, Span, Token, +) + + +def _decline(text: str, blob: str = "") -> None: + return None # a stand-in segmenter for the Parser repr cases def test_token_repr_is_compact() -> None: @@ -91,6 +98,7 @@ def test_policy_patch_repr_shows_only_set_fields() -> None: Lexicon.default(), Lexicon.empty(), Parser(), + Parser(segmenter=functools.partial(_decline, blob="x" * 500)), ]) def test_config_reprs_never_leak_the_unset_sentinel(obj: object) -> None: # Guard the whole family: every config type's repr is bounded and @@ -99,6 +107,26 @@ def test_config_reprs_never_leak_the_unset_sentinel(obj: object) -> None: assert "_Unset" not in repr(obj) +def test_parser_repr_names_the_segmenter_without_its_contents() -> None: + # A callable is the one component whose own repr is unbounded: a + # partial spells out its bound arguments, a callable instance its + # memory address. Neither may reach Parser's repr, so the name comes + # from __qualname__ when there is one and the TYPE otherwise. + plain = repr(Parser(segmenter=_decline)) + assert "segmenter=_decline" in plain + partial = repr(Parser(segmenter=functools.partial(_decline, + blob="x" * 500))) + assert "segmenter=partial" in partial + assert "xxx" not in partial and len(partial) < len(plain) + 20 + + class Div: # a callable INSTANCE: no __qualname__, so the type + def __call__(self, text: str) -> Segmentation | None: + return None + + instance = repr(Parser(segmenter=Div())) + assert "segmenter=Div" in instance and "0x" not in instance + + def test_policy_patch_repr_survives_unvalidated_name_order() -> None: # PolicyPatch defers name_order validation to apply time, so a # patch can hold a non-canonical or even garbage tuple -- its repr diff --git a/tests/v2/test_types.py b/tests/v2/test_types.py index 8390fc5e..a0478ee0 100644 --- a/tests/v2/test_types.py +++ b/tests/v2/test_types.py @@ -1,10 +1,12 @@ -from collections.abc import Iterable +import dataclasses +from collections.abc import Iterable, Iterator import pytest from nameparser import parse from nameparser._types import ( - STABLE_TAGS, Ambiguity, AmbiguityKind, ParsedName, Role, Span, Token, + STABLE_TAGS, Ambiguity, AmbiguityKind, ParsedName, Role, Segmentation, + Span, Token, ) @@ -73,6 +75,75 @@ def test_token_is_frozen_and_hashable() -> None: assert hash(t) == hash(Token("Juan", Span(0, 4), Role.GIVEN)) +def test_segmentation_validates_splits() -> None: + s = Segmentation((2,), confidence=0.97) + assert s.splits == (2,) and s.confidence == 0.97 + assert Segmentation(()).confidence is None + with pytest.raises(ValueError, match="ascending"): + Segmentation((3, 2)) + with pytest.raises(ValueError, match="interior"): + Segmentation((0,)) + with pytest.raises(TypeError, match="integers"): + Segmentation(("2",)) # type: ignore[arg-type] + with pytest.raises(ValueError, match="confidence"): + Segmentation((2,), confidence=1.5) + # wrong TYPE is the TypeError branch, distinct from the range check + with pytest.raises(TypeError, match="confidence"): + Segmentation((2,), confidence="high") # type: ignore[arg-type] + + +def test_segmentation_rejects_mappings_and_non_iterables() -> None: + # Token.tags' guards, on the other collection field: a mapping + # would silently contribute only its keys, and a bare int would + # surface as an uncurated "not iterable" naming nothing + with pytest.raises(TypeError, match="Segmentation.splits"): + Segmentation({2: "a", 3: "b"}) # type: ignore[arg-type] + with pytest.raises(TypeError, match="Segmentation.splits"): + Segmentation(5) # type: ignore[arg-type] + # ...and the bare string, the third member of that family and the + # one with teeth: "" is iterable and empty, so without the guard + # Segmentation("") became "confidently one token" -- an opinion + # nobody stated -- while "23" failed one character deep naming '2' + # instead of the argument. Both must name the field. + for bad in ("", "23"): + with pytest.raises(TypeError, match="Segmentation.splits"): + Segmentation(bad) # type: ignore[arg-type] + + +def test_segmentation_rejects_bools_in_both_fields() -> None: + # bool is an int subclass in both slots: True as an offset is a + # comparison result leaking into an index, True as a confidence one + # leaking into a score. Behavioral twin of Token's span guard. + with pytest.raises(TypeError, match="Segmentation.splits"): + Segmentation((True,)) # type: ignore[list-item] + with pytest.raises(TypeError, match="Segmentation.confidence"): + Segmentation((2,), confidence=True) # type: ignore[arg-type] + + +def test_segmentation_lets_a_generator_raise_its_own_error() -> None: + # the non-iterable guard probes iter(), which cannot run a + # generator's body; wrapping the tuple() call instead would catch a + # TypeError raised INSIDE the user's generator and relabel it as + # "splits must be an iterable", pointing the reader at the wrong bug + def boom() -> Iterator[int]: + yield 1 + raise TypeError("boom") + + with pytest.raises(TypeError) as caught: + Segmentation(boom()) # type: ignore[arg-type] + # the message is the generator's own, whole -- a `match=` would + # pass on the relabeled text too, since the generator's repr names + # the function + assert str(caught.value) == "boom" + + +def test_segmentation_is_frozen_and_hashable() -> None: + s = Segmentation((2,)) + assert isinstance(hash(s), int) + with pytest.raises(dataclasses.FrozenInstanceError): + s.splits = (3,) # type: ignore[misc] + + def test_ambiguity_kind_members_are_their_string_values() -> None: assert AmbiguityKind.PARTICLE_OR_GIVEN == "particle-or-given" assert AmbiguityKind("order") is AmbiguityKind.ORDER @@ -366,6 +437,11 @@ def test_types_pickle_round_trip() -> None: assert pickle.loads(pickle.dumps(amb)) == amb tok = Token("de", Span(9, 11), Role.FAMILY, frozenset({"particle"})) assert pickle.loads(pickle.dumps(tok)) == tok + # the family sweep is the point of this test, so a frozen type + # added later belongs in it -- Segmentation (#272) carries the same + # guarded __getstate__/__setstate__ pair as its three siblings + seg = Segmentation((2,), confidence=0.42) + assert pickle.loads(pickle.dumps(seg)) == seg def test_span_add_is_blocked() -> None: diff --git a/tools/differential/expected_changes.toml b/tools/differential/expected_changes.toml index 050ebf73..07117624 100644 --- a/tools/differential/expected_changes.toml +++ b/tools/differential/expected_changes.toml @@ -7,25 +7,52 @@ # ahead of `fields`-only ones before matching. [[change]] -issue = "fix(#271) native-script CJK: family-first order + hangul segmentation" +issue = "fix(#271/#272) native-script CJK: family-first order, hangul segmentation, the kana license and the nakaguro" # '毛 泽东', '김민준': script_orders flips first/last for a name written # wholly in Han or Hangul, and the Korean surnames that now ship as # default vocabulary additionally split an unspaced hangul token into -# last + first. Seeded from the fix(#271) rows in tests/v2/cases.py. +# last + first. '高橋 みなみ', 'マイケル・ジャクソン': #272 extends the +# same order table to kana-bearing Japanese names and makes the +# katakana middle dot a token separator, which moves pieces between the +# same three fields. Seeded from the fix(#271) and fix(#272) rows in +# tests/v2/cases.py. +# +# One rule, not two, because it is one diff shape: both issues move +# name pieces between first/middle/last on native-script CJK input, and +# splitting the class by issue would need a rule per script with no +# gain in tightness (the fields list is already the narrow half). # # Scoped by script, which is exactly the scope of the behavior -- -# neither change can touch a name with no CJK character in it. Han's +# nothing here can touch a name with no CJK character in it. Han's # astral block (U+20000-U+323AF) is deliberately left out of the class: # no name in either corpus reaches it, and a rule should be no wider # than the diffs it has to explain. Extend it -- and the sync pin in # tests/v2/test_regex_sync.py, which fails if the two disagree -- if -# one ever does. +# one ever does. The kana spans are BMP-only for a different reason: +# supplementary-plane kana is hentaigana and other archaic forms no +# modern name uses, so _SCRIPT_RANGES does not list it either. +# +# U+FF65 is the one span here that _SCRIPT_RANGES does NOT have, and +# it is deliberate: the halfwidth middle dot separates tokens like its +# fullwidth twin, so 'マイケル・ジャクソン' splits where 1.4 left one token, +# but halfwidth kana is excluded from CLASSIFICATION on purpose (a +# separate normalization problem). The rest of the halfwidth block +# (U+FF66-U+FF9F) is left out for the usual tightness reason, and it +# was measured rather than assumed: a dotless halfwidth name such as +# 'マイケル ジャクソン' is byte-identical on both sides, so covering the +# block would pre-excuse a future regression on a shape that is +# parity today. The dot is the whole mechanism; the dot is the whole +# span. # # Expected to match nothing against the current corpora, which contain -# no CJK at all: build_issues_corpus.py requires an internal space, and -# unspaced names are the shape this classifies. Kept for the reason the -# suffix-delimiter rule below is kept -- ready the moment a name lands. -name_regex = "[\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3]" +# no CJK at all -- build_issues_corpus.py requires an internal space, +# and unspaced names are the shape this classifies. That blind spot is +# tracked as #272's and #271's shared gap in issue #295, which also +# records the provenance decision a fix needs; until it is closed the +# behavioral guarantee lives in the fix(#271)/fix(#272) rows of +# tests/v2/cases.py, and this rule is kept ready for the moment a CJK +# name lands in a corpus. +name_regex = "[\\u3005-\\u3005\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65]" fields = ["first", "middle", "last"] [[change]] diff --git a/uv.lock b/uv.lock index 7ac65e14..ba1e21c6 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.12' and python_full_version < '3.15'", - "python_full_version < '3.12'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -28,6 +37,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + [[package]] name = "ast-serialize" version = "0.5.0" @@ -494,6 +512,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "lightgbm" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/8e/4db5e29290d7e619c307fdb8dab0a0514090af2ce3ec483050e024ec6126/lightgbm-4.7.0.tar.gz", hash = "sha256:f8e20f682c9aabd000bcf4a7ed8aa6f473c1adfecccae34ec24e823d156f4af0", size = 1792896, upload-time = "2026-07-18T21:00:56.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/05/7213965863cba1ed0150ad045bceed6276a1afaaaedbaeff4699ec4f0ccb/lightgbm-4.7.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:dfc1cfe8e760387be1e7ba7a214688be21fdff96e4ed9749188f83e1877c2477", size = 1877851, upload-time = "2026-07-18T21:00:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/b2/86/f4fe714f2e0bf3941705a20d7f6849dc476276d71236e82ea6b0d6539b86/lightgbm-4.7.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:129535462686f274df179133643118c5c5c5667167fe6c3a28d955f0b3c8e868", size = 1498914, upload-time = "2026-07-18T21:00:36.549Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/b29580948b92e8c2f84dea70118ac702ff067dc52ec4ffb5d73c953536a5/lightgbm-4.7.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4529acec5c6fefe4768302a529707d0ead90f6a6f42df694b856212e09695b8", size = 3349492, upload-time = "2026-07-18T21:00:37.943Z" }, + { url = "https://files.pythonhosted.org/packages/15/eb/837ea3b40cc36e22eeebb9785c01e42b2c255d033eea1d2d9ee8e2540e55/lightgbm-4.7.0-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d23e922acd891e77212e4d0fbcee9ba973c96dee479491341d05ba595357ebb7", size = 3476028, upload-time = "2026-07-18T21:00:39.331Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0b/c5c17d862b12ce292f24cd85d40f2f8f8981668fbdbd43fdc2625eccbc79/lightgbm-4.7.0-py3-none-win_amd64.whl", hash = "sha256:f42d1e5b32b6f170e606d7c689c6165671da98d7bf37f1addec2623efc8740c9", size = 1360833, upload-time = "2026-07-18T21:00:40.865Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -568,6 +618,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy" version = "2.1.0" @@ -628,10 +687,32 @@ 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 = "namedivider-python" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightgbm" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, + { name = "regex" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/e4/69d1a59c468d4ea1b80ce2f598de9994f5da7fcff35889b402fdf870e74f/namedivider_python-0.4.1.tar.gz", hash = "sha256:ef5d0aecc71fbf020da2d8b84793109d48aaf65da9d244f4746973c4821b08c0", size = 35271, upload-time = "2025-11-03T04:45:49.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/b7/8d7aaef9fb1480ab77030c0189929bd5489d848fb390ce945223def2063d/namedivider_python-0.4.1-py2.py3-none-any.whl", hash = "sha256:efedf5cfcddc6fcc46c8116cc883955888d9eed6d518169fe6af1e1683055b3f", size = 46427, upload-time = "2025-11-03T04:45:50.761Z" }, +] + [[package]] name = "nameparser" source = { editable = "." } +[package.optional-dependencies] +ja = [ + { name = "namedivider-python" }, +] + [package.dev-dependencies] dev = [ { name = "dill" }, @@ -647,6 +728,8 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "namedivider-python", marker = "extra == 'ja'", specifier = ">=0.4" }] +provides-extras = ["ja"] [package.metadata.requires-dev] dev = [ @@ -661,6 +744,161 @@ dev = [ { name = "sphinx", specifier = ">=8" }, ] +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -670,6 +908,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178, upload-time = "2026-07-22T22:17:48.274Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736, upload-time = "2026-07-22T22:17:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438, upload-time = "2026-07-22T22:17:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634, upload-time = "2026-07-22T22:17:56.858Z" }, + { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860, upload-time = "2026-07-22T22:17:59.1Z" }, + { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100, upload-time = "2026-07-22T22:18:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804, upload-time = "2026-07-22T22:18:03.903Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447, upload-time = "2026-07-22T22:18:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -739,6 +1032,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -754,6 +1163,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "roman-numerals" version = "4.1.0" @@ -788,6 +1210,162 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "snowballstemmer" version = "3.1.1" @@ -820,7 +1398,9 @@ name = "sphinx" version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster" }, @@ -851,8 +1431,15 @@ name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster" }, @@ -999,6 +1586,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1008,6 +1610,15 @@ 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" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"