diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 290ee0ae..aacd72a7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -20,7 +20,14 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] + # 3.15 is pre-release until 2026-10-01 (#259): surface breakage + # without blocking the branch; flip to blocking when it goes final. + continue-on-error: ${{ matrix.python-version == '3.15' }} + env: + # Without this, uv sync honors .python-version (3.11) over the + # matrix interpreter and every job silently tests 3.11. + UV_PYTHON: ${{ matrix.python-version }} steps: - uses: actions/checkout@v7 @@ -28,6 +35,7 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install uv uses: astral-sh/setup-uv@v6 with: diff --git a/.python-version b/.python-version index c8cfe395..2c073331 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10 +3.11 diff --git a/AGENTS.md b/AGENTS.md index 46115f44..eca650d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,9 @@ uv run mypy # Lint uv run ruff check nameparser/ -# Debug how a specific name string is parsed (prints HumanName repr) +# Debug how a specific name string is parsed: 2.0 core parse() -- +# prints the ParsedName repr raw and capitalized, then initials; +# --json emits the component dict uv run python -m nameparser "Dr. Juan Q. Xavier de la Vega III" # Build docs @@ -107,6 +109,22 @@ Parse flow: Each named attribute (`title`, `first`, etc.) is a `@property` that joins its corresponding `_list`. Setters call `_set_list()` which runs the value through `parse_pieces()`, so assigning `hn.last = "de la Vega"` correctly re-parses prefix tokens. +## 2.0 API modules (`nameparser/_*.py`, `tests/v2/`) + +The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These conventions apply to all new-API code and are stricter than the v1 sections above. The full design record (rationale, settled-decision logs, dated amendments) lives in untracked `docs/superpowers/specs/`; this section is the enforceable subset. **A commit that establishes or amends one of these conventions must update this section in the same commit** — grep-driven staleness sweeps miss paraphrased prose (see Workflow above), so write-time maintenance is the mechanism, audits are the backstop. + +- **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 486-name corpus. `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. +- **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. +- **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). +- **Reprs are bounded**: render which fields deviate from a named baseline and by how much, never contents (`Lexicon(default + titles: +2)`). +- **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. +- **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. +- **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 cross-cutting `test_reprs.py` and `test_layering.py`, 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. + ## Extension Patterns **Adding a scalar `Constants` attribute + `HumanName` kwarg** (e.g. `initials_separator`, `suffix_delimiter`): @@ -120,7 +138,7 @@ Each named attribute (`title`, `first`, etc.) is a `@property` that joins its co Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_deepcopy_roundtrip`/`test_nickname_delimiters_deepcopy_roundtrip` in `tests/test_constants.py`), not just reliance on conftest's autouse snapshot/restore exercising it incidentally. `TupleManager`/`RegexTupleManager.__getattr__` answer *any* unknown attribute lookup — dunder probes like `__deepcopy__` raise `AttributeError` (never answered as config keys), everything else still returns `self.get(attr)`/`EMPTY_REGEX` — so a new manager subtype or a `__getattr__` tweak can silently break `copy.deepcopy` (this bit `RegexTupleManager` before the dunder-lookup guard was added). A direct test on the new attribute's own manager instance catches that where the conftest fixture, which never asserts on the copy, would not. As with the scalar-attribute pattern above, also check `AGENTS.md` itself for now-stale references when you touch this. -**Unknown-key attribute access on `TupleManager`/`RegexTupleManager` warns (1.4, #256)** — a key not currently in the dict emits `DeprecationWarning` naming the miss and the known keys (`_warn_unknown_key`), before falling back to the same `None`/`EMPTY_REGEX` default as before; `.get()` stays silent. Dunder probes (`__deepcopy__`) still raise `AttributeError` outright, and single-underscore probes (`_repr_html_`, IPython's `_ipython_canary_method_should_not_exist_`, etc.) are excluded from the warning too — no real config key starts with `_`, so both guards just keep protocol/introspection probes from misfiring as "typo" warnings. This means internal parser code that reads `self.C.regexes.` unconditionally (e.g. `squash_bidi`'s `bidi`) now warns if a caller's custom `regexes` dict omits that key — a previously-silent partial-override pattern is on the same deprecation path as an actual typo. +**Unknown-key attribute access on `TupleManager`/`RegexTupleManager` warns (1.4, #256) — 2.0 note: the warning became a hard `AttributeError` naming the known keys (shim `TupleManager`); the paragraph below describes the deleted v1 machinery and applies only when reading v1 history.** — a key not currently in the dict emits `DeprecationWarning` naming the miss and the known keys (`_warn_unknown_key`), before falling back to the same `None`/`EMPTY_REGEX` default as before; `.get()` stays silent. Dunder probes (`__deepcopy__`) still raise `AttributeError` outright, and single-underscore probes (`_repr_html_`, IPython's `_ipython_canary_method_should_not_exist_`, etc.) are excluded from the warning too — no real config key starts with `_`, so both guards just keep protocol/introspection probes from misfiring as "typo" warnings. This means internal parser code that reads `self.C.regexes.` unconditionally (e.g. `squash_bidi`'s `bidi`) now warns if a caller's custom `regexes` dict omits that key — a previously-silent partial-override pattern is on the same deprecation path as an actual typo. **Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc` ∈ `PREFIXES` ∩ `TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu` ∈ `PREFIXES` ∩ `bound_first_names` (position-dependent: leading token → first-name join, mid-name → last-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `bound_first_names`). diff --git a/README.rst b/README.rst index 7a0f8b4d..f2379c9a 100644 --- a/README.rst +++ b/README.rst @@ -3,67 +3,9 @@ Name Parser |Build Status| |PyPI| |PyPI version| |Documentation| |License| |Downloads| |Codecov| -📣 **nameparser 2.0 is in design** — a new immutable core API, with full -compatibility for existing code through 2.x. Read the `design RFC -`__ and share -feedback on `the discussion issue -`__. - -A simple Python (3.10+) module for parsing human names into their -individual components. - -* hn.title -* hn.first -* hn.middle -* hn.last -* hn.suffix -* hn.nickname -* hn.maiden -* hn.surnames *(middle + last)* -* hn.given_names *(first + middle)* -* hn.initials *(first initial of each name part)* -* hn.last_base *(last, minus any prefixes)* -* hn.last_prefixes *(leading last-name particles, e.g. "van der")* - -Supported Name Structures -~~~~~~~~~~~~~~~~~~~~~~~~~ - -The supported name structure is generally "Title First Middle Last Suffix", where all pieces -are optional. Comma-separated format like "Last, First" is also supported. - -1. Title Firstname "Nickname" Middle Middle Lastname Suffix -2. Lastname [Suffix], Title Firstname (Nickname) Middle Middle[,] Suffix [, Suffix] -3. Title Firstname M Lastname [Suffix], Suffix [Suffix] [, Suffix] - -How It Works -~~~~~~~~~~~~ - -The parser works in two layers. - -A **vocabulary layer** recognizes name pieces by what they are, using -configurable sets of known words: titles ("Dr."), suffixes ("III", "PhD"), -last-name prefixes ("de la"), conjunctions ("y", "&"), and delimited -nicknames ("Doc"). Titles and conjunctions chain together to handle complex -titles like "Asst Secretary of State"; prefixes join forward so "de la Vega" -stays one last name. This layer doesn't care where in the string a word -appears — and it's the layer you customize, by adding or removing entries -in the sets to fit your dataset. - -A **positional layer** then assigns everything the vocabulary layer didn't -claim, based purely on where it sits: the first unclaimed word is the first -name, the last one is the last name, and anything between them is a middle -name. There is no semantic understanding — "Dr" is a title when it comes -before a name and a suffix when it comes after ("pre-nominal" and -"post-nominal" would probably be better names) — and no attempt to correct -mistakes in the input. - -It attempts the best guess that can be made with a simple, deterministic, -rule-based approach — no statistical models or machine learning; the same -input always parses the same way. The positional layer assumes Western name -order (given name first), so the main use case is English and other -languages that share that structure. It can also try to correct the -capitalization of names that are all upper- or lowercase. It's not perfect, -but it gets you pretty far. +nameparser parses human names into seven fields — title, given, middle, +family, suffix, nickname, maiden. Results are immutable, configuration is +composable, and locale packs are opt-in. Installation ------------ @@ -72,95 +14,50 @@ Installation pip install nameparser -If you want to try out the latest code from GitHub you can -install with pip using the command below. - -``pip install -e git+https://github.com/derek73/python-nameparser.git`` - -If you need to handle lists of names, check out -`namesparser `_, a -compliment to this module that handles multiple names in a string. - +Requires Python 3.11+. Quick Start Example -------------------- - -:: - - >>> from nameparser import HumanName - >>> name = HumanName("Dr. Juan Q. Xavier de la Vega III (Doc Vega)") - >>> name - - >>> name.last - 'de la Vega' - >>> name.as_dict() - {'title': 'Dr.', 'first': 'Juan', 'middle': 'Q. Xavier', 'last': 'de la Vega', 'suffix': 'III', 'nickname': 'Doc Vega', 'maiden': ''} - >>> str(name) - 'Dr. Juan Q. Xavier de la Vega III (Doc Vega)' - >>> name.string_format = "{first} {last}" - >>> str(name) - 'Juan de la Vega' - - -Because the positional layer has no semantic understanding, position is -everything: +-------------------- -:: - - >>> name = HumanName("1 & 2, 3 4 5, Mr.") - >>> name - - -Customization -------------- +.. code-block:: python -Your project may need some adjustment for your dataset. Most customization -is vocabulary — `customizing the configured pre-defined sets`_ of titles, -prefixes, etc. that the vocabulary layer matches against. You can also do -your own pre- or post-processing, or subclass the `HumanName` class for -deeper changes. See the `full documentation`_ for more information. + >>> from nameparser import parse + >>> name = parse("Dr. Juan Q. Xavier de la Vega III") + >>> name.given, name.family + ('Juan', 'de la Vega') + >>> name.render("{family}, {given}") + 'de la Vega, Juan' +Those seven fields are ``title``, ``given``, ``middle``, ``family``, +``suffix``, ``nickname``, and ``maiden`` — plus aggregate views like +``given_names``, ``surnames``, ``family_base``, and ``family_particles`` +for combining or splitting them further. -`Full documentation`_ -~~~~~~~~~~~~~~~~~~~~~ +Learn more +---------- -.. _customizing the configured pre-defined sets: http://nameparser.readthedocs.org/en/latest/customize.html -.. _Full documentation: http://nameparser.readthedocs.org/en/latest/ - - -Contributing ------------- +* `Getting started `__ — the full tour: parsing, aggregates, dicts, rendering, dedup +* `Customizing the parser `__ — vocabulary, behavior, and presentation +* `Locale packs `__ — opt-in bundles for East Slavic patronymics, Turkic markers, and more +* There's also a CLI: ``python -m nameparser --json "Doe, John"`` -If you come across name piece that you think should be in the default config, you're -probably right. `Start a New Issue`_ and we can get them added. +Coming from 1.x +---------------- -Please let me know if there are ways this library could be structured to make -it easier for you to use in your projects. Read CONTRIBUTING.md_ for more info -on running the tests and contributing to the project. +Nothing breaks. 2.0 keeps ``HumanName`` and ``CONSTANTS`` working exactly +as before — same imports, same attributes, same mutation API. See +`Migrating from HumanName `__ +for translating a v1 customization into the new API, whenever that's +convenient for you. -**GitHub Project** +See the `release log `__ +for the full list of changes in the 2.0 series. -https://github.com/derek73/python-nameparser +License +------- -.. _CONTRIBUTING.md: https://github.com/derek73/python-nameparser/tree/master/CONTRIBUTING.md -.. _Start a New Issue: https://github.com/derek73/python-nameparser/issues -.. _click here to propose changes to the titles: https://github.com/derek73/python-nameparser/edit/master/nameparser/config/titles.py +LGPL licensed. See `LICENSE `__ +for details. .. |Build Status| image:: https://github.com/derek73/python-nameparser/actions/workflows/python-package.yml/badge.svg :target: https://github.com/derek73/python-nameparser/actions/workflows/python-package.yml diff --git a/docs/concepts.rst b/docs/concepts.rst new file mode 100644 index 00000000..5533413d --- /dev/null +++ b/docs/concepts.rst @@ -0,0 +1,133 @@ +How the parser works +==================== + +``parse()`` turns a name string into a +:class:`~nameparser.ParsedName`. This page explains the model behind +that call: how a string becomes tokens and tokens become fields, +where configuration lives and why it is split the way it is, why +parsers are plain values, and what happens when a name is genuinely +ambiguous. The task pages all build on these four ideas. + +From string to name +-------------------- + +Every parse follows the same path: the input string is split into +tokens, each token is assigned one of the seven roles — ``title``, +``given``, ``middle``, ``family``, ``suffix``, ``nickname``, +``maiden`` — and every string you read off the result is computed +from those tokens at read time. + +Parsing ``"Dr. Juan Q. Xavier de la Vega III"`` produces eight +tokens. The first is ``Dr.`` with the ``title`` role; ``de``, ``la``, +and ``Vega`` each carry the ``family`` role, which is why +``name.family`` returns ``"de la Vega"`` — the field is a view that +joins the family-role tokens in order, not a stored string. + +Each token also records where it came from. A span is a pair of +character positions bounding the token in the original string: +``Dr.`` has span ``(0, 3)``, and ``name.original[0:3]`` is exactly +``"Dr."``. Internally, spans let the pipeline refer to a token by +position instead of by text. v1 re-found name pieces by searching for +matching text, so a name with a repeated word could make the parser +rewrite the wrong occurrence (`issue #100 +`_ and its +relatives); a position cannot be confused with a look-alike. + +:class:`~nameparser.ParsedName` is frozen: there is no attribute +assignment, ever. If a parse is almost right and you want to fix one +field, you call ``.replace()``, which returns a new +:class:`~nameparser.ParsedName` with that field changed and everything +else — tokens, spans, the rest of the roles — carried over unchanged. +``str()`` renders the default view; nothing about calling it mutates +the value you called it on. + +The three containers +--------------------- + +Every piece of nameparser configuration falls into exactly one of +three places, and which one is decided by a single question: what does +this setting vary with? + +* :class:`~nameparser.Lexicon` holds everything that varies by + **language**: the vocabulary — titles, particles, suffixes, + conjunctions, and the rest of the word lists the parser matches + against. +* :class:`~nameparser.Policy` holds everything that varies by + **data source or application**: the behavior switches — name order, + patronymic rules, delimiters, strip flags — anything that changes + how the pipeline runs, not what words it recognizes. +* :ref:`Rendering arguments ` cover everything + that varies by **output destination**: the ``spec`` you pass to + ``render(spec)``, or a keyword to ``initials()``/``capitalized()``. + +"Dean" is a common given name, so it is not in the default titles +vocabulary — but in some data it is more common as a title. Which +reading is right is a fact about the language and domain the names +come from, not about any one dataset or report: that makes it a +:class:`~nameparser.Lexicon` entry. A CRM that always exports "Family, Given" strings is a fact +about that one data source, not about the language of the names in +it — that's a :class:`~nameparser.Policy`. One particular report +wanting names formatted as "Family, Given" while every other consumer +of the same parsed data wants "Given Family" is a fact about where the +string is going next, decided at the moment you render it — that's a +rendering argument, not something baked into how the name was parsed. + +This replaces v1's single ``Constants`` object, which mixed all three +concerns — vocabulary, behavior, and output formatting — into one +mutable bag plus a ``string_format`` template string. Sorting a +setting into the right container is largely mechanical once you ask +the "varies by what?" question above; see :doc:`migrate` for the +attribute-by-attribute mapping from the old ``Constants`` to the new +containers. + +Parsers are values +-------------------- + +``parse()`` is a convenience function over a module-level default +:class:`~nameparser.Parser`. You only need to build your own +:class:`~nameparser.Parser` (directly, or via +:func:`~nameparser.parser_for`) when you want non-default vocabulary +or behavior — and when you do, build it once and reuse it. Constructing +a :class:`~nameparser.Parser` validates its configuration up front, so +it's cheap but not free; parsing individual names is the hot path, and +a :class:`~nameparser.Parser` is designed to be called many times +without reconstruction. + +:class:`~nameparser.Lexicon`, :class:`~nameparser.Policy`, +:class:`~nameparser.Parser`, and :class:`~nameparser.ParsedName` are +all frozen and hashable. That means they're safe to share across +threads without locking, safe to use as dict keys or cache keys, and +equality means exactly what it says — two :class:`~nameparser.Parser` +instances built from equal configuration are equal values, not merely +two objects that happen to behave the same. Every piece of +configuration in the 2.0 API is a frozen value — including the +module-level default parser itself. + +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. + +Some calls are irreducibly ambiguous — both readings are legitimate, +and no amount of rule-tuning resolves them without breaking some other +name. Those surface as entries on ``ParsedName.ambiguities`` instead +of being silently guessed away. The canonical example: a leading "Van" +in "Van Johnson" reads as a given name (that's the common case for +that shape), but "Van" is also a family-name particle in plenty of +other names, so the parse records a ``particle-or-given`` ambiguity +alongside its answer. You can inspect ``ambiguities`` to decide, case +by case, whether your data needs a second look. + +Tokens also carry tags — a second, independent label alongside their +role, recording how a token was classified rather than what part of +the name it belongs to — but only a handful of them are part of the +stable API: ``particle``, ``conjunction``, ``initial``, and ``joined``. +Any tag written with a namespace prefix, like ``vocab:...``, is +provenance information for debugging how a token got classified — it +can change shape between releases and isn't something to match against +in your own code. If you need to branch on how a token was +classified, branch on role or on one of the four stable tags above, +not on a namespaced one. diff --git a/docs/conf.py b/docs/conf.py index 6449d56d..e69d2547 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -36,6 +36,12 @@ 'sphinx.ext.viewcode', ] +# Declaration order is canonical in this codebase (Role order drives the +# seven-field order everywhere), so the reference lists members in +# source order, not alphabetically -- Lexicon's vocabulary grouping and +# Policy's field ordering match the customize.rst table. +autodoc_member_order = 'bysource' + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -139,7 +145,9 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# (empty: docs/_static does not exist; a non-existent entry warns on +# every build and blocks -W) +html_static_path = [] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied diff --git a/docs/customize.rst b/docs/customize.rst index da1b8dcd..17369b43 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -1,610 +1,173 @@ -Customizing the Parser with Your Own Configuration -================================================== +Customizing the parser +======================= -:py:class:`~nameparser.config.Constants` is for application-level -configuration, set once at startup: the shared module-level ``CONSTANTS`` -instance is the only channel that reaches parses happening in code you don't -own -- helpers, pipelines, a third-party library using nameparser internally --- the same role ``logging`` and ``locale`` play elsewhere. For anything -scoped to one dataset, one library, or one test, pass your own ``Constants`` -instance instead -- see the three explicit forms under "Module-level Shared -Configuration Instance" below. +Every piece of nameparser configuration sorts into one of three places +by asking what it varies with: vocabulary varies by **language** +(:class:`~nameparser.Lexicon`), behavior varies by **data source or +application** (:class:`~nameparser.Policy`), and presentation varies by +**output destination** (a rendering argument). See :doc:`concepts` for +why the split is drawn there. -Recognition of titles, prefixes, suffixes and conjunctions is handled by -matching the lower case characters of a name piece with pre-defined sets -of strings located in :py:mod:`nameparser.config`. You can adjust -these predefined sets to help fine tune the parser for your dataset. - -Changing the Parser Constants ------------------------------ - -There are a few ways to adjust the parser configuration depending on your -needs. The config is available in two places. - -The first is via ``from nameparser.config import CONSTANTS``. - -.. doctest:: - - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS # doctest: +ELLIPSIS - - -The other is the ``C`` attribute of a ``HumanName`` instance, e.g. -``hn.C``. +Vocabulary: Lexicon +-------------------- .. doctest:: - >>> from nameparser import HumanName - >>> hn = HumanName("Dean Robert Johns") - >>> hn.C # doctest: +ELLIPSIS - - -Both places are usually a reference to the same shared module-level -:py:class:`~nameparser.config.CONSTANTS` instance, depending on how you -instantiate the :py:class:`~nameparser.parser.HumanName` class (see below). - - - -Editable attributes of nameparser.config.CONSTANTS -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* :py:data:`~nameparser.config.TITLES` - Pieces that come before the name. Includes all `first_name_titles`. Cannot include things that may be first names. -* :py:data:`~nameparser.config.FIRST_NAME_TITLES` - Titles that, when followed by a single name, that name is a first name, e.g. "King David". -* :py:data:`~nameparser.config.SUFFIX_ACRONYMS` - Pieces that come at the end of the name that may or may not have periods separating the letters, e.g. "m.d.". -* :py:data:`~nameparser.config.SUFFIX_NOT_ACRONYMS` - Pieces that come at the end of the name that never have periods separating the letters, e.g. "Jr.". -* :py:data:`~nameparser.config.SUFFIX_ACRONYMS_AMBIGUOUS` - Acronym suffixes from ``SUFFIX_ACRONYMS`` that also plausibly work as a given-name nickname on their own, e.g. "JD", "Ed". When one of these appears alone in parenthesis or quotes (e.g. ``'JEFFREY (JD) BRICKEN'``), it's kept as a nickname rather than reclassified as a suffix, since that's the more common reading in ambiguous, delimiter-only context (see the "Nickname Handling" section in the usage guide). -* :py:data:`~nameparser.config.CONJUNCTIONS` - Connectors like "and" that join the preceding piece to the following piece. -* :py:data:`~nameparser.config.PREFIXES` - Connectors like "del" and "bin" that join to the following piece but not the preceding, similar to titles but can appear anywhere in the name. -* :py:data:`~nameparser.config.CAPITALIZATION_EXCEPTIONS` - Dictionary of pieces that do not capitalize the first letter, e.g. "Ph.D". -* :py:data:`~nameparser.config.REGEXES` - Regular expressions used to find words, initials, nicknames, etc. - -Each set-valued constant comes with :py:func:`~nameparser.config.SetManager.add`, :py:func:`~nameparser.config.SetManager.remove`, and :py:func:`~nameparser.config.SetManager.discard` methods for tuning -the constants for your project. These methods automatically lower case and -remove punctuation to normalize them for comparison. The two dict-valued -constants (``CAPITALIZATION_EXCEPTIONS`` and ``REGEXES``) are edited with -normal dict operations. - -Adding Custom Nickname Delimiters -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -:py:meth:`~nameparser.parser.HumanName.parse_nicknames` recognizes delimiters -through two per-bucket collections: -:py:obj:`~nameparser.config.Constants.nickname_delimiters` (default: the -three built-ins -- ``quoted_word``, ``double_quotes`` and ``parenthesis``, -each resolved live from :py:attr:`~nameparser.config.Constants.regexes`, so -overriding e.g. ``CONSTANTS.regexes.parenthesis`` still works exactly as -before) and :py:obj:`~nameparser.config.Constants.maiden_delimiters` (empty -by default -- see "Routing to Maiden Name" below). - -To recognize an *additional* delimiter, add a compiled pattern to -``nickname_delimiters`` under any key, then re-run -:py:meth:`~nameparser.parser.HumanName.parse_full_name` to pick it up: + >>> from nameparser import Lexicon, Parser + >>> lex = Lexicon.default().add(titles={"dean"}) + >>> Parser(lexicon=lex).parse("Dean Robert Johns").title + 'Dean' + +:meth:`~nameparser.Lexicon.add` and :meth:`~nameparser.Lexicon.remove` +both return a new :class:`~nameparser.Lexicon` — the one you started +from (here, ``Lexicon.default()``) is never mutated. Every field +accepts a plain set of lowercase words, keyword by field name (``titles`` +above; ``particles``, ``suffix_words``, and the rest work the same +way) — see :doc:`modules` for the full field list. + +``capitalization_exceptions`` is the one pair-valued field — each entry +maps a lowercase key to its exact-cased replacement (``"phd"`` → +``"PhD"``), so it isn't a fit for ``add()``/``remove()``. Change it with +``dataclasses.replace()`` instead: ``dataclasses.replace(lex, +capitalization_exceptions=(("phd", "PhD"),))``. + +Two fields — ``suffix_acronyms_ambiguous`` and ``particles_ambiguous`` +— mark entries from ``suffix_acronyms`` and ``particles`` that are also +plausible as ordinary name words on their own (an acronym suffix that +doubles as a nickname, a particle that doubles as a given name). They +don't add new vocabulary by themselves; they narrow how an existing +entry is read when it appears alone. If you're not sure whether a word +you're adding is one of these ambiguous cases, leave it out — an +unrecognized word usually still parses reasonably, while a wrongly +disambiguated one silently picks the less likely reading. (That +conservatism is why ``dean`` above isn't in the default vocabulary in +the first place: "Dean" is also a common given name, and a default +that swallowed it as a title would misparse "Dean Martin" for +everyone.) + +Behavior: Policy +----------------- + +When your data source or application needs different parsing behavior +— a different name order, stricter suffix rules, extra delimiters — +set it on :class:`~nameparser.Policy`, a small, closed set of fields, +listed below. + +.. list-table:: + :header-rows: 1 + :widths: 22 28 50 + + * - Field + - Type + - Effect + * - ``name_order`` + - one of the three exported order constants + - Assigns positional (no-comma) input to given/middle/family in + this order. Use the exported ``GIVEN_FIRST`` (default), + ``FAMILY_FIRST``, or ``FAMILY_FIRST_GIVEN_LAST`` constants. + Ignored when a comma separates family from given ("Thomas, + John" puts the family name first); a comma that only sets off + suffixes ("John Smith, Jr.") leaves it governing the name part. + * - ``patronymic_rules`` + - ``frozenset[PatronymicRule]`` + - Reorders patronymic-shaped names via opt-in detectors — East + Slavic formal order (``EAST_SLAVIC``) or Turkic reversed order + (``TURKIC``). Defaults to empty. + * - ``middle_as_family`` + - ``bool`` + - Folds ``middle`` into ``family`` instead of splitting them — + for naming systems with no middle-name concept. Defaults to + ``False``. + * - ``nickname_delimiters`` + - ``frozenset[tuple[str, str]]`` + - Routes content enclosed by these delimiter pairs to + ``nickname``. Defaults to + :data:`~nameparser.DEFAULT_NICKNAME_DELIMITERS` — straight + quotes and parentheses plus the typographic conventions (smart + quotes, guillemets, CJK brackets, ...). + * - ``maiden_delimiters`` + - ``frozenset[tuple[str, str]]`` + - Routes content enclosed by these delimiter pairs to ``maiden`` + instead, and drops them from the effective nickname set. + Defaults to empty — see the routing example below. + * - ``extra_suffix_delimiters`` + - ``frozenset[str]`` + - Adds separators that split suffix groups, e.g. ``" - "`` for + ``"Jane Smith, RN - CRNA"``. Additions only — the comma always + splits suffix groups and cannot be replaced. + * - ``lenient_comma_suffixes`` + - ``bool`` + - Reads an initial-shaped suffix word after a comma as a suffix: + ``"John Smith, V"`` is John Smith the fifth when ``True`` + (default); ``False`` reads ``V`` as a given-name initial + instead. Multi-letter suffixes (``III``, ``MD``) are + unaffected. + * - ``strip_emoji`` + - ``bool`` + - Excludes emoji from tokenization — they appear in no field or + rendered view, though ``original`` keeps them. Defaults to + ``True``. + * - ``strip_bidi`` + - ``bool`` + - Excludes bidirectional control characters the same way. + Defaults to ``True``. .. doctest:: - >>> import re - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> hn = HumanName("Benjamin {Ben} Franklin", constants=Constants()) - >>> hn.nickname - '' - >>> hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}', re.U) - >>> hn.parse_full_name() - >>> hn.nickname - 'Ben' - -Routing to Maiden Name -~~~~~~~~~~~~~~~~~~~~~~~ - -Parenthesized (or otherwise delimited) alternate/maiden surnames -- -``"Baker (Johnson), Jenny"`` -- go to ``nickname`` by default, same as any -other delimited content. To route a delimiter to the first-class ``maiden`` -field instead, move its key from ``nickname_delimiters`` to -``maiden_delimiters`` on a ``Constants`` instance (a plain ``dict.pop()`` + -assign -- this preserves the live link back to ``regexes`` for the three -built-ins) *before* parsing a name with it, the same way you'd configure -``patronymic_name_order`` or ``middle_name_as_last``: - -.. doctest:: - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> C = Constants() - >>> C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') - >>> hn = HumanName("Baker (Johnson), Jenny", constants=C) - >>> hn.first, hn.last, hn.maiden - ('Jenny', 'Baker', 'Johnson') - -This also strips the parenthesized maiden name from the no-comma written -form, since routing happens before positional parsing: - -.. doctest:: - - >>> hn = HumanName("Jenny Baker (Johnson)", constants=C) - >>> hn.first, hn.last, hn.maiden - ('Jenny', 'Baker', 'Johnson') - -Routing an already-active built-in delimiter on an *existing* ``HumanName`` -instance and calling ``parse_full_name()`` again will not work: only the -``full_name`` setter resets the working copy of the name string back to the -original input, so re-parsing in place has nothing left for the moved -delimiter to match if it already matched during the first parse. Configure -the ``Constants`` first, as above. - -``maiden`` is not included in the default :py:obj:`~nameparser.config.Constants.string_format`, -so ``str(hn)`` is unaffected unless you add ``{maiden}`` to your own format. - -Other editable attributes -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* :py:obj:`~nameparser.config.Constants.string_format` - controls output from `str()` -* :py:obj:`~nameparser.config.Constants.empty_attribute_default` - value returned by empty attributes, defaults to empty string -* :py:obj:`~nameparser.config.Constants.capitalize_name` - If set, applies :py:meth:`~nameparser.parser.HumanName.capitalize` to :py:class:`~nameparser.parser.HumanName` instance. -* :py:obj:`~nameparser.config.Constants.force_mixed_case_capitalization` - If set, forces the capitalization of mixed case strings when :py:meth:`~nameparser.parser.HumanName.capitalize` is called. -* :py:obj:`~nameparser.config.Constants.suffix_delimiter` - additional delimiter used to split suffix groups after comma-splitting, e.g. ``" - "`` for names like ``"Jane Smith, RN - CRNA"``. Defaults to ``None`` (disabled). -* :py:obj:`~nameparser.config.Constants.initials_separator` - string placed between consecutive initials within the same name group (after the delimiter). Defaults to ``" "``, so ``"A. K."``; set to ``""`` for compact ``"A.K."``. -* :py:obj:`~nameparser.config.Constants.patronymic_name_order` - If set, detects Russian formal-order names (``Surname GivenName Patronymic``) via a trailing East-Slavic patronymic suffix and rotates the parts to Western order (``first=GivenName``, ``middle=Patronymic``, ``last=Surname``). Also detects reversed-order Azerbaijani/Central-Asian Turkic patronymics (``Surname GivenName PatronymicRoot Marker``, e.g. ``oglu``/``qizi``). Opt-in; see subsections below. -* :py:obj:`~nameparser.config.Constants.middle_name_as_last` - If set, folds middle names into the last name (``.last`` becomes what ``.surnames`` already was, ``.middle`` becomes empty). Opt-in; see subsection below. - - -Russian Formal Name Order -~~~~~~~~~~~~~~~~~~~~~~~~~ - -By default the parser treats all three-part names as ``First Middle Last``. For -Russian data in formal order (``Surname GivenName Patronymic``), enable -``patronymic_name_order``:: - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> C = Constants(patronymic_name_order=True) - >>> hn = HumanName("Ivanov Ivan Ivanovich", constants=C) - >>> hn.first, hn.middle, hn.last - ('Ivan', 'Ivanovich', 'Ivanov') - -Detection is anchored on a recognised East-Slavic patronymic suffix -(``-ovich``, ``-ovna``, ``-evich``, ``-evna``, ``-ichna``, and the irregular -forms ``-ilyich``, ``-kuzmich``, ``-lukich``, ``-fomich``, ``-fokich``; same -patterns in Cyrillic). A comma activates the parser's standard -Last, First Middle path, which already handles Russian formal order — -reordering is suppressed to avoid a double-transformation. - -**Opt-in tradeoff:** when the flag is on, any name whose last token happens to -end in a patronymic suffix is reordered — including Western names with -patronymic-form surnames such as ``"David Michael Abramovich"``. Enable this -flag only when your data is predominantly Russian formal-order names. - - -Turkic Patronymics -~~~~~~~~~~~~~~~~~~ - -Azerbaijani and Central-Asian formal names follow a different shape: a -4-word ``[Given] [Father's given name] [Marker] [Family]``, where the -marker is a standalone word (``oglu``/``oğlu`` "son of", -``qizi``/``qızı`` "daughter of", and further variants — see below), not a -bound suffix. The same ``patronymic_name_order`` flag also detects and -rotates the reversed, no-comma form of this shape:: - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> C = Constants(patronymic_name_order=True) - >>> hn = HumanName("Aliyev Vusal Said oglu", constants=C) - >>> hn.first, hn.middle, hn.last - ('Vusal', 'Said oglu', 'Aliyev') - -Natural order (``"Vusal Said oglu Aliyev"``) and comma order -(``"Aliyev, Vusal Said oglu"``) already parse correctly without this flag -and are left unchanged. - -Detection is scoped strictly to the 4-token shape (single-token first/last, -exactly two middle tokens, last token a recognised marker) — matching the -East-Slavic guard's token-count strictness above. Unlike that guard, there's -no additional check on the given-name token, since Turkic markers are a -small, closed set unlikely to coincide with an ordinary given name (whereas -East-Slavic patronymic suffixes can coincide with real Western surnames). -Recognised markers cover common transliterations and native orthographies: -Latin ``oglu``, ``oğlu``, ``ogly``, ``ogli``, ``o'g'li`` (and its Uzbek -modifier-apostrophe and right-single-quote variants), ``qizi``, ``qızı``, -``kizi``, ``kyzy``, ``gyzy``, ``uly``, ``uulu``; and Cyrillic ``оглу``, -``оглы``, ``оғлу``, ``ўғли``, ``угли``, ``кызы``, ``гызы``, ``қызы``, -``қизи``, ``улы``, ``ұлы``, ``уулу``. Matching is case-insensitive. - - -Suppressing Middle Names -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Some naming systems have no middle-name concept — everything after the given -name is lineage or family (e.g. Arabic patronymic chaining: given + father + -grandfather + family). Enable ``middle_name_as_last`` to fold the middle name -into the last name instead of splitting them:: - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> C = Constants(middle_name_as_last=True) - >>> hn = HumanName("Mohamad Ahmad Ali Hassan", constants=C) - >>> hn.first, hn.middle, hn.last - ('Mohamad', '', 'Ahmad Ali Hassan') - -The fold applies uniformly to comma input too, so both written forms of a name -converge on the same result:: - - >>> hn2 = HumanName("Hassan, Mohamad Ahmad Ali", constants=C) - >>> hn2.first, hn2.last - ('Mohamad', 'Ahmad Ali Hassan') - - -Splitting last-name prefix particles -------------------------------------- - -The :py:attr:`~nameparser.parser.HumanName.last_base` and -:py:attr:`~nameparser.parser.HumanName.last_prefixes` properties split the last -name at the boundary between leading prefix particles and the core surname. They -use the same ``PREFIXES`` set, so adding a particle makes the split pick it up -automatically:: - - >>> from nameparser import HumanName - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.prefixes.add('op') # doctest: +ELLIPSIS - SetManager({...}) - >>> HumanName("Jan op den Berg").last_base - 'Berg' - >>> HumanName("Jan op den Berg").last_prefixes - 'op den' - >>> CONSTANTS.prefixes.remove('op') # doctest: +ELLIPSIS - SetManager({...}) - -Note the ``remove`` call at the end — ``customize.rst`` examples share global -``CONSTANTS``, so mutations must be reversed to avoid affecting later examples. - -Because ``last_base`` is a plain string property, sorting a list of names by -core surname (ignoring prefix particles like *van*, *de la*) is just a key -function:: - - names = [ - HumanName("Vincent van Gogh"), - HumanName("Juan de la Vega"), - HumanName("John Smith"), - ] - sorted_names = sorted(names, key=lambda n: n.last_base.lower()) - # sort keys: 'gogh', 'smith', 'vega' → van Gogh, Smith, de la Vega - -To sort by first name when two people share the same ``last_base``, add it as -a secondary key:: - - sorted_names = sorted(names, key=lambda n: (n.last_base.lower(), n.first.lower())) - -Bound First Names ------------------- - -``CONSTANTS.bound_first_names`` controls bound given-name prefixes that attach -to the following word to form one first name. By default it contains -``{'abdul', 'abdel', 'abdal', 'abu', 'abou', 'umm'}``. - -Example:: - - >>> from nameparser import HumanName - >>> hn = HumanName("abdul salam ahmed salem") - >>> hn.first, hn.middle, hn.last - ('abdul salam', 'ahmed', 'salem') - -To **disable** the feature entirely:: - - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.bound_first_names.clear() - -To **add** a word (e.g. if your data uses ``mohamad`` as a bound prefix):: - - >>> CONSTANTS.bound_first_names.add('mohamad') - -To **remove** a single entry:: - - >>> CONSTANTS.bound_first_names.remove('umm') - -You can also pass a custom set per ``Constants`` instance:: - - >>> from nameparser.config import Constants - >>> c = Constants(bound_first_names={'abu', 'umm'}) - >>> hn2 = HumanName("abu bakr al saud", constants=c) - >>> hn2.first, hn2.last - ('abu bakr', 'al saud') - -Non-First-Name Prefixes ------------------------ - -``CONSTANTS.non_first_name_prefixes`` is the subset of prefixes that are *never* -a standalone first name (``de``, ``dos``, ``ibn``, ...). When a name **starts** -with one of these, there is no first name -- the whole thing is a surname. - -Example:: - - >>> from nameparser import HumanName - >>> hn = HumanName("de Mesnil") - >>> hn.first, hn.last - ('', 'de Mesnil') - -A member must be a prefix that is never a given name in any culture, and the set -must stay **disjoint** from ``bound_first_names`` (a word cannot both join to -the first name and never be a first name). Ambiguous particles that *can* be -given names (``van``, ``von``, ``della``, ``di``, ``del``, ...) are intentionally -excluded; add them yourself if your data warrants it:: - - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.non_first_name_prefixes.add('von') # doctest: +SKIP - -To **disable** the feature entirely:: - - >>> CONSTANTS.non_first_name_prefixes.clear() # doctest: +SKIP - -Parser Customization Examples ------------------------------ - -Removing a Title -~~~~~~~~~~~~~~~~ - -Take a look at the :py:mod:`nameparser.config` documentation to see what's -in the constants. Here's a quick walk through of some examples where you -might want to adjust them. - -"Hon" is a common abbreviation for "Honorable", a title used when -addressing judges, and is included in the default tiles constants. This -means it will never be considered a first name, because titles are the -pieces before first names. - -But "Hon" is also sometimes a first name. If your dataset contains more -"Hon"s than "Honorable"s, you may wish to remove it from the titles -constant so that "Hon" can be parsed as a first name. - -.. doctest:: - :options: +ELLIPSIS, +NORMALIZE_WHITESPACE - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> constants = Constants() - >>> hn = HumanName("Hon Solo", constants=constants) - >>> hn - - >>> constants.titles.remove('hon') - SetManager({'10th', ..., 'zoologist'}) - >>> hn = HumanName("Hon Solo", constants=constants) - >>> hn - - - -If you don't want to detect any titles at all, you can remove all of them: - -.. doctest:: - :options: +ELLIPSIS, +NORMALIZE_WHITESPACE - - >>> constants.titles.clear() - SetManager(set()) - - -Adding a Title -~~~~~~~~~~~~~~~~ - -You can also pass a ``Constants`` instance to ``HumanName`` on instantiation. - -"Dean" is a common first name so it is not included in the default titles -constant. But in some contexts it is more common as a title. If you would -like "Dean" to be parsed as a title, simply add it to the titles constant. - -You can pass multiple strings to both the :py:func:`~nameparser.config.SetManager.add` -and :py:func:`~nameparser.config.SetManager.remove` -methods and each string will be added or removed. Both functions -automatically normalize the strings for the parser's comparison method by -making them lower case and removing periods. - -.. doctest:: - :options: +ELLIPSIS, +NORMALIZE_WHITESPACE - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> constants = Constants() - >>> constants.titles.add('dean', 'Chemistry') - SetManager({'10th', ..., 'zoologist'}) - >>> hn = HumanName("Assoc Dean of Chemistry Robert Johns", constants=constants) - >>> hn - - - -Module-level Shared Configuration Instance ------------------------------------------- - -As established above, ``CONSTANTS`` is shared by every ``HumanName`` created -without its own config -- that's what makes it the right place for -application-level setup, and also the source of the one gotcha it carries: -changing the config on one instance changes the behavior of every other -instance that shares it, which can be surprising if you only meant to -configure the one you're holding. Parsing itself never modifies the -configuration — only your own ``add`` and ``remove`` calls do — so the shared -instance is safe to read concurrently, e.g. parsing names on multiple threads. - -.. doctest:: module config - :options: +ELLIPSIS, +NORMALIZE_WHITESPACE - - >>> from nameparser import HumanName - >>> instance = HumanName("") - >>> instance.C.titles.add('dean') - SetManager({'10th', ..., 'zoologist'}) - >>> other_instance = HumanName("Dean Robert Johns") - >>> other_instance # Dean parses as title - - - -If you'd prefer new instances to have their own config values, pass your own -:py:class:`~nameparser.config.Constants` instance as the ``constants`` -argument when instantiating ``HumanName``. There are three spellings, -depending on which config you want the new instance to start from: - -.. code-block:: - - HumanName(name) # shared CONSTANTS (unchanged) - HumanName(name, constants=Constants()) # private, fresh library defaults - HumanName(name, constants=CONSTANTS.copy()) # private, snapshot of the current shared config - -The middle and last forms both give the instance an independent config that -further changes to ``CONSTANTS`` won't reach, but they answer different -questions: ``Constants()`` ignores any customization already made to -``CONSTANTS`` and starts clean, while ``CONSTANTS.copy()`` carries those -customizations over into the private copy. Each instance always has a ``C`` -attribute, but if you didn't pass one of the private forms to the -``constants`` argument then it's a reference to the module-level config -values with the behavior described above. - -.. doctest:: module config - :options: +ELLIPSIS, +NORMALIZE_WHITESPACE - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> instance = HumanName("Dean Robert Johns") - >>> instance.has_own_config - False - >>> instance.C.titles.add('dean') - SetManager({'10th', ..., 'zoologist'}) - >>> other_instance = HumanName("Dean Robert Johns", Constants()) # <-- fresh, private config - >>> other_instance - - >>> other_instance.has_own_config - True - -.. deprecated:: 1.4.0 - Passing ``None`` as the ``constants`` argument also builds a fresh - ``Constants()``, but is deprecated: ``None`` conventionally means "use - the default," which here is the *shared* ``CONSTANTS`` -- the opposite of - what passing ``None`` actually does. It emits a ``DeprecationWarning`` - and will raise ``TypeError`` in 2.0 (issue #260); use one of the two - explicit forms above instead. - -Don't Remove Emojis -~~~~~~~~~~~~~~~~~~~ - -By default, all emojis are removed from the input string before the name is parsed. -You can turn this off by setting the ``emoji`` regex to ``False``. - -.. doctest:: - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> constants = Constants() - >>> constants.regexes.emoji = False - >>> hn = HumanName("Sam 😊 Smith", constants=constants) - >>> str(hn) - 'Sam 😊 Smith' - -Don't Remove Bidi Control Characters -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -By default, invisible bidirectional control characters (the left-to-right and -right-to-left marks and friends, common in copy-pasted right-to-left names) are -removed from the input string before the name is parsed. You can turn this off -by setting the ``bidi`` regex to ``False``. - -.. doctest:: - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> constants = Constants() - >>> constants.regexes.bidi = False - >>> hn = HumanName("\u200fJohn\u200f Smith", constants=constants) - >>> hn.first == "\u200fJohn\u200f" - True - -Config Changes May Need Parse Refresh -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + >>> from nameparser import Parser, Policy + >>> policy = Policy(maiden_delimiters=frozenset({("(", ")")})) + >>> Parser(policy=policy).parse("Jane (Jones) Smith").maiden + 'Jones' -The full name is parsed upon assignment to the ``full_name`` attribute or -instantiation. Sometimes after making changes to configuration or other inner -data after assigning the full name, the name will need to be re-parsed with the -:py:func:`~nameparser.parser.HumanName.parse_full_name()` method before you see -those changes with ``repr()``. +A pair routes to exactly one field, and ``maiden_delimiters`` states +the specific intent — so listing a pair there automatically drops it +from the effective ``nickname_delimiters`` set, and the one-liner +above is the whole recipe. To *add* delimiters instead of rerouting +them, build on the named default: +``nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS | {("«", "»")}``. +.. _rendering-arguments: -Adjusting names after parsing them -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Presentation: rendering arguments +---------------------------------- -Each attribute has a corresponding ordered list of name pieces. If you're doing -pre- or post-processing you may wish to manipulate these lists directly. -The strings returned by the attribute names just join these lists with spaces. +Once a name is parsed, how it's displayed is a separate decision made +at the point of output, not baked into the parse: -* o.title_list -* o.first_list -* o.middle_list -* o.last_list -* o.suffix_list -* o.nickname_list -* o.maiden_list +.. code-block:: python -:: + def render(self, spec: str = "{title} {given} {middle} {family} {suffix}") -> str: ... + def initials(self, spec: str = "{given} {middle} {family}", + delimiter: str = ".", separator: str = " ") -> str: ... + def capitalized(self, lexicon: Lexicon | None = None, *, force: bool = False) -> ParsedName: ... - >>> hn = HumanName("Juan Q. Xavier Velasquez y Garcia, Jr.") - >>> hn.middle_list - ['Q.', 'Xavier'] - >>> hn.middle_list += ["Ricardo"] - >>> hn.middle_list - ['Q.', 'Xavier', 'Ricardo'] +Looking for v1's ``string_format``? It's the ``render(spec)`` argument +now — pass your own format string per call instead of setting it once +on a shared config object. See :doc:`usage` for worked examples of all +three. +Sharing a configured parser +---------------------------- -You can also replace any name bucket's contents by assigning a string or a list -directly to the attribute. +A :class:`~nameparser.Parser` is a frozen value, so the way to share +one configuration across a codebase is the same way you'd share any +other constant: build it once at module level and import it wherever +you parse. -:: +.. code-block:: python - >>> hn = HumanName("Dr. John A. Kenneth Doe") - >>> hn.title = ["Associate","Professor"] - >>> hn.suffix = "Md." - >>> hn - + # myapp/names.py + from nameparser import Lexicon, Parser, Policy + lex = Lexicon.default().add(titles={"dean"}) + policy = Policy(strip_emoji=False) + parser = Parser(lexicon=lex, policy=policy) + # elsewhere + from myapp.names import parser + name = parser.parse(raw_name) +Because :class:`~nameparser.Parser` and its ``lexicon``/``policy`` are +immutable and hashable, ``parser`` is safe to import and call from +multiple threads with no locking — there is no shared mutable state to +protect, unlike v1's module-level ``CONSTANTS``. diff --git a/docs/index.rst b/docs/index.rst index f7ce0760..e85eb681 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,71 +6,38 @@ Python Human Name Parser ======================== -Version |release| +Version |release| -A simple Python module for parsing human names into their individual -components. +nameparser parses human names into seven fields — title, given, +middle, family, suffix, nickname, maiden. Results are immutable, +configuration is composable (:class:`~nameparser.Lexicon` for +vocabulary, :class:`~nameparser.Policy` for behavior), and locale +packs are opt-in. Requires Python 3.11+. -* hn.title -* hn.first -* hn.middle -* hn.last -* hn.suffix -* hn.nickname -* hn.maiden +.. doctest:: -Supports 3 different comma placement variations in the input string. + >>> from nameparser import parse + >>> name = parse("Dr. Juan Q. Xavier de la Vega III") + >>> name.given, name.family + ('Juan', 'de la Vega') + >>> name.render("{family}, {given}") + 'de la Vega, Juan' -1. Title Firstname "Nickname" Middle Middle Lastname Suffix -2. Lastname [Suffix], Title Firstname (Nickname) Middle Middle[,] Suffix [, Suffix] -3. Title Firstname M Lastname [Suffix], Suffix [Suffix] [, Suffix] - - -It attempts the best guess that can be made with a simple, rule-based -approach. It's not perfect, but it gets you pretty far. - -Its main use case is English, but it may be useful for other latin-based languages, especially -if you are willing to `customize it`_, but it is not likely to be useful for languages -that do not share the same structure as English names. - -.. _customize it: customize.html - -Instantiating the `HumanName` class with a string splits on commas and then spaces, -classifying name parts based on placement in the string and matches against known name -pieces like titles. It joins name pieces on conjunctions and special prefixes to last names like -"del". Titles can be chained together and include conjunctions to handle -titles like "Asst Secretary of State". It can also try to correct -capitalization. - -It does not attempt to correct input mistakes. When there is ambiguity that cannot be resolved by a rule-based approach, -HumanName prefers to handle the most common cases correctly. For example, -"Dean" is not parsed as title because it is more common as a first name -(You can customize this behavior though, see `Parser Customization Examples`_). - -.. _Parser Customization Examples: customize.html#parser-customization-examples - - -Parsing Names -------------- +Coming from 1.x? :doc:`migrate`. .. toctree:: :maxdepth: 2 - + usage + concepts customize - -**Developer Documentation** - -.. toctree:: - :maxdepth: 2 - + locales + migrate modules - resources release_log + resources contributing - - Indices and tables ================== @@ -80,4 +47,3 @@ Indices and tables **GitHub Project**: https://github.com/derek73/python-nameparser - diff --git a/docs/locales.rst b/docs/locales.rst new file mode 100644 index 00000000..97f2fdc2 --- /dev/null +++ b/docs/locales.rst @@ -0,0 +1,144 @@ +Locale packs +============ + +A locale pack is an opt-in bundle of policy — and, when a naming +tradition needs it, vocabulary — for one specific pattern: East Slavic +patronymics, Turkic patronymic markers, and so on. Packs apply only +when you ask for one by name, and every pack makes the same promise: +a pack never changes a name it doesn't declare. + +Using a pack +------------ + +:func:`~nameparser.parser_for` folds one or more packs onto a base +:class:`~nameparser.Parser` (the module default, unless you pass +``base=``). Here the Russian pack reads "Сидоров Иван Петрович" +(Sidorov Ivan Petrovich — surname/given/patronymic order) the way a +formal Russian document intends: + +.. doctest:: + + >>> from nameparser import locales, parser_for + >>> ru = parser_for(locales.RU) + >>> ru.parse("Сидоров Иван Петрович").given + 'Иван' + +Packs stack: pass more than one pack and their policies fold together +in order. + +.. doctest:: + + >>> both = parser_for(locales.RU, locales.TR_AZ) + >>> sorted(rule.name for rule in both.policy.patronymic_rules) + ['EAST_SLAVIC', 'TURKIC'] + +Find what's shipped with :func:`~nameparser.locales.available`, and +look one up dynamically by its lowercase code with +:func:`~nameparser.locales.get` — the same code the ``--locale`` flag +takes: + +.. doctest:: + + >>> locales.available() + ('ru', 'tr_az') + >>> locales.get("ru") is locales.RU + True + +The command line accepts the same codes: ``python -m nameparser +--locale ru --json "Сидоров Иван Петрович"`` applies the pack before +parsing, equivalent to ``parser_for(locales.get("ru"))``. + +.. list-table:: Shipped packs + :header-rows: 1 + :widths: 15 85 + + * - Code + - Turns on + * - ``ru`` + - East Slavic patronymic order — detects a formal + given/patronymic/family shape (Cyrillic and transliterated + ``-ovich``/``-ovna``-style endings) and assigns it accordingly. + * - ``tr_az`` + - Turkic patronymic markers — detects a standalone marker token + (``oglu``, ``qizi``, ``uulu``, and their Latin- and + Cyrillic-script variants) and reads the name around it as + given/middle/family. + +Both shipped packs are policy-only — they carry no vocabulary of their +own; see :doc:`concepts` for why that split (language vocabulary vs. +behavior) is drawn where it is. + +Creating your own Locale +------------------------- + +You don't need to touch nameparser's registry to use your own pack — +:class:`~nameparser.Locale` is a plain, constructible value: +``Locale(code=..., lexicon=..., policy=PolicyPatch(...))``. A +:class:`~nameparser.PolicyPatch` is a :class:`~nameparser.Policy`-shaped +patch: every field defaults to :data:`~nameparser.UNSET` (leave it +alone) instead of to a concrete value, so a pack only ever states what +it changes. + +.. doctest:: + + >>> from nameparser import Lexicon, Locale, PolicyPatch, parser_for + >>> lex = Lexicon.empty().add(titles={"kapitan"}) + >>> mine = Locale(code="mycorp", lexicon=lex, + ... policy=PolicyPatch(middle_as_family=True)) + >>> name = parser_for(mine).parse("Kapitan Anna Maria Schmidt") + >>> name.title, name.given, name.family + ('Kapitan', 'Anna', 'Maria Schmidt') + +That pack does two things at once: the :class:`~nameparser.Lexicon` +fragment teaches the parser that ``kapitan`` is a title, and the +:class:`~nameparser.PolicyPatch` turns on ``middle_as_family`` so any +remaining given-position words after the first fold into ``family`` +instead of ``middle`` — compare this to the default parser's reading +of the same string, which has no title and splits ``given='Kapitan'``, +``middle='Anna Maria'``, ``family='Schmidt'``. + +When ``parser_for`` folds one or more packs onto a base, lexicons +union (a pack's words are added to the base's, never removed); policy +fields declared as set-valued in :class:`~nameparser.PolicyPatch` +(``patronymic_rules`` and the delimiter fields) union the same way; +and every other, scalar field is later-wins — if two packs (or a pack +and an explicit conflicting value) set the same scalar field, the last +one applied wins and a ``UserWarning`` is raised so the conflict +isn't silent. + +Contributing a pack to nameparser +---------------------------------- + +Shipping a pack in nameparser itself (rather than keeping it local to +your own code) means meeting the in-repo contract, checked mechanically +by ``tests/v2/test_locales.py``: + +#. Add a registry entry in ``nameparser/locales/__init__.py`` — a + ``"CODE": ("module.path", "ATTR")`` row in ``_REGISTRY``, so the + pack loads lazily on first access (importing ``nameparser.locales`` + never imports pack modules). +#. Declare a module-level ``DEVIATES(name)`` predicate: given a name + string, return whether *this pack alone* might parse it differently + from the default parser. Over-declaring is safe; under-declaring is + not — when in doubt, ``DEVIATES`` should say yes. +#. Add a rotator list to ``tests/v2/test_locales.py`` with at least one + name exercising every alternation branch of every marker regex the + pack defines — ``test_rotators_cover_every_marker_branch`` fails + until each branch is hit. +#. 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 + silent, undeclared side effects on names outside the pack's stated + scope. +#. Keep the pack policy-only in 2.0 — ``ru`` and ``tr_az`` both ship + an empty :class:`~nameparser.Lexicon`; a pack that wants to carry + its own vocabulary is a later conversation. +#. 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 to copy +from. Staged packs in progress are tracked in issues `#271 +`_, `#272 +`_, and `#146 +`_. diff --git a/docs/migrate.rst b/docs/migrate.rst new file mode 100644 index 00000000..48fe1472 --- /dev/null +++ b/docs/migrate.rst @@ -0,0 +1,246 @@ +Migrating from HumanName +========================= + +Nothing breaks. 2.0 keeps ``HumanName`` and ``CONSTANTS`` working +exactly as before — same imports, same attributes, same mutation API +(``name.C.titles.add(...)``, ``name.first = "..."``, and so on). +Upgrading to 2.0 and migrating your code to the new +:class:`~nameparser.Parser`/:class:`~nameparser.Lexicon`/ +:class:`~nameparser.Policy` API are two separate decisions — you can do +the former today and the latter whenever it's convenient, or never. The +compatibility layer (``HumanName`` and ``nameparser.config``) is +removed in 3.0; that release is not scheduled. + +The full 1.x documentation remains the canonical reference for +``HumanName`` and stays online at the readthedocs ``stable`` build, +currently the 1.4.0 release: https://nameparser.readthedocs.io/en/stable/ + +This page exists for the other direction: translating a v1 +customization or a v1-shaped comparison into the 2.0 API, one row per +old name. + +Attribute map +------------- + +``HumanName``'s seven fields and their aggregates map onto +:class:`~nameparser.ParsedName` like this: + +.. list-table:: + :header-rows: 1 + :widths: 30 40 30 + + * - ``HumanName`` + - ``ParsedName`` + - Note + * - ``title`` + - ``title`` + - + * - ``first`` + - ``given`` + - + * - ``middle`` + - ``middle`` + - + * - ``last`` + - ``family`` + - + * - ``suffix`` + - ``suffix`` + - + * - ``nickname`` + - ``nickname`` + - + * - ``maiden`` + - ``maiden`` + - New field (added 1.3) + * - ``title_list``, ``first_list``, ``middle_list``, ``last_list``, + ``suffix_list``, ``nickname_list``, ``maiden_list`` + - ``tokens_for(Role.TITLE)``, ``tokens_for(Role.GIVEN)``, ... + - Returns the raw :class:`~nameparser.Token` tuple for that role; + read ``.text`` off each token for the string a ``_list`` + attribute gave you + * - ``given_names`` / ``given_names_list`` + - ``given_names`` + - Unchanged name; ``middle`` folded into ``first`` + * - ``surnames`` / ``surnames_list`` + - ``surnames`` + - Unchanged name; ``middle`` folded into ``last`` + * - ``last_base`` / ``last_base_list`` + - ``family_base`` + - The surname with leading particles split off + * - ``last_prefixes`` / ``last_prefixes_list`` + - ``family_particles`` + - The particles ``family_base`` was split from (e.g. ``"de la"``) + * - ``string_format`` + - ``render(spec)`` + - A per-call argument now, not stored config — see :doc:`customize` + * - ``initials_format``, ``initials_delimiter``, ``initials_separator`` + - ``initials(spec, delimiter, separator)`` + - Same three knobs, now call-site arguments to + :meth:`~nameparser.ParsedName.initials` + * - ``suffix_delimiter`` + - ``Policy(extra_suffix_delimiters={...})`` + - Moves from a ``HumanName``/``Constants`` scalar to a ``Policy`` + set field, so more than one custom delimiter can be active at + once + * - ``capitalize()``, ``force_mixed_case_capitalization`` + - ``capitalized(lexicon, force=...)`` + - :meth:`~nameparser.ParsedName.capitalized` returns a new value + rather than mutating in place + +Side by side: + +.. doctest:: + + >>> from nameparser import HumanName, parse, Role + >>> hn = HumanName("Dr. Juan Q. Xavier de la Vega III") + >>> n = parse("Dr. Juan Q. Xavier de la Vega III") + >>> hn.title == n.title, hn.first == n.given, hn.last == n.family + (True, True, True) + >>> hn.first_list == [t.text for t in n.tokens_for(Role.GIVEN)] + True + >>> hn.given_names == n.given_names, hn.surnames == n.surnames + (True, True) + >>> hn.last_base == n.family_base, hn.last_prefixes == n.family_particles + (True, True) + +Config map +---------- + +``CONSTANTS``' vocabulary sets map onto :class:`~nameparser.Lexicon` +fields: + +.. list-table:: + :header-rows: 1 + :widths: 30 40 30 + + * - ``CONSTANTS`` + - ``Lexicon`` + - Note + * - ``titles`` + - ``titles`` + - + * - ``first_name_titles`` + - ``given_name_titles`` + - + * - ``suffix_acronyms`` + - ``suffix_acronyms`` + - + * - ``suffix_not_acronyms`` + - ``suffix_words`` + - + * - ``suffix_acronyms_ambiguous`` + - ``suffix_acronyms_ambiguous`` + - + * - ``prefixes`` + - ``particles`` + - + * - ``non_first_name_prefixes`` + - ``particles_ambiguous`` + - **Flipped** — see the warning below + * - ``conjunctions`` + - ``conjunctions`` + - + * - ``bound_first_names`` + - ``bound_given_names`` + - + * - ``capitalization_exceptions`` + - ``capitalization_exceptions`` + - Pair-valued; set it via ``dataclasses.replace(lexicon, + capitalization_exceptions={...})``, not ``add()``/``remove()`` + +And behavior/render scalars map onto :class:`~nameparser.Policy` (or a +rendering argument, where the 2.0 equivalent isn't config at all): + +.. list-table:: + :header-rows: 1 + :widths: 30 40 30 + + * - ``CONSTANTS`` + - 2.0 equivalent + - Note + * - ``patronymic_name_order`` + - ``Policy(patronymic_rules={PatronymicRule.EAST_SLAVIC, + PatronymicRule.TURKIC})`` + - v1's single flag enabled both detectors at once; pick one rule + (or a locale pack, see :doc:`locales`) if you only want one + tradition + * - ``middle_name_as_last`` + - ``Policy.middle_as_family`` + - + * - ``nickname_delimiters`` + - ``Policy.nickname_delimiters`` + - Was a dict of named sentinels; now a plain ``frozenset`` of + ``(open, close)`` pairs. Both APIs gained the #273 typographic + defaults (smart quotes, guillemets, CJK brackets, ...) in 2.0 + * - ``maiden_delimiters`` + - ``Policy.maiden_delimiters`` + - Same shape change as ``nickname_delimiters``. Precedence + differs: in the 2.0 API a pair listed here wins over + ``nickname_delimiters``; through the 1.x facade a pair in both + buckets keeps parsing as ``nickname`` (v1 behavior) + * - ``regexes.bidi`` + - ``Policy.strip_bidi`` + - ``regexes.bidi = False`` becomes ``Policy(strip_bidi=False)`` + * - ``regexes.emoji`` + - ``Policy.strip_emoji`` + - ``regexes.emoji = False`` becomes ``Policy(strip_emoji=False)`` + * - ``force_mixed_case_capitalization`` + - ``capitalized(force=True)`` + - Moves from stored config to a per-call argument + * - ``capitalize_name`` + - *(no equivalent)* + - 2.0 never capitalizes automatically during ``parse()``; call + ``.capitalized()`` explicitly on the result instead + +Every other ``regexes.*`` entry (``word``, ``spaces``, and the rest of +the compiled-pattern proxy) has no 2.0 replacement — parsing behavior +is configured entirely through named ``Policy`` fields now, not by +handing the parser a regex. + +.. warning:: + + ``non_first_name_prefixes`` and ``particles_ambiguous`` mark + **complementary** sets, not the same set under a new name. + ``non_first_name_prefixes`` lists particles that are *never* read as + a given name; ``particles_ambiguous`` lists the particles that + *may* be read as one. Translating a customization means flipping + the set: ``particles_ambiguous = lexicon.particles - + constants.non_first_name_prefixes``. Copying + ``non_first_name_prefixes`` straight into ``particles_ambiguous`` + silently inverts which particles are allowed to double as a given + name. + +Comparison +---------- + +``HumanName.__eq__``/``__hash__`` were deprecated in 1.3.0 and are gone +in 2.0's core API; use ``matches()`` for "is this the same name?" and +``comparison_key()`` for dedup, dict keys, and sorting — both exist on +``HumanName`` and on :class:`~nameparser.ParsedName` with the same +behavior: + +.. doctest:: + + >>> from nameparser import HumanName, parse + >>> hn = HumanName("de la Vega, Juan") + >>> n = parse("de la Vega, Juan") + >>> hn.matches("Juan de la Vega"), n.matches("Juan de la Vega") + (True, True) + >>> hn.comparison_key() == n.comparison_key() + True + +One behavior changed underneath both methods: components now fold with +``str.casefold()`` instead of ``str.lower()``, so more Unicode +case-pairs compare equal than did under 1.4 (see the 2.0.0 section of +:doc:`release_log` for the exact rule and examples). + +Behavior changes +----------------- + +Beyond the API surface mapped above, a handful of parse *outputs* +differ between 1.4 and 2.0 for specific input shapes — comma-suffix +routing, maiden-marker detection, an ambiguous-acronym data change, and +one rendering difference under a custom suffix delimiter. These are +listed with their reasoning and test coverage in the 2.0.0 section of +:doc:`release_log`; they aren't repeated here. diff --git a/docs/modules.rst b/docs/modules.rst index bdc6b52d..8cec591a 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -1,39 +1,164 @@ -HumanName Class Documentation -============================== +API reference +============= + +The 2.0 API +----------- + +Parsing +~~~~~~~ + +.. autofunction:: nameparser.parse + +.. autoclass:: nameparser.Parser + :members: + +.. autofunction:: nameparser.parser_for + +Results +~~~~~~~ + +.. autoclass:: nameparser.ParsedName + :members: + +.. autoclass:: nameparser.Token + :members: + +.. autoclass:: nameparser.Span + :members: + +.. autoclass:: nameparser.Role + :members: + +.. autoclass:: nameparser.Ambiguity + :members: + +.. autoclass:: nameparser.AmbiguityKind + :members: + +Configuration +~~~~~~~~~~~~~ + +.. autoclass:: nameparser.Lexicon + :members: + +.. autoclass:: nameparser.Policy + :members: + +.. autoclass:: nameparser.PolicyPatch + :members: + +.. py:data:: nameparser.UNSET + + Sentinel meaning "this patch does not set this field" — the default + of every :class:`~nameparser.PolicyPatch` field, distinguishable + from every real value including ``None`` and ``False``. You rarely + need it: omit a field instead of passing it. Import it to test + whether a patch sets a field (``patch.name_order is UNSET``) or to + leave a field conditionally unset when building patches + programmatically. + +.. autoclass:: nameparser.PatronymicRule + :members: + +.. _name-order-constants: + +Name-order constants +^^^^^^^^^^^^^^^^^^^^ + +The three valid values for ``Policy(name_order=...)``. ``name_order`` +is deliberately restricted to these exported constants — an arbitrary +tuple of :class:`~nameparser.Role` members raises ``ValueError``, +because only these three orders have defined assignment semantics. + +.. py:data:: nameparser.GIVEN_FIRST + :value: (Role.GIVEN, Role.MIDDLE, Role.FAMILY) + + Western order (the default): the first word of positional input is + the given name, the last is the family name, everything between is + middle. + +.. py:data:: nameparser.FAMILY_FIRST + :value: (Role.FAMILY, Role.GIVEN, Role.MIDDLE) + + Family name first, given name second, remaining words middle — + e.g. Hungarian, or East Asian order. + +.. py:data:: nameparser.FAMILY_FIRST_GIVEN_LAST + :value: (Role.FAMILY, Role.MIDDLE, Role.GIVEN) + + Family name first, given name *last*, the words between middle — + e.g. Vietnamese full-name order. + +Delimiter defaults +^^^^^^^^^^^^^^^^^^ + +.. py:data:: nameparser.DEFAULT_NICKNAME_DELIMITERS + :value: frozenset({("'", "'"), ('"', '"'), ("(", ")"), ("“", "”"), ("„", "“"), ("”", "”"), ("«", "»"), ("»", "«"), ("「", "」"), ("『", "』"), ("(", ")")}) + + The default :attr:`~nameparser.Policy.nickname_delimiters` set: + straight quotes and parentheses plus the typographic conventions — + smart quotes, German/Polish low-high quotes, Swedish right-right + quotes, guillemets in both directions, CJK corner brackets, and + fullwidth parentheses (#273). Curly *single* quotes are deliberately + absent: U+2019 is the typographic apostrophe ("O’Connor"). Build on + the constant for additive customizations, e.g. + ``nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS | {("⦅", "⦆")}``; + to *reroute* a pair to ``maiden``, just list it in + :attr:`~nameparser.Policy.maiden_delimiters` (it is dropped from + the effective nickname set automatically). + +Locales +~~~~~~~ + +.. autoclass:: nameparser.Locale + :members: + +.. automodule:: nameparser.locales + :members: get, available + +1.x compatibility layer +------------------------ + +.. note:: + + ``HumanName`` and ``nameparser.config`` are the 1.x API, kept + working through 2.x and removed in 3.0. New code should use the + 2.0 API above; see :doc:`migrate`. HumanName.parser ----------------- +~~~~~~~~~~~~~~~~ .. py:module:: nameparser.parser .. py:class:: HumanName - :noindex: + :noindex: .. autoclass:: HumanName - :members: - :special-members: __eq__, __init__ + :members: + :special-members: __eq__, __init__ HumanName.config ----------------- +~~~~~~~~~~~~~~~~ .. automodule:: nameparser.config - :members: + :members: HumanName.config Defaults -------------------------- - +~~~~~~~~~~~~~~~~~~~~~~~~~~ .. automodule:: nameparser.config.titles - :members: + :members: .. automodule:: nameparser.config.suffixes - :members: + :members: .. automodule:: nameparser.config.prefixes - :members: + :members: .. automodule:: nameparser.config.bound_first_names - :members: + :members: .. automodule:: nameparser.config.conjunctions - :members: + :members: +.. automodule:: nameparser.config.maiden_markers + :members: .. automodule:: nameparser.config.capitalization - :members: + :members: .. automodule:: nameparser.config.regexes - :members: + :members: diff --git a/docs/release_log.rst b/docs/release_log.rst index 7ad2fc2e..56b61400 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -1,5 +1,41 @@ Release Log =========== +* 2.0.0 - unreleased + + **Locale packs (opt-in)** + + - Add ``nameparser.locales`` with the first two packs: ``locales.RU`` (East Slavic patronymic order) and ``locales.TR_AZ`` (Turkic patronymic markers). Packs are pure data folded in at ``parser_for(locales.RU)``; they compose (``parser_for(locales.RU, locales.TR_AZ)`` unions the rules) and are never auto-detected -- there is no reliable way to detect a name's language (closes the pack half of #270; #271/#272/#146 stay staged for 2.x) + - The CLI gains ``--locale CODE`` (``python -m nameparser --locale ru "..."``) + - Add non-Latin vocabulary to the default lexicon (#269): Cyrillic, Greek, Arabic and Hebrew titles, conjunctions, and name particles -- native-script entries cannot collide with Latin-script names. Deferred pending vetting: Cyrillic ``мл``/``ст`` suffixes and the bare Greek ``κ`` title (it collides with the initial+surname shape). Behavior note: ``محمد بن سلمان`` now chains ``بن`` onto the family name where 1.x read it as a middle name + - Add the Arabic-script bound given names (#269 follow-up): ``عبد``, the kunya pair ``أبو``/``ابو``, and ``أم``/``ام`` join the following word into the given name like their transliterations (``abdul``, ``abu``, ``umm``) always did -- ``عبد الرحمن محمد`` now parses given ``عبد الرحمن``, family ``محمد``, where 1.x split it into given + middle (``tests/v2/cases.py`` rows ``arabic_bound_given_*``). Not present in the differential corpus + - Add Hebrew honorifics, Hebrew post-nominals, and Devanagari titles (#269 follow-up): the Israeli honorifics ``גברת``, ``פרופ'``/``פרופ׳``, ``פרופסור``, ``עו"ד``/``עו״ד``, and ``הרב`` as plain titles; the post-nominals ``ז"ל``/``ז״ל`` and ``שליט"א``/``שליט״א`` as suffixes (both gershayim spellings each); and a new #269 script -- Devanagari ``श्री``, ``श्रीमती``, and ``डॉ`` (``डॉ.`` matches via edge-period normalization). Latin ``sri``/``shri`` deliberately NOT added (they collide with real given names; the native script cannot). Deferred: bare ``רב`` (ordinary word "many") and ``בר`` as a particle (Bar is a common modern given name). Not present in the differential corpus + - Add Arabic honorific titles and the conjunction ``و`` (#269 follow-up): the doctor/professor/hajj/sheikha/engineer forms (``الدكتور``/``الدكتورة``/``دكتور``/``دكتورة``, ``الأستاذ``/``الأستاذة``/``أستاذ``/``أستاذة``, ``الحاج``/``الحاجة``, ``الشيخة``, ``مهندس``) as given-name titles -- Arabic honorifics precede the given name, like ``الشيخ``. Deferred under the collision rule: bare ``سيد``/``شيخ``/``أمير``/``سلطان`` (all common given names), the ``د.`` abbreviation (bare ``د`` would swallow initials), and the Ottoman post-nominals ``باشا``/``بك``/``أفندي`` (survive as family names). Not present in the differential corpus + + **Behavior Changes (draft)** + + .. note:: + This section is a **draft**, generated from the v1-vs-2.0 + differential harness (``tools/differential/``) run against the + pre-M12 v1 test corpus (486 name strings). It will be replaced + by hand-written release notes before 2.0 ships; entries below + are grouped by the harness's classification and reference the + ``tests/v2/cases.py`` row (if any) that pins the new behavior. + + - Fix ``"Andrews, M.D."``-shaped input: the lone strict-suffix-or-title piece after a comma (e.g. ``"Smith, Dr."``, ``"Andrews, M.D."``) was routed to ``first`` in 1.x; 2.0 routes it to ``suffix``/``title`` instead, since the pre-comma piece is definitionally the family name (``tests/v2/cases.py`` rows ``family_comma_lone_suffix_piece``, ``family_comma_lone_title``; verified live against 1.4.0 -- 1/486 corpus names diff, all others parity) + - Fix a lone recognized trailing suffix (e.g. ``"Johnson PhD"``, ``"Mr. Johnson PhD"``) being routed to ``first``/``last`` in 1.x when no comma is present; 2.0 keeps a recognized suffix in ``suffix`` (``tests/v2/cases.py`` rows ``suffix_stays_suffix``, ``suffix_stays_suffix_title``; not present in the differential corpus -- no v1 test string exercises the bare two-token shape, so this is unverified against 1.4 live but pinned by the case table) + - Fix maiden-name markers (``née``/``nee``/``born``/``geb.``/``roz.``) being folded into ``middle``/``last`` in 1.x (e.g. ``"Jane Smith née Jones"`` → ``middle="Smith née"``); 2.0 recognizes the marker and routes the following name to the new ``maiden`` field (closes #274; ``tests/v2/cases.py`` row ``maiden_marker``; not present in the differential corpus) + - Data change: ``ma``/``do`` added to ``suffix_acronyms_ambiguous`` -- ambiguous acronyms count as suffixes only when written with periods, so a bare common surname (``"Jack Ma"``, ``"Anh Do"``) keeps its family name under 2.0's keep-recognized-suffixes routing, matching 1.4's output (``tests/v2/cases.py`` row ``ambiguous_surname_acronyms``). Side effect: parenthesized/quoted ``"(MA)"``/``"(DO)"`` (no periods) no longer escape to ``suffix`` the way 1.x did -- they now fall through to nickname parsing like any other ambiguous-acronym delimited content. Not present in the differential corpus + - Change suffix-delimiter rendering: with a custom ``Policy``/``Constants`` suffix delimiter configured (e.g. ``suffix_delimiter="/"``, ``"John Smith, RN/CRNA"``), 1.x split the token and rendered ``suffix="RN, CRNA"``; 2.0 keeps the no-space delimiter-core token whole (``suffix="RN/CRNA"``) -- role assignment is unchanged, only rendering differs (anti-#100, migration plan deviation 5; ``tests/v2/cases.py`` row ``suffix_delimiter_no_space_core``). Only fires with a non-default policy, so it does not appear in the (default-policy) differential corpus + - Change ``comparison_key()``/``matches()`` (both APIs) to fold components with ``str.casefold()`` where 1.4 used ``str.lower()``: Unicode case-pair forms now compare equal -- ``"STRASSE"`` matches ``"Straße"``, and a Greek final-sigma variant matches its regular-sigma form. Strictly more permissive (anything 1.4 matched still matches); parse output is unaffected -- vocabulary normalization itself uses ``lower()``, v1-parity (``tests/v2/test_types.py`` row ``test_matches_casefolds_unicode_case_pairs``) + - Change delimiter-overlap precedence in the 2.0 API: a pair listed in ``Policy.maiden_delimiters`` is dropped from the effective ``nickname_delimiters`` set (maiden wins), so ``Policy(maiden_delimiters={("(", ")")})`` alone routes parenthesized content to ``maiden`` -- no need to rebuild the nickname set. The default nickname set is now the public ``DEFAULT_NICKNAME_DELIMITERS`` constant. The 1.x facade keeps v1's nickname-wins precedence on overlap via a shim-side pre-subtraction, so no ``HumanName`` behavior changes (``tests/v2/cases.py`` row ``maiden_delimiters_win_when_shared``; ``tests/v2/test_config_shim.py`` row ``test_snapshot_overlap_keeps_v1_nickname_precedence``) + - Recognize typographic nickname delimiters by default in BOTH APIs (closes #273): smart quotes (``“Jack”``), German/Polish low-high quotes (``„Hansi“``), Swedish right-right quotes (``”Ann”``), guillemets in both directions (``«Petit»``, ``»Hansi«``, inner spacing tolerated), CJK corner brackets (``「タロ」``/``『ハナ』``), and fullwidth parentheses. In 1.x these leaked into ``middle`` as literal text. Curly *single* quotes stay excluded (U+2019 is the apostrophe in "O’Connor" -- pinned by ``curly_apostrophe_stays_literal``). The v1 keyed idioms work on the new named sentinels (``smart_double_quotes``, ``guillemets``, ...). A delimiter character consumed by another pair's extraction no longer emits a spurious ``unbalanced-delimiter`` ambiguity (``tests/v2/cases.py`` rows ``*_nickname``; ``tests/v2/pipeline/test_extract.py``). Not present in the differential corpus + + Everything else in the 486-name differential corpus (built from the + v1 test banks as of commit ``2d5d8c2``, pre-dating the M12 test + reconciliation) parses identically between nameparser 1.4.0 and + this working tree; see ``tools/differential/README.md`` for how to + reproduce the comparison. + * 1.4.0 - July 12, 2026 - Add ``Constants.copy()``, a detached deep copy that preserves the source instance's current customizations (unlike ``Constants()``, which always starts from library defaults) -- useful as ``CONSTANTS.copy()`` for a private snapshot of the shared config (#260) diff --git a/docs/usage.rst b/docs/usage.rst index 8c9ae36e..e39fb4f7 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,328 +1,120 @@ -Using the HumanName Parser -========================== +Getting started +=============== -Example Usage -------------- +Requires Python 3.11+. ``pip install nameparser`` -Requires Python 3.10+. +Parse a name +------------ .. doctest:: - :options: +NORMALIZE_WHITESPACE - >>> from nameparser import HumanName - >>> name = HumanName("Dr. Juan Q. Xavier de la Vega III") - >>> name.title - 'Dr.' - >>> name["title"] - 'Dr.' - >>> name.first - 'Juan' - >>> name.middle - 'Q. Xavier' - >>> name.last - 'de la Vega' - >>> name.last_base - 'Vega' - >>> name.last_prefixes - 'de la' - >>> name.suffix - 'III' - >>> name.surnames - 'Q. Xavier de la Vega' - >>> name.given_names - 'Juan Q. Xavier' - >>> name.full_name = "Juan Q. Xavier Velasquez y Garcia, Jr." - >>> name - - >>> name.middle = "Jason Alexander" - >>> name.middle - 'Jason Alexander' - >>> name - - >>> name.middle = ["custom","values"] - >>> name.middle - 'custom values' - >>> name.full_name = 'Doe-Ray, Jonathan "John" A. Harris' - >>> name.as_dict() - {'title': '', 'first': 'Jonathan', 'middle': 'A. Harris', 'last': 'Doe-Ray', 'suffix': '', 'nickname': 'John', 'maiden': ''} - >>> name.as_dict(False) # add False to hide keys with empty values - {'first': 'Jonathan', 'middle': 'A. Harris', 'last': 'Doe-Ray', 'nickname': 'John'} - >>> name = HumanName("Dr. Juan Q. Xavier de la Vega III") - >>> name2 = HumanName("de la vega, dr. juan Q. xavier III") - >>> name.matches(name2) - True - >>> name.matches("de la vega, dr. juan Q. xavier III") - True - -``name == other`` and ``hash(name)`` are deprecated and will be removed in -2.0; use ``matches()`` for comparison and ``comparison_key()`` for sets, -dicts, and dedup (see `issue #223 -`_). Slicing a name -by position (``name[1:-3]``) and item assignment (``name['first'] = ...``) -are likewise deprecated and will be removed in 2.0; use the named attributes -instead (see `issue #258 -`_). - -Empty or unparsable input does not raise an error; it produces a name whose -attributes are all empty. Check ``len(name) == 0`` (or ``str(name) == ''``) -to detect that nothing was parsed. - - -Capitalization Support ----------------------- - -The HumanName class can try to guess the correct capitalization of name -entered in all upper or lower case. By default, it will not adjust -the case of names entered in mixed case. To run capitalization on a -`HumanName` instance, pass the parameter `force=True`. - - Capitalize the name. + >>> from nameparser import parse + >>> name = parse("Dr. Juan Q. Xavier de la Vega III") + >>> name.given, name.family + ('Juan', 'de la Vega') + >>> name.title, name.middle, name.suffix + ('Dr.', 'Q. Xavier', 'III') - * bob v. de la macdole-eisenhower phd -> Bob V. de la MacDole-Eisenhower Ph.D. +A parsed name has seven fields: ``title``, ``given``, ``middle``, +``family``, ``suffix``, ``nickname``, and ``maiden``. Parsing never +raises; unparseable input yields a :class:`~nameparser.ParsedName` with +empty fields plus any ``ambiguities`` the parser noticed along the way +(see :doc:`concepts`). -.. doctest:: capitalize - - >>> name = HumanName("bob v. de la macdole-eisenhower phd") - >>> name.capitalize() - >>> str(name) - 'Bob V. de la MacDole-Eisenhower Ph.D.' - >>> name = HumanName('Shirley Maclaine') # Don't change mixed case names - >>> name.capitalize() - >>> str(name) - 'Shirley Maclaine' - >>> name.capitalize(force=True) - >>> str(name) - 'Shirley MacLaine' - -To apply capitalization to all `HumanName` instances, set -:py:attr:`~nameparser.config.Constants.capitalize_name` to `True`. - -.. doctest:: capitalize_name - :options: +NORMALIZE_WHITESPACE - - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.capitalize_name = True - >>> name = HumanName("bob v. de la macdole-eisenhower phd") - >>> str(name) - 'Bob V. de la MacDole-Eisenhower Ph.D.' - >>> CONSTANTS.capitalize_name = False - -To force the capitalization of mixed case strings on all `HumanName` instances, -set :py:attr:`~nameparser.config.Constants.force_mixed_case_capitalization` to `True`. +Aggregate views +---------------- -.. doctest:: force_mixed_case_capitalization - :options: +NORMALIZE_WHITESPACE +.. doctest:: - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.force_mixed_case_capitalization = True - >>> name = HumanName('Shirley Maclaine') - >>> name.capitalize() - >>> str(name) - 'Shirley MacLaine' - >>> CONSTANTS.force_mixed_case_capitalization = False + >>> name.given_names # given + middle + 'Juan Q. Xavier' + >>> name.family_base, name.family_particles # family, split apart + ('Vega', 'de la') +``surnames`` (``middle`` + ``family``) is the mirror-image aggregate. +The plural is the tell: ``given`` and ``family`` are single fields, +while ``given_names`` and ``surnames`` roll several fields together — +the same sense in which a passport form asks for your "given names" as +one blank that can hold more than one word. -Nickname Handling +Dicts and strings ------------------ -The content of parenthesis or quotes in the name will be -available from the nickname attribute. - -.. doctest:: nicknames - :options: +NORMALIZE_WHITESPACE - - >>> name = HumanName('Jonathan "John" A. Smith') - >>> name - - -Exception: content that looks like a suffix (a member of -:py:data:`~nameparser.config.SUFFIX_ACRONYMS` or -:py:data:`~nameparser.config.SUFFIX_NOT_ACRONYMS`, or anything ending in a -period) is treated as a suffix instead of a nickname, since that's usually -what's meant, e.g. a retired military title or a professional designation -written in parenthesis. - -.. doctest:: nicknames - :options: +NORMALIZE_WHITESPACE - - >>> name = HumanName('Andrew Perkins (MBA)') - >>> name - - -A few suffix acronyms, listed in -:py:data:`~nameparser.config.SUFFIX_ACRONYMS_AMBIGUOUS`, also work as common -given-name nicknames on their own (e.g. "JD", "Ed"). These stay nicknames -when found alone in parenthesis or quotes, since that's the more common -reading in that ambiguous context: - -.. doctest:: nicknames - :options: +NORMALIZE_WHITESPACE - - >>> name = HumanName('JEFFREY (JD) BRICKEN') - >>> name - - -Leading Period-Abbreviation Titles ------------------------------------ - -An unrecognized, multi-letter word ending in a period, found anywhere in the -leading title run (i.e. before the first name is set), is treated as a title --- this covers military ranks and other abbreviations that aren't in the -built-in titles list, including chained abbreviations like -``"Foo. Xyz. John Smith"``. Single-letter initials (``"J."``) and -internal-period abbreviations (``"E.T."``) are not affected, and the same -word appearing after the first name is left as a middle name. - -.. doctest:: leading_period_titles - :options: +NORMALIZE_WHITESPACE - - >>> name = HumanName("Major. Dona Smith") - >>> name - - >>> name = HumanName("J. Smith") - >>> name.first - 'J.' - >>> name.title - '' - -Change the output string with string formatting ------------------------------------------------ - -The string representation of a `HumanName` instance is controlled by its `string_format` attribute. -The default value, `"{title} {first} {middle} {last} {suffix} ({nickname})"`, includes parenthesis -around nicknames. Trailing commas and empty quotes and parenthesis are automatically removed if the -name has no nickname pieces. +.. doctest:: -You can change the default formatting for all `HumanName` instances by setting a new -:py:attr:`~nameparser.config.Constants.string_format` value on the shared -:py:class:`~nameparser.config.CONSTANTS` configuration instance. + >>> name.as_dict(include_empty=False) + {'title': 'Dr.', 'given': 'Juan', 'middle': 'Q. Xavier', 'family': 'de la Vega', 'suffix': 'III'} + >>> str(name) + 'Dr. Juan Q. Xavier de la Vega III' + >>> name.render("{family}, {given}") + 'de la Vega, Juan' + >>> name.initials() + 'J. Q. X. V.' -.. doctest:: string format +Fixing case +----------- - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.string_format = "{title} {first} ({nickname}) {middle} {last} {suffix}" - >>> name = HumanName('Robert Johnson') - >>> str(name) - 'Robert Johnson' - >>> name = HumanName('Robert "Rob" Johnson') - >>> str(name) - 'Robert (Rob) Johnson' +.. doctest:: -You can control the order and presence of any name fields by changing the -:py:attr:`~nameparser.config.Constants.string_format` attribute of the shared CONSTANTS instance. -Don't want to include nicknames in your output? No problem. Just omit that keyword from the -`string_format` attribute. + >>> str(parse("juan de la vega").capitalized()) + 'Juan de la Vega' -.. doctest:: string format +Nicknames and maiden names +---------------------------- - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.string_format = "{title} {first} {last}" - >>> name = HumanName("Dr. Juan Ruiz de la Vega III (Doc Vega)") - >>> str(name) - 'Dr. Juan de la Vega' +.. doctest:: + >>> parse("Jonathan 'Jack' Kennedy").nickname + 'Jack' + >>> parse("Jane Smith née Jones").maiden + 'Jones' -Initials Support +Comparing names ---------------- -The HumanName class can try to get the correct representation of initials. -Initials can be tricky as different format usages exist. -To exclude any of the name parts from the initials, change the initials format string: -:py:attr:`~nameparser.config.Constants.initials_format` -Three attributes exist for the format, `first`, `middle` and `last`. +``==`` is strict value equality — two :class:`~nameparser.ParsedName` +instances are equal only if every field matches exactly. For "is this +the same name, allowing for order and case?" use ``matches()`` or +``comparison_key()`` instead. -.. doctest:: initials format - - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.initials_format = "{first} {middle}" - >>> HumanName("Doe, John A. Kenneth, Jr.").initials() - 'J. A. K.' - >>> HumanName("Doe, John A. Kenneth, Jr.", initials_format="{last}, {first}").initials() - 'D., J.' - >>> CONSTANTS.initials_format = "{first} {middle} {last}" - - -Furthermore, the delimiter for the string output can be set through: -:py:attr:`~nameparser.config.Constants.initials_delimiter` +.. doctest:: -.. doctest:: initials delimiter + >>> parse("de la Vega, Juan").matches("Juan de la Vega") + True + >>> parse("JUAN DE LA VEGA").comparison_key() == parse("Juan de la Vega").comparison_key() + True - >>> HumanName("Doe, John A. Kenneth, Jr.", initials_delimiter=";").initials() - 'J; A; K; D;' +Correcting a parse +-------------------- -The separator between consecutive initials *within* a name group (e.g. two middle -names) is controlled by :py:attr:`~nameparser.config.Constants.initials_separator`, -which defaults to ``" "``. Setting it to ``""`` removes that space within a group; -spacing *between* groups is still governed by ``initials_format``. +:class:`~nameparser.ParsedName` is immutable, so a correction is a new +value: ``replace()`` returns a copy with the given fields changed and +everything else carried over. -``initials_delimiter``, ``initials_separator``, and ``initials_format`` work together: +.. doctest:: -- ``initials_delimiter`` — appended *after* each individual initial (default ``"."``) -- ``initials_separator`` — placed *after* the delimiter between consecutive initials in the same group (default ``" "``), so with ``delimiter="."`` and ``separator=" "`` you get ``A. K.`` -- ``initials_format`` — controls how the first, middle, and last groups are arranged + >>> name = parse("Juan de la Vega") + >>> corrected = name.replace(title="Dr.") + >>> str(corrected) + 'Dr. Juan de la Vega' + >>> name.title + '' -For example, to produce compact period-separated initials with no spaces: +Command line +------------ -.. doctest:: initials separator +:: - >>> HumanName("Doe, John A. Kenneth, Jr.", initials_separator="", initials_format="{first}{middle}{last}").initials() - 'J.A.K.D.' - >>> HumanName("Doe, John A. Kenneth, Jr.", initials_delimiter="", initials_separator="", initials_format="{first}{middle}{last}").initials() - 'JAKD' + $ python -m nameparser --json "Doe, John" + {"title": "", "given": "John", "middle": "", "family": "Doe", "suffix": "", "nickname": "", "maiden": ""} -To get a list representation of the initials, use :py:meth:`~nameparser.HumanName.initials_list`. -This function is unaffected by :py:attr:`~nameparser.config.Constants.initials_format` +Add ``--locale`` to parse with a locale pack (for example ``--locale +ru``); see :doc:`locales`. -.. doctest:: list format +Where next +---------- - >>> HumanName("Doe, John A. Kenneth, Jr.", initials_delimiter=";").initials_list() - ['J', 'A', 'K', 'D'] - +* :doc:`concepts` — how the parser works +* :doc:`customize` — your own vocabulary and behavior +* :doc:`locales` — locale packs +* :doc:`migrate` — coming from 1.x ``HumanName`` diff --git a/nameparser/__init__.py b/nameparser/__init__.py index c82d8002..9e6e7011 100644 --- a/nameparser/__init__.py +++ b/nameparser/__init__.py @@ -5,3 +5,36 @@ __author_email__ = 'derek73@gmail.com' __license__ = "LGPL" __url__ = "https://github.com/derek73/python-nameparser" + +from nameparser._lexicon import Lexicon +from nameparser._locale import Locale +from nameparser._parser import Parser, parse, parser_for +from nameparser._policy import ( + DEFAULT_NICKNAME_DELIMITERS, + FAMILY_FIRST, + FAMILY_FIRST_GIVEN_LAST, + GIVEN_FIRST, + UNSET, + PatronymicRule, + Policy, + PolicyPatch, +) +from nameparser._types import ( + Ambiguity, + AmbiguityKind, + ParsedName, + Role, + Span, + Token, +) + +__all__ = [ + # v1 (compatibility layer) + "HumanName", + # v2 core + "Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName", + "Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "UNSET", + "GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST", + "DEFAULT_NICKNAME_DELIMITERS", "Locale", + "Parser", "parse", "parser_for", +] diff --git a/nameparser/__main__.py b/nameparser/__main__.py index 5d96ec8f..fd1dde53 100644 --- a/nameparser/__main__.py +++ b/nameparser/__main__.py @@ -1,31 +1,43 @@ -"""Command-line debug helper: parse a name and print the result. - -Usage: +"""Command-line debug helper over the 2.0 API (migration spec §6). python -m nameparser "Dr. Juan Q. Xavier de la Vega III" + python -m nameparser --json "Doe, John" """ -import logging -import sys +import argparse +import json -from nameparser import HumanName +from nameparser import parse -def main() -> None: - if len(sys.argv) <= 1: - print('Usage: python -m nameparser "Name String"') - raise SystemExit(1) - log = logging.getLogger('HumanName') - log.setLevel(logging.ERROR) - log.addHandler(logging.StreamHandler()) - name_string = sys.argv[1] - hn = HumanName(name_string) - print(repr(hn)) - hn.capitalize() - print(repr(hn)) - # Use comma rather than concatenation: initials() returns - # empty_attribute_default (possibly None) when there are no initials. - print("Initials:", hn.initials()) +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser( + prog="nameparser", description="Parse a personal name.") + ap.add_argument("name", help="the name string to parse") + ap.add_argument("--json", action="store_true", + help="print the component dict as JSON") + ap.add_argument("--locale", metavar="CODE", + help="parse with a locale pack (e.g. 'ru'); see " + "nameparser.locales") + args = ap.parse_args(argv) + # `is not None`, not truthiness: --locale "" must reach get() and + # exit 2 listing the codes, not silently fall back to the default + if args.locale is not None: + from nameparser import locales, parser_for + try: + parser = parser_for(locales.get(args.locale)) + except KeyError as exc: + ap.error(exc.args[0]) # exits 2, message to stderr + n = parser.parse(args.name) + else: + n = parse(args.name) + if args.json: + print(json.dumps(n.as_dict(), ensure_ascii=False)) + return 0 + print(repr(n)) + print(repr(n.capitalized())) + print("Initials:", n.initials()) + return 0 -if __name__ == '__main__': - main() +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nameparser/_config_shim.py b/nameparser/_config_shim.py new file mode 100644 index 00000000..096df927 --- /dev/null +++ b/nameparser/_config_shim.py @@ -0,0 +1,1035 @@ +"""v1 ``Constants`` compatibility shim over Lexicon/Policy (migration +spec §3). ``nameparser.config`` re-exports these names from the swap +commit onward; the whole module is deleted in 3.0 with the facade. + +Layering: facade layer -- may import anything public; here that's +``nameparser.util`` for ``lc()``, ``nameparser.config.regexes`` for +the read-only regexes proxy's underlying compiled patterns, and +``nameparser._lexicon``/``_policy``/``_parser`` for the ``_snapshot()`` +translation and the shared parser cache. + +All ``nameparser.config.`` imports below are deferred +(function-local), never module-level: ``nameparser/config/__init__.py`` +itself imports ``CONSTANTS``/``Constants``/etc. from this module, so a +module-level ``from nameparser.config.regexes import REGEXES`` here +would need the ``nameparser.config`` package's ``__init__.py`` to run +to completion first -- which needs this module to already be fully +initialized. Importing the data submodules lazily, on first use, breaks +that cycle. +""" +from __future__ import annotations + +import functools +import warnings +from collections.abc import ( + Callable, Iterable, Iterator, KeysView, Mapping, Set, +) +from typing import NamedTuple, Self + +from nameparser._lexicon import Lexicon +from nameparser._parser import Parser +from nameparser._policy import PatronymicRule, Policy +from nameparser.util import lc + + +def _reject_bare_str_or_bytes(value: object, expected: str) -> None: + # A bare string is an iterable of its characters, so e.g. SetManager('dr') + # would silently shred it into {'d', 'r'} instead of raising -- shared by + # SetManager's constructor/operands (#238/#241) and TupleManager's + # constructor (#242). Ported from v1's `_reject_bare_str_or_bytes`. + if isinstance(value, bytes): + raise TypeError( + f"expected {expected}, got a single bytes; decode it first: " + f"{value!r}.decode()" + ) + if isinstance(value, str): + raise TypeError( + f"expected {expected}, got a single str; wrap it in a list: " + f"[{value!r}]" + ) + + +def _lc_validated(s: object) -> str: + # Validates and lc()-normalizes a single element -- shared by bulk + # iterable normalization (constructor/operands) and add()'s per-string + # loop, so every path raises the same #238/#245-shaped TypeError instead + # of lc() crashing cryptically (bytes) or silently transmuting (None). + if isinstance(s, bytes): + raise TypeError( + f"expected a str, got bytes; decode it first: {s!r}.decode()" + ) + if not isinstance(s, str): + raise TypeError(f"expected a str, got {type(s).__name__}: {s!r}") + return lc(s) + + +def _normalize_iterable_of_strings( + elements: object, expected: str = "an iterable of strings") -> set[str]: + # a SetManager's elements were already validated/normalized when it was + # built, so copy them instead of re-validating (v1's fast path) + if isinstance(elements, SetManager): + return set(elements._elements) + _reject_bare_str_or_bytes(elements, expected) + return {_lc_validated(s) for s in elements} # type: ignore[attr-defined] + + +class SetManager: + """v1 ``SetManager`` surface over a plain set of ``lc()``-normalized + strings. Mutations call ``_on_change`` (the owning Constants' + generation bump, wired by a later task). ``__call__`` and the + missing-member-tolerant ``remove()`` are gone per the #243 schedule + (warned 1.3.0, removed 2.0): ``remove()`` of a missing member raises + ``KeyError``, matching ``set.remove``. + """ + + _elements: set[str] + _on_change: Callable[[], None] | None + + def __init__(self, elements: Iterable[str] = (), + _on_change: Callable[[], None] | None = None, + _field: str | None = None) -> None: + # _field carries a Constants field name (e.g. "titles") through to + # the bare-str/bytes guard's message, when this SetManager is being + # built on behalf of a named Constants field (constructor kwarg or + # __setattr__ auto-wrap); a direct SetManager(...) call gets the + # generic v1 message instead. + expected = f"{_field} to be an iterable of strings" if _field \ + else "an iterable of strings" + self._elements = _normalize_iterable_of_strings(elements, expected) + self._on_change = _on_change + + def _changed(self) -> None: + if self._on_change is not None: + self._on_change() + + def add(self, *strings: str) -> SetManager: + """Add the normalized string arguments to the set. Returns + ``self`` for chaining.""" + # notify only on real change (v1 parity): a no-op add must not + # bump the owner's generation + changed = False + try: + for s in strings: + normalized = _lc_validated(s) # TypeError on bytes (#245) + if normalized not in self._elements: + self._elements.add(normalized) + changed = True + finally: + # a TypeError mid-list still leaves earlier additions + # applied, so the owner must hear about them or its cache + # goes stale (same rule as remove() below) + if changed: + self._changed() + return self + + def remove(self, *strings: str) -> SetManager: + """Remove the normalized string arguments from the set. + Raises ``KeyError`` if any argument is not a member. Returns + ``self`` for chaining.""" + changed = False + try: + for s in strings: + # _lc_validated: bytes get the same decode-hint TypeError + # as add() (#245) + normalized = _lc_validated(s) + self._elements.remove(normalized) # KeyError on missing (#243) + changed = True + finally: + # a KeyError mid-list still leaves earlier removals applied, + # so the owner must hear about them or its cache goes stale + if changed: + self._changed() + return self + + def discard(self, *strings: str) -> SetManager: + """Remove the normalized string arguments from the set if + present; missing members are ignored, like ``set.discard``. + Returns ``self`` for chaining.""" + changed = False + for s in strings: + normalized = _lc_validated(s) # bytes decode hint (#245) + if normalized in self._elements: + self._elements.discard(normalized) + changed = True + if changed: + self._changed() + return self + + def clear(self) -> SetManager: + """Remove all entries from the set. Returns ``self`` for + chaining.""" + if self._elements: + self._elements.clear() + self._changed() + return self + + def __contains__(self, item: object) -> bool: + return isinstance(item, str) and lc(item) in self._elements + + def __iter__(self) -> Iterator[str]: + return iter(self._elements) + + def __len__(self) -> int: + return len(self._elements) + + def __eq__(self, other: object) -> bool: + if isinstance(other, SetManager): + return self._elements == other._elements + if isinstance(other, (set, frozenset)): + return self._elements == other + return NotImplemented + + __hash__ = None # type: ignore[assignment] # mutable; v1 parity + + # -- set operators: accept ANY iterable (v1.3 normalize-everywhere) ----- + # A bare str/bytes operand raises TypeError via _normalize_iterable_of_ + # strings rather than iterating its characters (#238/#241); everything + # else (list, generator, set, another SetManager, ...) is normalized + # through lc() before the plain set op runs. + + def __or__(self, other: object) -> set[str]: + return self._elements | _normalize_iterable_of_strings(other) + + __ror__ = __or__ + + def __and__(self, other: object) -> set[str]: + return self._elements & _normalize_iterable_of_strings(other) + + __rand__ = __and__ + + def __sub__(self, other: object) -> set[str]: + return self._elements - _normalize_iterable_of_strings(other) + + def __rsub__(self, other: object) -> set[str]: + return _normalize_iterable_of_strings(other) - self._elements + + def __xor__(self, other: object) -> set[str]: + return self._elements ^ _normalize_iterable_of_strings(other) + + __rxor__ = __xor__ # symmetric difference is commutative, like v1 + + # -- comparisons: v1 subclassed collections.abc.Set, whose __le__/__lt__/ + # __ge__/__gt__ mixins only accept another Set-registered operand (set, + # frozenset, or another Set subclass) -- NOT an arbitrary iterable like + # list, unlike the operators above. Mirrored here since this SetManager + # doesn't itself subclass the ABC. + + def __le__(self, other: object) -> bool: + if not isinstance(other, (SetManager, Set)): + return NotImplemented + if len(self) > len(other): + return False + return all(elem in other for elem in self) + + def __lt__(self, other: object) -> bool: + if not isinstance(other, (SetManager, Set)): + return NotImplemented + return len(self) < len(other) and self.__le__(other) + + def __ge__(self, other: object) -> bool: + if not isinstance(other, (SetManager, Set)): + return NotImplemented + if len(self) < len(other): + return False + return all(elem in self for elem in other) + + def __gt__(self, other: object) -> bool: + if not isinstance(other, (SetManager, Set)): + return NotImplemented + return len(self) > len(other) and self.__ge__(other) + + def __repr__(self) -> str: + # Sorted so repr is stable across runs -- set() iteration order + # depends on per-process string hash randomization. + elements = ", ".join(repr(e) for e in sorted(self._elements)) + return f"SetManager({{{elements}}})" if self._elements else "SetManager(set())" + + # -- pickle interop with v1 blobs --------------------------------------- + + def __getstate__(self) -> dict[str, object]: + return {"_elements": set(self._elements)} + + def __setstate__(self, state: dict[str, object]) -> None: + # v1 SetManager stored its set under `elements` (plain __dict__ + # pickling); the shim stores `_elements`. Accept both, so a + # v1.3/1.4 Constants blob's embedded managers unpickle straight + # into shim instances. Re-normalize: nothing guarantees an + # incoming blob's elements passed through lc(). + elements: Iterable[str] = state.get( # type: ignore[assignment] + "_elements", state.get("elements", ())) + self._elements = {lc(e) for e in elements} + self._on_change = None # rewired by the owning Constants + + +def _validated_mapping_args(args: tuple[object, ...]) -> tuple[object, ...]: + """The #242 constructor guard, shared by TupleManager and + _DelimiterManager: a bare str/bytes silently shreds into a garbage + mapping (dict's own error), and an iterable of short strings + silently splits each one into a (key, value) pair -- ported from + v1's TupleManager.__init__ guard.""" + if not args: + return args + arg = args[0] + _reject_bare_str_or_bytes( + arg, "a mapping or iterable of (key, value) pairs") + if not isinstance(arg, Mapping): + checked = [] + for item in arg: # type: ignore[attr-defined] + if isinstance(item, (str, bytes)): + kind = "bytes" if isinstance(item, bytes) else "str" + raise TypeError( + f"expected (key, value) pairs, got a {kind} " + f"element {item!r}; a 2-character string " + "silently splits into a key and a value" + ) + checked.append(item) + args = (checked, *args[1:]) + return args + + +class TupleManager(dict[str, object]): + """v1 ``TupleManager``: a dict with dot-notation access. Backs + ``capitalization_exceptions``. Unknown-key attribute access raises + ``AttributeError`` naming the key (#256, warned 1.4, enforced 2.0 -- + the v1 ``DeprecationWarning`` is gone, this shim only speaks 2.0). + Mutations call ``_on_change`` (the owning Constants' generation + bump, wired by a later task). + """ + + _on_change: Callable[[], None] | None + + def __init__(self, *args: object, + _on_change: Callable[[], None] | None = None, + **kwargs: object) -> None: + args = _validated_mapping_args(args) + super().__init__(*args, **kwargs) + self._on_change = _on_change + + def _changed(self) -> None: + if self._on_change is not None: + self._on_change() + + def __getattr__(self, name: str) -> object: + # Only reached for a missing attribute -- real instance attrs + # (_on_change) and dict methods (keys, get, ...) resolve first + # without ever hitting this. Dunder/underscore probes (pickling, + # copy.deepcopy, IPython's _repr_html_) are never config keys. + if name.startswith("_"): + raise AttributeError(name) + try: + return self[name] + except KeyError: + # #256: name the known keys, like v1's 1.4 deprecation warning + # did -- this shim only speaks 2.0, so what was a warning there + # is a hard AttributeError here. + raise AttributeError( + f"{name!r} is not a known key " + f"({', '.join(sorted(self))}); use .get() for intentional " + "soft access." + ) from None + + def __setattr__(self, name: str, value: object) -> None: + # v1 parity: dunder probes (typing's __orig_class__, etc.) and this + # shim's own _on_change hook get real object-attribute storage; + # every other name -- including single-underscore ones, per v1 -- + # routes to the dict so `t.mcdonald = 'x'` and `t['mcdonald'] = 'x'` + # are the same operation. + if name == "_on_change" or (name.startswith("__") and name.endswith("__")): + object.__setattr__(self, name, value) + else: + self[name] = value + + def __delattr__(self, name: str) -> None: + if name == "_on_change" or (name.startswith("__") and name.endswith("__")): + object.__delattr__(self, name) + else: + del self[name] + + def __setitem__(self, key: str, value: object) -> None: + super().__setitem__(key, value) + self._changed() + + def __delitem__(self, key: str) -> None: + super().__delitem__(key) + self._changed() + + def pop(self, *args: object) -> object: + # bump only on a real removal -- pop(key, default) on a missing + # key is a no-op read, not a mutation, same rule as SetManager's + # no-op add() + present = bool(args) and args[0] in self + result = super().pop(*args) # type: ignore[call-overload] + if present: + self._changed() + return result + + def popitem(self) -> tuple[str, object]: + result = super().popitem() # KeyError when empty: no bump + self._changed() + return result + + def clear(self) -> None: + had_items = bool(self) + super().clear() + if had_items: # clearing an empty dict is a no-op, not a change + self._changed() + + def update(self, *args: object, **kwargs: object) -> None: + # dict.update's C path skips a subclass __setitem__; route every + # item through it so subclass validation (_DelimiterManager's + # sentinel rule) and the owner notification hold here too + for key, value in dict(*args, **kwargs).items(): + self[key] = value + + def setdefault(self, key: str, default: object = None) -> object: + if key in self: + return self[key] # existing key: a read, not a mutation + self[key] = default # validated + notifying path + return default + + # in-place |= must validate/notify like update; dict's C path would + # skip both. mypy flags any non-overloaded __ior__ as inconsistent + # with dict.__or__'s overloads -- the runtime behavior is the plain + # dict |= contract, so the ignore is about typeshed shape only. + def __ior__(self, other: object) -> Self: # type: ignore[override, misc] + self.update(other) + return self + + # -- pickle interop ------------------------------------------------- + + def __reduce__(self) -> tuple[type[TupleManager], tuple[()], dict[str, object]]: + return (type(self), (), dict(self)) + + def __setstate__(self, state: dict[str, object]) -> None: + # routes through __setitem__ (validated for _DelimiterManager); + # _on_change is still None here, so no spurious bumps + self.update(state) + self._on_change = None # rewired by the owning Constants + + +#: The named delimiter buckets, translated to the ``Policy`` +#: (open, close) pairs they stand for (spec §3). The first three are +#: v1's; the rest are the #273 typographic conventions, named so the +#: v1 keyed idioms (pop/move/del) work on them like the originals. +#: Keep in sync with DEFAULT_NICKNAME_DELIMITERS in _policy.py (pinned +#: by the default-Constants equality test). +_SENTINEL_PAIRS = { + "quoted_word": ("'", "'"), + "double_quotes": ('"', '"'), + "parenthesis": ("(", ")"), + "smart_double_quotes": ("“", "”"), + "low_high_quotes": ("„", "“"), + "right_double_quotes": ("”", "”"), + "guillemets": ("«", "»"), + "reversed_guillemets": ("»", "«"), + "corner_brackets": ("「", "」"), + "white_corner_brackets": ("『", "』"), + "fullwidth_parenthesis": ("(", ")"), +} + +#: derived, so the manager's accepted keys and _snapshot()'s +#: translation table can never drift apart +_DELIMITER_SENTINELS = tuple(_SENTINEL_PAIRS) + + +class RegexTupleManager(TupleManager): # pickle-compat: do NOT delete + """Pickle-compat alias only: v1.4's ``Constants.regexes`` field was a + ``nameparser.config.RegexTupleManager`` instance (a ``TupleManager`` + subclass whose ``__getattr__`` fell back to ``EMPTY_REGEX`` for an + unknown key). Unpickling a v1.4 blob resolves and constructs this + class -- via ``TupleManager.__reduce__``'s ``(type(self), (), state)`` + -- before ``Constants.__setstate__`` runs, so the name must exist + here even though the shim's ``Constants._snapshot()`` never reads it: + ``regexes`` is a read-only ``_RegexesProxy`` in 2.0 (see above), and + ``Constants.__setstate__`` below deliberately ignores an incoming + ``regexes`` key rather than restoring it from this reconstructed + (and otherwise unused) instance. + """ + + +class _DelimiterManager(TupleManager): + """v1 ``nickname_delimiters``/``maiden_delimiters`` bucket. In 2.0 + only the named sentinels in ``_DELIMITER_SENTINELS`` exist (spec + §3; the v1 trio plus the #273 typographic pairs) -- assigning any + other key raises so a caller reaches for a custom-delimiter Policy + kwarg instead of a dict entry that silently does nothing. ``pop()``/ + ``__setitem__``/``__delitem__`` stay open (inherited) for the + documented bucket-move idiom, e.g. + ``maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')``. + """ + + def __init__(self, *args: object, + _on_change: Callable[[], None] | None = None, + **kwargs: object) -> None: + # dict's C-level __init__ never calls a subclass __setitem__, so + # collect and validate the initial items here -- BEFORE any item + # lands -- or the sentinel rule silently misses the constructor. + # The parent's #242 guard runs first: bare str/bytes gets the + # friendly TypeError, not dict's cryptic one. + args = _validated_mapping_args(args) + items: dict[str, object] = dict(*args, **kwargs) + for key in items: + self._reject_non_sentinel(key) + super().__init__(items, _on_change=_on_change) + + @staticmethod + def _reject_non_sentinel(key: str) -> None: + if key not in _DELIMITER_SENTINELS: + raise TypeError( + f"2.0 delimiter managers accept only the named sentinels " + f"{_DELIMITER_SENTINELS}; for custom delimiter pairs use " + f"Policy(nickname_delimiters=...) / maiden_delimiters" + ) + + def __setitem__(self, key: str, value: object) -> None: + self._reject_non_sentinel(key) + super().__setitem__(key, value) + # update/setdefault/|= inherit TupleManager's routing through + # __setitem__, so they validate (and notify) for free + + +class _RegexesProxy: + """Read-only view over the v1 compiled patterns + (``nameparser.config.regexes.REGEXES``). Reads keep working -- + ``CONSTANTS.regexes.word`` stays informational -- but 2.0 configures + parsing behavior through named ``Policy`` flags, not by mutating a + regex, so any attribute *or* item assignment raises ``TypeError`` + (spec §3's uniform read-only rule). + """ + + @staticmethod + def _regexes() -> Mapping[str, object]: + # deferred import: see the module docstring's note on why every + # nameparser.config. import in this file is lazy + from nameparser.config.regexes import REGEXES + return REGEXES + + def __getattr__(self, name: str) -> object: + if name.startswith("_"): + raise AttributeError(name) + try: + return self._regexes()[name] + except KeyError: + raise AttributeError(f"no regex named {name!r}") from None + + def __getitem__(self, name: str) -> object: + return self._regexes()[name] + + def __contains__(self, name: object) -> bool: + return name in self._regexes() + + def __iter__(self) -> Iterator[str]: + return iter(self._regexes()) + + def keys(self) -> KeysView[str]: + return self._regexes().keys() + + def __setattr__(self, name: str, value: object) -> None: + self._raise_readonly(name) + + def __setitem__(self, name: str, value: object) -> None: + self._raise_readonly(name) + + @staticmethod + def _raise_readonly(name: str) -> None: + # bidi/emoji are the two regexes v1 code toggled directly + # (`CONSTANTS.regexes.bidi = False`) to opt out of stripping; + # point those two at their named Policy replacement, everything + # else gets the generic pointer. + hints = { + "bidi": "use Policy(strip_bidi=False) to keep bidi marks", + "emoji": "use Policy(strip_emoji=False) to keep emoji", + } + hint = hints.get( + name, "parsing behavior is configured through named Policy " + "flags in 2.0; if none fits, open an issue") + raise TypeError( + f"assigning CONSTANTS.regexes.{name} is not supported in " + f"2.0: {hint}" + ) + + +_SET_FIELDS = ( + "prefixes", "suffix_acronyms", "suffix_not_acronyms", + "suffix_acronyms_ambiguous", "titles", "first_name_titles", + "conjunctions", "bound_first_names", "non_first_name_prefixes", +) +_MANAGER_FIELDS = _SET_FIELDS + ( + "capitalization_exceptions", "nickname_delimiters", "maiden_delimiters", +) + +#: v1's Constants.__repr__ field order (#221) -- kept as its own tuple +#: rather than reusing _SET_FIELDS, whose order differs (v1 lists +#: suffix_acronyms_ambiguous last, not fourth). +_REPR_COLLECTION_ATTRS = ( + "prefixes", "suffix_acronyms", "suffix_not_acronyms", "titles", + "first_name_titles", "conjunctions", "bound_first_names", + "non_first_name_prefixes", "suffix_acronyms_ambiguous", +) +#: v1's repr scalar order, minus empty_attribute_default -- removed in 2.0 +#: (#255), so there's no such attribute on this shim's Constants to show. +_REPR_SCALAR_ATTRS = ( + "string_format", "initials_format", "initials_delimiter", + "initials_separator", "suffix_delimiter", + "capitalize_name", "force_mixed_case_capitalization", + "patronymic_name_order", "middle_name_as_last", +) + +_SCALAR_DEFAULTS: dict[str, object] = { + "patronymic_name_order": False, + "middle_name_as_last": False, + "capitalize_name": False, + "force_mixed_case_capitalization": False, + "string_format": "{title} {first} {middle} {last} {suffix} ({nickname})", + "initials_format": "{first} {middle} {last}", + "initials_delimiter": ".", + "initials_separator": " ", + "suffix_delimiter": None, +} + +# distinguishes "attribute not set yet" from any real scalar value +# (None is a legitimate value for string_format/suffix_delimiter) +_UNSET = object() + +# distinguishes "kwarg not passed to Constants()" (use library defaults) +# from any real value a caller might pass, including a falsy one like "" +_UNSET_KWARG = object() + + +_SHARED_MUTATION_MESSAGE = ( + "mutating the shared CONSTANTS singleton is deprecated and will be " + "removed in 3.0; build a Lexicon/Policy (or a private Constants " + "passed as HumanName(constants=...)) instead. See the migration " + "guide." +) + + +def _default_vocab() -> dict[str, set[str]]: + # v1 data modules stay the single vocabulary source through 2.x + # (same rule as Lexicon.default()). + from nameparser.config.bound_first_names import BOUND_FIRST_NAMES + from nameparser.config.conjunctions import CONJUNCTIONS + from nameparser.config.prefixes import ( + NON_FIRST_NAME_PREFIXES, PREFIXES, + ) + from nameparser.config.suffixes import ( + SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_NOT_ACRONYMS, + ) + from nameparser.config.titles import FIRST_NAME_TITLES, TITLES + return { + "prefixes": PREFIXES, + "suffix_acronyms": SUFFIX_ACRONYMS, + "suffix_not_acronyms": SUFFIX_NOT_ACRONYMS, + "suffix_acronyms_ambiguous": SUFFIX_ACRONYMS_AMBIGUOUS, + "titles": TITLES, + "first_name_titles": FIRST_NAME_TITLES, + "conjunctions": CONJUNCTIONS, + "bound_first_names": BOUND_FIRST_NAMES, + "non_first_name_prefixes": NON_FIRST_NAME_PREFIXES, + } + + +class _RenderDefaults(NamedTuple): + """v1 scalar rendering knobs that have no home on ``Policy`` + (spec §3): ``__str__``/initials formatting and capitalization stay + per-Constants defaults, layered onto a shared ``Parser`` by the + facade (a later task) rather than folded into the cache key.""" + + string_format: str | None + initials_format: str + initials_delimiter: str + initials_separator: str + suffix_delimiter: str | None + capitalize_name: bool + force_mixed_case_capitalization: bool + + +@functools.lru_cache(maxsize=64) +def _cached_parser(lexicon: Lexicon, policy: Policy) -> Parser: + # keyed on hashable value objects: shared across every facade whose + # Constants resolve to the same snapshot (spec §3) + return Parser(lexicon=lexicon, policy=policy) + + +class Constants: + """v1 ``Constants`` shim: a mutable container whose state resolves to + a frozen ``(Lexicon, Policy, _RenderDefaults)`` snapshot via + ``_snapshot()``. ``_generation`` increments on every mutation; + facades compare it against a cached value to decide whether their + snapshot is stale (dirty-tracking, spec §3 -- the facade itself is + a later task). + + The module-level ``CONSTANTS`` singleton (below) has ``_shared`` + flipped to ``True``: any mutation reached through it emits + ``DeprecationWarning`` pointing at ``Lexicon``/``Policy`` and + ``HumanName(constants=...)``. A private ``Constants()`` never + warns -- only the shared instance is on the 3.0 removal path. + """ + + _shared = False # the CONSTANTS singleton flips this to True + _generation: int + + prefixes: SetManager + suffix_acronyms: SetManager + suffix_not_acronyms: SetManager + suffix_acronyms_ambiguous: SetManager + titles: SetManager + first_name_titles: SetManager + conjunctions: SetManager + bound_first_names: SetManager + non_first_name_prefixes: SetManager + capitalization_exceptions: TupleManager + nickname_delimiters: _DelimiterManager + maiden_delimiters: _DelimiterManager + regexes: _RegexesProxy + + patronymic_name_order: bool + middle_name_as_last: bool + capitalize_name: bool + force_mixed_case_capitalization: bool + string_format: str | None + initials_format: str + initials_delimiter: str + initials_separator: str + suffix_delimiter: str | None + + def __init__( + self, + *, + prefixes: Iterable[str] | object = _UNSET_KWARG, + suffix_acronyms: Iterable[str] | object = _UNSET_KWARG, + suffix_not_acronyms: Iterable[str] | object = _UNSET_KWARG, + suffix_acronyms_ambiguous: Iterable[str] | object = _UNSET_KWARG, + titles: Iterable[str] | object = _UNSET_KWARG, + first_name_titles: Iterable[str] | object = _UNSET_KWARG, + conjunctions: Iterable[str] | object = _UNSET_KWARG, + bound_first_names: Iterable[str] | object = _UNSET_KWARG, + non_first_name_prefixes: Iterable[str] | object = _UNSET_KWARG, + capitalization_exceptions: + Mapping[str, str] | Iterable[tuple[str, str]] | object + = _UNSET_KWARG, + regexes: object = _UNSET_KWARG, + patronymic_name_order: bool = False, + middle_name_as_last: bool = False, + ) -> None: + # v1.4 parity constructor kwargs (#238/#242/#244 hardening); the + # signature is spelled out rather than **kwargs so an unknown + # keyword raises Python's own TypeError with no help from here. + # `regexes` is the one deliberate 2.0 divergence: v1.4 accepted it + # (RegexTupleManager(regexes)), but constructor injection is + # assignment by another door, and __setattr__ above already + # forbids `c.regexes = ...` post-construction -- the uniform 2.0 + # rule is that parsing behavior is configured through Policy, not + # by handing Constants a compiled-pattern mapping either way. + if regexes is not _UNSET_KWARG: + raise TypeError( + "Constants(regexes=...) is not supported in 2.0; parsing " + "behavior is configured through named Policy flags, not " + "by constructing Constants with a regex mapping. See the " + "migration guide." + ) + overrides = { + "prefixes": prefixes, + "suffix_acronyms": suffix_acronyms, + "suffix_not_acronyms": suffix_not_acronyms, + "suffix_acronyms_ambiguous": suffix_acronyms_ambiguous, + "titles": titles, + "first_name_titles": first_name_titles, + "conjunctions": conjunctions, + "bound_first_names": bound_first_names, + "non_first_name_prefixes": non_first_name_prefixes, + } + vocab = _default_vocab() + object.__setattr__(self, "_generation", 0) + for name in _SET_FIELDS: + value = overrides[name] + if value is _UNSET_KWARG: + value = vocab[name] + # a caller-supplied value REPLACES that field's default + # vocabulary wholesale (v1 parity); SetManager itself validates/ + # normalizes and rejects a bare str/bytes (#238/#241), naming + # this field in the message via _field= + object.__setattr__( + self, name, + SetManager(value, _on_change=self._bump, _field=name)) # type: ignore[arg-type] + if capitalization_exceptions is _UNSET_KWARG: + from nameparser.config.capitalization import ( + CAPITALIZATION_EXCEPTIONS, + ) + capitalization_exceptions = CAPITALIZATION_EXCEPTIONS + object.__setattr__(self, "capitalization_exceptions", TupleManager( + capitalization_exceptions, # type: ignore[arg-type] + _on_change=self._bump)) + object.__setattr__(self, "nickname_delimiters", _DelimiterManager( + {name: name for name in _DELIMITER_SENTINELS}, + _on_change=self._bump)) + object.__setattr__(self, "maiden_delimiters", _DelimiterManager( + _on_change=self._bump)) + object.__setattr__(self, "regexes", _RegexesProxy()) + for name, value in _SCALAR_DEFAULTS.items(): + object.__setattr__(self, name, value) + # the two behavior bools were v1.4 constructor kwargs too + # (docs/customize.rst doctests use them); truthiness matches v1, + # storage goes through the plain scalar slot + if patronymic_name_order: + object.__setattr__(self, "patronymic_name_order", True) + if middle_name_as_last: + object.__setattr__(self, "middle_name_as_last", True) + + def _invalidate_pst(self) -> None: + """Pickle-compat alias only, never called at runtime: v1's four + cached-union ``SetManager`` fields (``prefixes``, + ``suffix_acronyms``, ``suffix_not_acronyms``, ``titles``) stored + their ``_on_change`` as the bound method + ``Constants._invalidate_pst``. A pickled bound method serializes + as a back-reference to its ``__self__`` (this same ``Constants`` + instance, mid-unpickle) plus the method NAME -- reconstructed via + ``getattr(constants_obj, '_invalidate_pst')`` before + ``Constants.__setstate__`` ever runs (same two-phase-unpickling + story as ``RegexTupleManager`` above). Without this name, loading + a v1.4 blob raises ``AttributeError`` looking up the method. + ``SetManager.__setstate__`` (shim) never restores ``_on_change`` + from pickled state regardless -- it always resets to ``None`` and + is rewired by ``Constants.__setstate__`` below -- so this bound + method value is reconstructed only to satisfy the pickle format; + it is discarded immediately and never invoked. + """ + + def _bump(self) -> None: + # stacklevel=3 is exact for the direct scalar-assignment path + # (user code -> Constants.__setattr__ -> here) and lands one + # frame short -- inside the manager's own add()/remove()/ + # __setitem__ -- for the indirect manager-mutation path (user + # code -> manager method -> _changed() -> here), since a + # single warn() call can't be exact for both call depths at + # once. Either way the warning still fires from this module, + # not the manager's true caller, which is enough: only the + # DeprecationWarning's presence/category/message are load- + # bearing (see the specified test), not the reported line. + if self._shared: + warnings.warn(_SHARED_MUTATION_MESSAGE, DeprecationWarning, + stacklevel=3) + object.__setattr__(self, "_generation", self._generation + 1) + + def __setattr__(self, name: str, value: object) -> None: + if name == "_shared": + # the flag the whole shared-mutation DeprecationWarning + # mechanism hinges on: only the module-level singleton flip + # (object.__setattr__ below) may set it + raise AttributeError( + "Constants._shared is read-only; it marks the module " + "singleton and is set once at import" + ) + if name == "empty_attribute_default": + raise AttributeError( + "empty_attribute_default was removed in 2.0 (#255): " + "empty attributes are always ''" + ) + if name == "regexes": + raise TypeError( + "replacing CONSTANTS.regexes is not supported in 2.0; " + "parsing behavior is configured through named Policy " + "flags" + ) + if name in _SET_FIELDS: + # v1 allowed wholesale reassignment (c.titles = {...}); same + # bare-str/bytes guard as the constructor kwarg path (#238/#241) + value = SetManager( + value, _on_change=self._bump, _field=name) # type: ignore[arg-type] + elif name == "capitalization_exceptions": + value = TupleManager(value, _on_change=self._bump) # type: ignore[arg-type] + elif name in ("nickname_delimiters", "maiden_delimiters"): + value = _DelimiterManager(value, _on_change=self._bump) # type: ignore[arg-type] + elif name in _SCALAR_DEFAULTS and \ + getattr(self, name, _UNSET) == value: + # no-op scalar assignment: managers already suppress no-op + # mutations, so re-assigning the current scalar value must + # not bump the generation (or warn on the shared singleton) + # either. __init__ writes via object.__setattr__, so this + # only runs on real user assignments -- _UNSET never + # actually matches, it just keeps a not-yet-set attribute + # from raising here. Manager-field reassignment above stays + # an unconditional bump: comparing manager contents isn't + # worth it. + object.__setattr__(self, name, value) + return + object.__setattr__(self, name, value) + if name in _MANAGER_FIELDS or name in _SCALAR_DEFAULTS: + self._bump() + + def copy(self) -> Constants: + """Independent copy (#260), subclass-preserving. Divergence from + v1 (which deepcopied __dict__): attributes OUTSIDE the known + field surface -- e.g. ad-hoc names stashed on the instance -- + are not carried by copy() or pickling; only the enumerated + fields survive.""" # #260 + # An independent instance with its own generation counter and + # its own manager callbacks -- not a shared-state alias like a + # naive attribute-for-attribute copy would produce. v1's copy() + # was `copy.deepcopy(self)`, which builds the new object via + # `type(self).__new__(type(self))` -- NOT by calling `type(self)()` + # -- so a Constants subclass copies as itself without its __init__ + # running (and without needing to satisfy whatever signature that + # __init__ might require). Mirrored here with an explicit __new__ + # bypass rather than type(self)(). + new = object.__new__(type(self)) + object.__setattr__(new, "_generation", 0) + for name in _SET_FIELDS: + object.__setattr__( + new, name, + SetManager(getattr(self, name), _on_change=new._bump)) + object.__setattr__(new, "capitalization_exceptions", TupleManager( + dict(self.capitalization_exceptions), _on_change=new._bump)) + for bucket in ("nickname_delimiters", "maiden_delimiters"): + object.__setattr__(new, bucket, _DelimiterManager( + dict(getattr(self, bucket)), _on_change=new._bump)) + object.__setattr__(new, "regexes", _RegexesProxy()) + for name in _SCALAR_DEFAULTS: + object.__setattr__(new, name, getattr(self, name)) + return new + + def __repr__(self) -> str: # #221 + # Collections (some with hundreds of entries, e.g. titles/prefixes) + # are summarized as counts rather than dumped in full, like v1. + # Scalars are only shown when they differ from the library default + # -- _SCALAR_DEFAULTS stands in for v1's `getattr(type(self), name)` + # class-level default, since this shim's scalar defaults are + # instance attributes set in __init__, not class attributes. + lines = [f" {name}: {len(getattr(self, name))}" + for name in _REPR_COLLECTION_ATTRS] + lines += [ + f" {name}: {value!r}" for name in _REPR_SCALAR_ATTRS + if (value := getattr(self, name)) != _SCALAR_DEFAULTS[name] + ] + return "" + + # -- snapshot ----------------------------------------------------------- + + def _snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: + # generation-keyed cache: rebuilding the Lexicon re-normalizes + # ~1400 vocabulary entries (~185us) -- the same dirty-tracking + # the facade uses, applied one level up. A pure read either way + # (no bump, no warning). + cached = getattr(self, "_snapshot_cache", None) + if cached is not None and cached[0] == self._generation: + return cached[1] # type: ignore[no-any-return] + snapshot = self._build_snapshot() + object.__setattr__( + self, "_snapshot_cache", (self._generation, snapshot)) + return snapshot + + def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: + """Resolve this v1-shaped, mutable Constants into the frozen + 2.0 value objects it corresponds to (spec §3). A pure read: no + generation bump, no deprecation warning even on the shared + singleton -- only direct attribute mutation is on the 3.0 + removal path. + """ + from nameparser.config.maiden_markers import MAIDEN_MARKERS + acronyms = frozenset(self.suffix_acronyms) + # keep in sync with _lexicon._default_lexicon() (pinned by the + # default-Constants equality test in tests/v2/test_config_shim.py) + lexicon = Lexicon( + titles=frozenset(self.titles), + given_name_titles=frozenset(self.first_name_titles), + suffix_acronyms=acronyms, + suffix_words=frozenset(self.suffix_not_acronyms), + # intersect: Lexicon enforces ambiguous <= acronyms; v1 + # behaves the same when an acronym is deleted but its + # ambiguous entry lingers (the entry simply stops mattering) + suffix_acronyms_ambiguous=frozenset( + self.suffix_acronyms_ambiguous) & acronyms, + particles=frozenset(self.prefixes), + # complement translation: v1 marks the never-given subset; + # v2 marks the may-be-given subset + particles_ambiguous=frozenset(self.prefixes) + - frozenset(self.non_first_name_prefixes), + conjunctions=frozenset(self.conjunctions), + bound_given_names=frozenset(self.bound_first_names), + # v1 Constants has no manager for these (#274 is 2.0 + # behavior); the data module is the only source + maiden_markers=frozenset(MAIDEN_MARKERS), + # TupleManager is dict[str, object] (v1 parity: values were + # never statically str-typed); every real entry is a str, + # same assumption _DelimiterManager's sentinel lookup makes + capitalization_exceptions=tuple( + sorted(self.capitalization_exceptions.items())), # type: ignore[arg-type] + ) + rules = frozenset({PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) \ + if self.patronymic_name_order else frozenset() + policy = Policy( + patronymic_rules=rules, + middle_as_family=self.middle_name_as_last, + nickname_delimiters=frozenset( + _SENTINEL_PAIRS[k] for k in self.nickname_delimiters), + # v1 precedence: a pair in BOTH v1 buckets parses as nickname. + # Policy itself resolves overlap the other way (maiden wins), + # so pre-subtract here to keep the facade at v1 behavior. + maiden_delimiters=frozenset( + _SENTINEL_PAIRS[k] for k in self.maiden_delimiters + if k not in self.nickname_delimiters), + # suffix_delimiter is a _RenderDefaults-only field here; the + # facade layers it onto extra_suffix_delimiters per instance + # (a later task) -- _snapshot() itself stays pure translation + ) + defaults = _RenderDefaults( + self.string_format, self.initials_format, self.initials_delimiter, + self.initials_separator, self.suffix_delimiter, + self.capitalize_name, self.force_mixed_case_capitalization) + return lexicon, policy, defaults + + # -- pickle ----------------------------------------------------------- + + def __getstate__(self) -> dict[str, object]: + state: dict[str, object] = {} + for name in _SET_FIELDS: + state[name] = set(getattr(self, name)) + state["capitalization_exceptions"] = dict( + self.capitalization_exceptions) + state["nickname_delimiters"] = dict(self.nickname_delimiters) + state["maiden_delimiters"] = dict(self.maiden_delimiters) + for name in _SCALAR_DEFAULTS: + state[name] = getattr(self, name) + return state + + def __setstate__(self, state: dict[str, object]) -> None: + if "suffixes_prefixes_titles" in state: + # pre-1.3.0 blob: its dir()-sweep __getstate__ captured this + # computed property. The 1.4 DeprecationWarning promised + # ValueError in 2.0 (#279). + raise ValueError( + "this pickle was written by nameparser <= 1.2.x (#279); " + "re-pickle under 1.3/1.4 to migrate, or re-create the " + "configuration. See " + "https://github.com/derek73/python-nameparser/issues/279" + ) + # Accepts BOTH shapes with a single overlay: the shim's own + # state and v1.3/1.4 state (public field names -> manager/ + # scalar values) share every key that matters, so no shape + # marker is needed. empty_attribute_default is accepted and + # DROPPED (#255: empty is always '' in 2.0). + state = {k: v for k, v in state.items() + if k != "empty_attribute_default"} + self.__init__() # type: ignore[misc] # defaults, then overlay + # (managers re-wrapped below so _on_change points at THIS + # instance, not whatever produced the incoming state) + for name in _SET_FIELDS: + if name in state: + object.__setattr__(self, name, SetManager( + state[name], _on_change=self._bump)) # type: ignore[arg-type] + if "capitalization_exceptions" in state: + object.__setattr__( + self, "capitalization_exceptions", TupleManager( + state["capitalization_exceptions"], # type: ignore[arg-type] + _on_change=self._bump)) + for bucket in ("nickname_delimiters", "maiden_delimiters"): + if bucket in state: + object.__setattr__(self, bucket, _DelimiterManager( + state[bucket], _on_change=self._bump)) # type: ignore[arg-type] + for name in _SCALAR_DEFAULTS: + if name in state: + object.__setattr__(self, name, state[name]) + + +CONSTANTS = Constants() +object.__setattr__(CONSTANTS, "_shared", True) diff --git a/nameparser/_facade.py b/nameparser/_facade.py new file mode 100644 index 00000000..0ea0083b --- /dev/null +++ b/nameparser/_facade.py @@ -0,0 +1,679 @@ +"""The 2.0 ``HumanName`` facade (migration spec §2): a mutable wrapper +over a frozen ParsedName, delegating parsing to the core Parser resolved +from the bound Constants shim. Keeps every v1 spelling. Deleted in 3.0. + +Layering: facade layer -- may import anything public plus _render. +""" +from __future__ import annotations + +import dataclasses +import warnings +from collections.abc import Iterator +from typing import Any + +# Import order matters here -- breaks a real import cycle. nameparser. +# config's package __init__ re-exports CONSTANTS/Constants/etc. from +# _config_shim (the v1 nameparser.config.Constants compat path), while +# _config_shim's own default CONSTANTS singleton needs nameparser.config's +# DATA submodules (titles, prefixes, ...), imported lazily -- see +# _config_shim.py's module docstring. If _config_shim were the first of +# the two ever touched, building its CONSTANTS would need to import +# nameparser.config, whose __init__ would in turn need _config_shim's +# (not-yet-built) CONSTANTS: ImportError. Importing the config package +# here first lets its __init__ run to completion; when IT then imports +# _config_shim to build the default CONSTANTS, nameparser.config is +# already registered in sys.modules, so its data-submodule imports +# resolve directly instead of re-entering (and failing on) its own +# still-executing __init__. +import nameparser.config # noqa: F401 + +import nameparser._render as _render +from nameparser._config_shim import CONSTANTS, Constants, _cached_parser +from nameparser._lexicon import _normalize +from nameparser._parser import Parser +from nameparser._types import FOLDED_TAG, ParsedName, Role + +_V2_FIELD = {"first": "given", "last": "family"} # v1 name -> v2 name +_V1_SPELLING = {v2: v1 for v1, v2 in _V2_FIELD.items()} +# derived from Role: declaration order IS the canonical field order +# (never restated), rendered in the v1 spellings +_MEMBERS = tuple(_V1_SPELLING.get(r.value, r.value) for r in Role) + + + +#: v1 parsing hooks the facade never calls (spec §2 exception 2 / #280). +_V1_HOOKS = ( + "pre_process", "post_process", "parse_full_name", "parse_pieces", + "parse_nicknames", "join_on_conjunctions", "squash_emoji", + "handle_firstnames", "handle_middle_name_as_last", + "is_title", "is_conjunction", "is_prefix", "is_roman_numeral", + "is_suffix", "is_suffix_lenient", "is_an_initial", +) +# Module-level mutable state (sanctioned exception, AGENTS.md "One +# sanctioned global"): strong-references every distinct HumanName +# subclass for process lifetime so the hook warning fires once per +# class. Fine in practice -- subclasses are statically defined, and the +# whole module is deleted with the facade layer in 3.0. +_WARNED_SUBCLASSES: set[type] = set() + + +def _empty_parsed() -> ParsedName: + return ParsedName(original="", tokens=(), ambiguities=()) + + +class HumanName: + """v1 ``HumanName`` facade: a mutable wrapper over a frozen + ``ParsedName``, delegating all parsing to the core ``Parser`` + resolved from the bound ``Constants`` shim (dirty-tracked via its + ``_generation``). Keeps every v1 spelling (``first``/``last`` over + the core ``given``/``family``); the v1 parsing hooks are never + called (#280). Deleted with the facade layer in 3.0. + """ + + def __init__( + self, + full_name: str = "", + constants: Constants | None = CONSTANTS, + string_format: str | None = None, + initials_format: str | None = None, + initials_delimiter: str | None = None, + initials_separator: str | None = None, + suffix_delimiter: str | None = None, + first: str | list[str] | None = None, + middle: str | list[str] | None = None, + last: str | list[str] | None = None, + title: str | list[str] | None = None, + suffix: str | list[str] | None = None, + nickname: str | list[str] | None = None, + maiden: str | list[str] | None = None, + ) -> None: + if constants is None: + raise TypeError( + "constants=None was removed in 2.0 (#261): pass a " + "Constants instance, or use the new Parser/Lexicon/" + "Policy API for per-call configuration" + ) + if not isinstance(constants, Constants): + raise TypeError( + f"constants must be a Constants instance, got {constants!r}" + ) + self._warn_overridden_hooks() + self._C = constants + self._snapshot_gen = -1 # forces first resolve + _, _, defaults = constants._snapshot() + self.string_format = (string_format if string_format is not None + else defaults.string_format) + self.initials_format = (initials_format if initials_format is not None + else defaults.initials_format) + self.initials_delimiter = ( + initials_delimiter if initials_delimiter is not None + else defaults.initials_delimiter) + self.initials_separator = ( + initials_separator if initials_separator is not None + else defaults.initials_separator) + # These five assignments route through the validating properties + # below. The suffix_delimiter setter resets _snapshot_gen to -1 + # on every assignment (including this one, harmlessly -- it's + # already -1 above), so reassigning it post-construction (e.g. + # n.suffix_delimiter = " - ") correctly forces the next + # _resolve() to rebuild the Policy with the new delimiter. + self.suffix_delimiter = (suffix_delimiter if suffix_delimiter is not None + else defaults.suffix_delimiter) + self._full_name = "" + self._parsed = _empty_parsed() + if first or middle or last or title or suffix or nickname or maiden: + # These route through the field-setter properties (None + # clears the field); no full-string parse, full_name stays "". + self.first = first + self.middle = middle + self.last = last + self.title = title + self.suffix = suffix + self.nickname = nickname + self.maiden = maiden + else: + self._apply_full_name(full_name) + + @classmethod + def _warn_overridden_hooks(cls) -> None: + if cls is HumanName or cls in _WARNED_SUBCLASSES: + return + overridden = [h for h in _V1_HOOKS + if getattr(cls, h, None) is not getattr( + HumanName, h, None)] + _WARNED_SUBCLASSES.add(cls) + if overridden: + warnings.warn( + f"{cls.__name__} overrides v1 parsing hooks " + f"({', '.join(overridden)}) that the 2.0 facade never " + f"calls; parsing is delegated to the core Parser. " + f"Migrate to the Lexicon/Policy API. See " + f"https://github.com/derek73/python-nameparser/issues/280", + DeprecationWarning, stacklevel=3) + + # -- render defaults ----------------------------------------------------- + # One-line validating setters (spec §2): assigning a non-str (or, for + # the two fields that allow it, non-str-non-None) raises TypeError at + # assignment time instead of failing later inside .format(). + + @property + def string_format(self) -> str | None: + return self._string_format + + @string_format.setter + def string_format(self, value: str | None) -> None: + if value is not None and not isinstance(value, str): + raise TypeError( + f"string_format must be a str or None, got {value!r}") + self._string_format = value + + @property + def initials_format(self) -> str: + return self._initials_format + + @initials_format.setter + def initials_format(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError( + f"initials_format must be a str, got {value!r}") + self._initials_format = value + + @property + def initials_delimiter(self) -> str: + return self._initials_delimiter + + @initials_delimiter.setter + def initials_delimiter(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError( + f"initials_delimiter must be a str, got {value!r}") + self._initials_delimiter = value + + @property + def initials_separator(self) -> str: + return self._initials_separator + + @initials_separator.setter + def initials_separator(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError( + f"initials_separator must be a str, got {value!r}") + self._initials_separator = value + + @property + def suffix_delimiter(self) -> str | None: + return self._suffix_delimiter + + @suffix_delimiter.setter + def suffix_delimiter(self, value: str | None) -> None: + if value is not None and not isinstance(value, str): + raise TypeError( + f"suffix_delimiter must be a str or None, got {value!r}") + self._suffix_delimiter = value + # Invalidate the cached Policy: _resolve() layers suffix_delimiter + # onto extra_suffix_delimiters, so a stale snapshot would keep + # parsing against the old delimiter. + self._snapshot_gen = -1 + + # -- config / parsing --------------------------------------------------- + + def _resolve(self) -> Parser: + """Dirty-tracked parser resolution (spec §3): rebuild the + snapshot only when the bound Constants' generation moved.""" + gen = self._C._generation + if self._snapshot_gen != gen: + lexicon, policy, _ = self._C._snapshot() + if self.suffix_delimiter: + policy = dataclasses.replace( + policy, + extra_suffix_delimiters=frozenset( + {self.suffix_delimiter})) + self._lexicon, self._policy = lexicon, policy + self._parser = _cached_parser(lexicon, policy) + self._snapshot_gen = gen + # the fast path is a plain attribute return: hashing the two + # value objects for the lru lookup is the whole fast-path cost + return self._parser + + def parse_full_name(self) -> None: + """Re-parse the stored ``full_name`` (v1's documented re-parse + trigger, docs/customize.rst): mutate ``name.C`` then call this to + force a re-parse without reassigning ``full_name``. The v1 + parsing INTERNALS this name evokes live in the core ``Parser``, + not here; a subclass overriding this method still triggers the + #280 hook-override warning, and full_name assignment never + consults it.""" + self._apply_full_name(self._full_name) + + def _apply_full_name(self, value: str) -> None: + if isinstance(value, bytes): + raise TypeError( + "bytes input was removed in 2.0 (#245): decode first, " + "e.g. HumanName(raw.decode('utf-8'))" + ) + if not isinstance(value, str): + raise TypeError(f"full_name must be a str, got {value!r}") + # parse FIRST: if snapshot resolution raises, the instance must + # not be left with a new full_name over the old parsed fields + parsed = self._resolve().parse(value) + self._full_name = value + self._parsed = parsed + if self._C.capitalize_name: + self.capitalize() # v1 parser.py:1653 parity + + def capitalize(self, force: bool | None = None) -> None: + """Re-capitalize the current parse against the bound lexicon. + force=None reads the bound Constants' render default + (force_mixed_case_capitalization); the core's capitalized() + implements the single-case gate (v1 parity) -- not + re-implemented here.""" + self._resolve() + if force is None: + force = self._C.force_mixed_case_capitalization + self._parsed = self._parsed.capitalized(self._lexicon, force=force) + + @property + def full_name(self) -> str: + return self._full_name + + @full_name.setter + def full_name(self, value: str) -> None: + self._apply_full_name(value) + + @property + def original(self) -> str: + return self._parsed.original or self._full_name + + @property + def C(self) -> Constants: + return self._C + + @C.setter + def C(self, constants: Constants | None) -> None: + # v1.4 closed #239 by making C a validating setter that ONLY + # stores the new value -- no re-parse (checked against v1.4: + # `git show 2d5d8c2:nameparser/parser.py` lines ~204-206, the C + # setter body is exactly `self._C = self._validate_constants(...)`). + # A caller who wants the new config reflected must still trigger + # a re-parse, e.g. via parse_full_name() or a full_name + # reassignment -- matched here rather than re-parsing eagerly. + if constants is None: + raise TypeError( + "assigning constants=None to C was removed in 2.0 (#261): " + "pass a Constants instance, or use the new Parser/Lexicon/" + "Policy API for per-call configuration" + ) + if not isinstance(constants, Constants): + raise TypeError( + f"constants must be a Constants instance, got {constants!r}" + ) + self._C = constants + self._snapshot_gen = -1 # invalidate: next _resolve() rebuilds + + @property + def has_own_config(self) -> bool: + """True when this instance is not using the shared module-level + CONSTANTS.""" + return self._C is not CONSTANTS + + # -- fields --------------------------------------------------------- + + def _get_field(self, member: str) -> str: + return getattr(self._parsed, _V2_FIELD.get(member, member)) + + def _set_field(self, member: str, value: str | list[str] | None) -> None: + if value is None: + joined = "" + elif isinstance(value, list): + for element in value: + if not isinstance(element, str): + raise TypeError( + f"name parts must be strings, got {element!r}") + joined = " ".join(value) + elif isinstance(value, str): + joined = value + else: + raise TypeError( + f"{member} must be a str, list, or None, got {value!r}") + self._parsed = self._parsed.replace( + **{_V2_FIELD.get(member, member): joined}) + + def _list_for(self, member: str) -> list[str]: + # A "joined" continuation token ("Ph." + "D.") belongs to its + # predecessor's part, matching v1's fix_phd (suffix_list had ONE + # "Ph. D." element). ParsedName._text_for heals only the suffix + # string view (the ", " join); the facade list view heals for + # every role -- a continuation is never its own list element. + role = Role(_V2_FIELD.get(member, member)) + parts: list[str] = [] + folded: list[str] = [] + for tok in self._parsed.tokens_for(role): + if "joined" in tok.tags and parts: + parts[-1] += " " + tok.text + elif FOLDED_TAG in tok.tags: + # middle_as_family fold: v1 PREPENDED middle_list to + # last_list -- keep the list view consistent with the + # string view (_text_for orders folded-first too) + folded.append(tok.text) + else: + parts.append(tok.text) + return folded + parts + + @property + def title(self) -> str: + return self._get_field("title") + + @title.setter + def title(self, value: str | list[str] | None) -> None: + self._set_field("title", value) + + @property + def title_list(self) -> list[str]: + return self._list_for("title") + + @property + def first(self) -> str: + return self._get_field("first") + + @first.setter + def first(self, value: str | list[str] | None) -> None: + self._set_field("first", value) + + @property + def first_list(self) -> list[str]: + return self._list_for("first") + + @property + def middle(self) -> str: + return self._get_field("middle") + + @middle.setter + def middle(self, value: str | list[str] | None) -> None: + self._set_field("middle", value) + + @property + def middle_list(self) -> list[str]: + return self._list_for("middle") + + @property + def last(self) -> str: + return self._get_field("last") + + @last.setter + def last(self, value: str | list[str] | None) -> None: + self._set_field("last", value) + + @property + def last_list(self) -> list[str]: + return self._list_for("last") + + @property + def suffix(self) -> str: + return self._get_field("suffix") + + @suffix.setter + def suffix(self, value: str | list[str] | None) -> None: + self._set_field("suffix", value) + + @property + def suffix_list(self) -> list[str]: + return self._list_for("suffix") + + @property + def nickname(self) -> str: + return self._get_field("nickname") + + @nickname.setter + def nickname(self, value: str | list[str] | None) -> None: + self._set_field("nickname", value) + + @property + def nickname_list(self) -> list[str]: + return self._list_for("nickname") + + @property + def maiden(self) -> str: + return self._get_field("maiden") + + @maiden.setter + def maiden(self, value: str | list[str] | None) -> None: + self._set_field("maiden", value) + + @property + def maiden_list(self) -> list[str]: + return self._list_for("maiden") + + # -- derived views ---------------------------------------------------- + + @property + def surnames_list(self) -> list[str]: + return self.middle_list + self.last_list + + @property + def surnames(self) -> str: + return " ".join(self.surnames_list) + + @property + def given_names_list(self) -> list[str]: + return self.first_list + self.middle_list + + @property + def given_names(self) -> str: + return " ".join(self.given_names_list) + + def _is_particle(self, text: str) -> bool: + self._resolve() + return _normalize(text) in self._lexicon.particles + + def _is_conjunction(self, text: str) -> bool: + self._resolve() + return _normalize(text) in self._lexicon.conjunctions + + def _split_last(self) -> tuple[list[str], list[str]]: + # v1 parser.py _split_last, verbatim: vocabulary lookup at ACCESS + # time (so assigned last names split too), with the all-particle + # guard (a family name is assumed not to consist entirely of + # particles, e.g. surname "Do" which also appears in PREFIXES) + words = " ".join(self.last_list).split() + i = 0 + while i < len(words) and self._is_particle(words[i]): + i += 1 + if i == len(words): + return [], words + return words[:i], words[i:] + + @property + def last_prefixes_list(self) -> list[str]: + return self._split_last()[0] + + @property + def last_prefixes(self) -> str: + return " ".join(self._split_last()[0]) + + @property + def last_base_list(self) -> list[str]: + return self._split_last()[1] + + @property + def last_base(self) -> str: + return " ".join(self._split_last()[1]) + + # -- initials ------------------------------------------------------------- + + def _process_initial(self, name_part: str, firstname: bool = False) -> str: + # v1 parser.py:427 verbatim: particles/conjunctions are filtered + # from initials unless the part is a first name. split() rather + # than split(" "): *_list attributes assigned directly bypass + # whitespace normalization, and split(" ") yields empty strings + # for repeated spaces (#232). + parts = name_part.split() + initials = [] + for part in parts: + if not (self._is_particle(part) + or self._is_conjunction(part)) or firstname: + initials.append(part[0]) + if len(initials) > 0: + return self.initials_separator.join(initials) + # Return '' (never empty_attribute_default, which may be None) + # when a part has no initialable words, e.g. a middle name + # consisting only of prefixes ("de la"). Callers drop these + # parts entirely. + return "" + + def _initials_lists(self) -> tuple[list[str], list[str], list[str]]: + """Initials for the first, middle and last name groups. Parts + that yield no initials (e.g. a prefix-only middle name like + "de la") are dropped rather than kept as empty strings. + """ + def group_initials(names: list[str], + firstname: bool = False) -> list[str]: + return [i for i in (self._process_initial(n, firstname) + for n in names if n) if i] + return (group_initials(self.first_list, True), + group_initials(self.middle_list), + group_initials(self.last_list)) + + def initials_list(self) -> list[str]: + first, middle, last = self._initials_lists() + return first + middle + last + + def initials(self) -> str: + first, middle, last = self._initials_lists() + joiner = self.initials_delimiter + self.initials_separator + + def group(items: list[str]) -> str: + return joiner.join(items) + self.initials_delimiter \ + if items else "" + + # A fully-empty result renders as "" -- the v1 fallback to + # C.empty_attribute_default (which may be None) is dropped per + # #255. + _s = self.initials_format.format( + first=group(first), middle=group(middle), last=group(last)) + return self.collapse_whitespace(_s) + + # -- comparison ----------------------------------------------------------- + + def matches(self, other: str | HumanName) -> bool: + """Component-wise case-insensitive comparison (v1 parity); a + str argument is parsed with this instance's resolved parser.""" + if not isinstance(other, (str, HumanName)): + # pre-check so the error names the facade type a caller + # actually passed HumanName.matches(), not the core + # ParsedName it delegates to below + raise TypeError( + f"matches() takes a str or HumanName, got {other!r}") + target = other._parsed if isinstance(other, HumanName) else other + return self._parsed.matches(target, parser=self._resolve()) + + def comparison_key(self) -> tuple[str, ...]: + """One casefolded component per field in canonical order -- the + v1 replacement for ==/hash (#223); see ParsedName.comparison_key.""" + return self._parsed.comparison_key() + + # -- dunders ------------------------------------------------------------ + + def collapse_whitespace(self, string: str) -> str: + # v1 parser.py:976 verbatim, over _render's regexes (the #254 + # collapse owns them; this public method keeps v1's narrower + # two-step contract for initials() and direct callers) + string = _render._SPACES.sub(" ", string.strip()) + if string and _render._COMMA_CHAR.fullmatch(string[-1]): + string = string[:-1] + return string + + def __str__(self) -> str: + if self.string_format is not None: + rendered = self.string_format.format( + **{k: v or "" for k, v in self.as_dict().items()}) + # the full #254 collapse is _render._collapse -- one owner + # for the cleanup chain the v1 __str__ spelled inline + return _render._collapse(rendered) + return " ".join(self) + + def __repr__(self) -> str: + attrs = ( + f" title: {self.title or ''!r}\n" + f" first: {self.first or ''!r}\n" + f" middle: {self.middle or ''!r}\n" + f" last: {self.last or ''!r}\n" + f" suffix: {self.suffix or ''!r}\n" + f" nickname: {self.nickname or ''!r}\n" + f" maiden: {self.maiden or ''!r}" + ) + return f"<{self.__class__.__name__} : [\n{attrs}\n]>" + + def __iter__(self) -> Iterator[str]: + return (value for member in _MEMBERS + if (value := getattr(self, member))) + + def __len__(self) -> int: + return sum(1 for member in _MEMBERS if getattr(self, member)) + + def __getitem__(self, key: str) -> str: + if isinstance(key, slice): + raise TypeError( + "slicing a HumanName was removed in 2.0 (#258); access " + "the named attributes instead" + ) + return getattr(self, key) + + def as_dict(self, include_empty: bool = True) -> dict[str, str]: + """The seven v1-named components as a dict; include_empty=False + drops empty fields.""" + d = {member: getattr(self, member) for member in _MEMBERS} + if include_empty: + return d + return {k: v for k, v in d.items() if v} + + # -- pickle (v1-shaped state; one path for 1.4 and 2.x blobs) ----------- + + def __getstate__(self) -> dict[str, Any]: + # The emitted key set matches v1.4's pickle shape (minus + # encoding/_had_comma/_derived_*, which are v1-internal and + # ignored on read), so one __setstate__ path serves both eras. + state: dict[str, Any] = { + "_full_name": self._full_name, + "original": self.original, + "C": None if self._C is CONSTANTS else self._C, + "string_format": self.string_format, + "initials_format": self.initials_format, + "initials_delimiter": self.initials_delimiter, + "initials_separator": self.initials_separator, + "suffix_delimiter": self.suffix_delimiter, + } + for member in _MEMBERS: + state[f"{member}_list"] = getattr(self, f"{member}_list") + return state + + def __setstate__(self, state: dict[str, Any]) -> None: + c = state.get("C") + self._C = CONSTANTS if c is None else c + self._snapshot_gen = -1 + defaults = self._C._snapshot()[2] + self._string_format = state.get("string_format", + defaults.string_format) + self._initials_format = state.get("initials_format", + defaults.initials_format) + self._initials_delimiter = state.get("initials_delimiter", + defaults.initials_delimiter) + self._initials_separator = state.get("initials_separator", + defaults.initials_separator) + self._suffix_delimiter = state.get("suffix_delimiter", + defaults.suffix_delimiter) + self._full_name = state.get("_full_name", "") + # components come back exactly as pickled (spec §2): synthetic + # tokens via replace(), never a re-parse. Known edge: replace() + # re-splits on whitespace without the "joined" tag, so joined-tag + # healing is lost for multi-word list elements -- v1's fix_phd + # suffix pickles as ["Ph. D."] but round-trips to ["Ph.", "D."], + # rendering the suffix as "Ph., D.". Classify in the differential + # harness / M12 if it surfaces. + parsed = ParsedName(original=str(state.get("original", "")), + tokens=(), ambiguities=()) + fields = {} + for member in _MEMBERS: + values = state.get(f"{member}_list") or [] + fields[_V2_FIELD.get(member, member)] = " ".join(values) + self._parsed = parsed.replace( + **{k: v for k, v in fields.items() if v}) diff --git a/nameparser/_lexicon.py b/nameparser/_lexicon.py new file mode 100644 index 00000000..f83fc7b2 --- /dev/null +++ b/nameparser/_lexicon.py @@ -0,0 +1,377 @@ +"""Immutable vocabulary configuration for the 2.0 API. + +Layering: may import nameparser.config DATA modules (the imports in +_default_lexicon() are the authoritative list) as the single source of +vocabulary during 2.x -- never nameparser.config itself, never +nameparser.parser. Enforced by tests/v2/test_layering.py. +""" +from __future__ import annotations + +import dataclasses +import functools +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from types import MappingProxyType + +#: Vocabulary set fields, in declaration order. add()/remove() operate +#: on exactly these and reject capitalization_exceptions (its entries +#: are pairs -- use dataclasses.replace); __or__ unions these AND merges +#: capitalization_exceptions right-biased. +_VOCAB_FIELDS = ( + "titles", "given_name_titles", "suffix_acronyms", "suffix_words", + "suffix_acronyms_ambiguous", "particles", "particles_ambiguous", + "conjunctions", "bound_given_names", "maiden_markers", +) + + +def _normalize(word: str) -> str: + """Lowercase, strip whitespace and EDGE periods -- v1's lc() + semantics. Interior periods survive on purpose: 'J.R.' must not + collapse to 'jr' and hit the periodless vocabulary (v1 parity, + pinned live 2026-07-17). Suffix-ACRONYM membership alone uses the + period-free form (see _vocab.suffix_as_written), mirroring v1's + is_suffix, which removed periods only for the acronym test. + + lower(), NOT casefold(): casefold's caseless-matching folds mutate + the stored vocabulary itself -- 'κος' becomes the misspelling 'κοσ' + (final sigma flattened) and 'großfürst' becomes 'grossfürst' -- + while lower() applies Unicode SpecialCasing contextually and keeps + both as authored. This function is the single fold for storage AND + match-time lookups, so matching stays symmetric either way; lower() + is what v1's lc() used, preserving which cross-spellings match.""" + return word.lower().strip().strip(".") + + +def _normset(entries: Iterable[str], field_name: str) -> frozenset[str]: + # Reject a bare str before iterating: iterating "dr" would silently + # yield the single characters {'d', 'r'} -- the set(str) footgun on + # the primary customization surface. + if isinstance(entries, str): + raise TypeError( + f"Lexicon.{field_name} must be an iterable of strings, " + f"not a bare string" + ) + # A Mapping would silently contribute only its keys; a dict here + # almost always means the caller confused this field with + # capitalization_exceptions. + if isinstance(entries, Mapping): + raise TypeError( + f"Lexicon.{field_name} must be an iterable of strings, not a " + f"mapping (only capitalization_exceptions holds key->value pairs)" + ) + items = tuple(entries) # materialize once; entries may be a generator + normalized = set() + for w in items: + if not isinstance(w, str): + raise TypeError( + f"Lexicon.{field_name} entries must be strings, got {w!r}" + ) + n = _normalize(w) + # "." or "" is a data bug (stray split artifact, empty CSV + # cell); dropping it silently would also let a data-module typo + # vanish instead of failing CI. + if not n: + raise ValueError( + f"Lexicon.{field_name} entry {w!r} normalizes to empty " + f"(lowercase + strip periods/whitespace leaves nothing)" + ) + normalized.add(n) + return frozenset(normalized) + + +def _normpairs( + raw: Mapping[str, str] | Iterable[tuple[str, str]], +) -> tuple[tuple[str, str], ...]: + """Canonicalize capitalization_exceptions input: _normset's sibling + for the one pair-valued field. Dedupes on the NORMALIZED key so the + tuple and the derived map always agree ("Ph.D." and "phd" collide + after normalization); last occurrence wins, matching dict semantics + and the right-bias rule used elsewhere.""" + if isinstance(raw, str): + raise TypeError( + "capitalization_exceptions must be a mapping or an " + "iterable of (key, value) pairs, not a bare string" + ) + pairs = raw.items() if isinstance(raw, Mapping) else raw + deduped: dict[str, str] = {} + for entry in pairs: + # A 2-char str entry would unpack "ab" into ("a", "b") + # silently, so reject str outright; other mis-shapes would + # otherwise surface as bare unpack errors. + if isinstance(entry, str): + raise TypeError( + f"capitalization_exceptions entries must be " + f"(key, value) pairs, got {entry!r}" + ) + try: + k, v = entry + except (TypeError, ValueError): + raise TypeError( + f"capitalization_exceptions entries must be " + f"(key, value) pairs, got {entry!r}" + ) from None + if not isinstance(k, str) or not isinstance(v, str): + raise TypeError( + f"capitalization_exceptions entries must be " + f"str -> str, got {k!r}: {v!r}" + ) + normalized_key = _normalize(k) + if not normalized_key: + raise ValueError( + f"capitalization_exceptions key {k!r} normalizes to " + f"empty (lowercase + strip periods/whitespace leaves " + f"nothing)" + ) + deduped[normalized_key] = v + return tuple(sorted(deduped.items())) + + +@dataclass(frozen=True, slots=True) +class Lexicon: + """The vocabulary a parser matches against: which words are + titles, particles, suffixes, and so on. Immutable and hashable. + Start from :meth:`default` (the shipped vocabulary) or + :meth:`empty`, derive variants with :meth:`add` / :meth:`remove` / + ``|`` (union), and pass the result to ``Parser(lexicon=...)``. + Entries are normalized at construction -- lowercased, edge periods + stripped -- so matching is case-insensitive. Field docs below show + examples, not full contents; inspect any field's shipped vocabulary + directly, e.g. ``Lexicon.default().conjunctions``.""" + + #: Pre-nominal titles ("dr", "sir", "capt", ...). Full default + #: list: :data:`~nameparser.config.titles.TITLES`. + titles: frozenset[str] = frozenset() + #: Titles whose single following name reads as a GIVEN name + #: ("sheikh", "sister", ...) rather than a family name. Full + #: default list: :data:`~nameparser.config.titles.FIRST_NAME_TITLES`. + given_name_titles: frozenset[str] = frozenset() + #: Post-nominal acronym suffixes, matched with or without periods + #: ("phd" matches "PhD" and "Ph.D."). Full default list: + #: :data:`~nameparser.config.suffixes.SUFFIX_ACRONYMS`. + suffix_acronyms: frozenset[str] = frozenset() + #: Post-nominal word suffixes ("jr", "esquire", "iii", ...). Full + #: default list: + #: :data:`~nameparser.config.suffixes.SUFFIX_NOT_ACRONYMS`. + suffix_words: frozenset[str] = frozenset() + #: Subset of suffix_acronyms counted as suffixes only when written + #: WITH periods -- their bare forms are common surnames ("ma", + #: "do": "Jack Ma" keeps his family name). Full default list: + #: :data:`~nameparser.config.suffixes.SUFFIX_ACRONYMS_AMBIGUOUS`. + suffix_acronyms_ambiguous: frozenset[str] = frozenset() + #: Family-name particles that chain onto the following piece + #: ("van", "de", "bin", ...). Full default list: + #: :data:`~nameparser.config.prefixes.PREFIXES`. + particles: frozenset[str] = frozenset() + #: Subset of particles that can also BE a given name: a leading + #: one reads as given and records a particle-or-given ambiguity + #: ("Van Johnson"). No constant of its own -- the default derives + #: as particles minus + #: :data:`~nameparser.config.prefixes.NON_FIRST_NAME_PREFIXES` + #: (which marks the opposite, never-given subset). + particles_ambiguous: frozenset[str] = frozenset() + #: Words or characters that join surrounding pieces into one + #: ("and", "&", "y", "и", ...). Full default list: + #: :data:`~nameparser.config.conjunctions.CONJUNCTIONS`. + conjunctions: frozenset[str] = frozenset() + #: Given-name prefixes that bind to the following word to form one + #: given name ("abdul" -> "Abdul Salam"); never standalone names. + #: Full default list: + #: :data:`~nameparser.config.bound_first_names.BOUND_FIRST_NAMES`. + bound_given_names: frozenset[str] = frozenset() + #: Marker words introducing a birth surname, routed to the maiden + #: field ("née", "geb.", "roz.", ...). Full default list: + #: :data:`~nameparser.config.maiden_markers.MAIDEN_MARKERS`. + maiden_markers: frozenset[str] = frozenset() + #: Lowercase word -> exact-cased replacement used by capitalized() + #: ("phd" -> "Ph.D."). Pair-valued: change it with + #: dataclasses.replace(), not add()/remove(); read it as a mapping + #: via capitalization_exceptions_map. Full default mapping: + #: :data:`~nameparser.config.capitalization.CAPITALIZATION_EXCEPTIONS`. + # Canonical storage: sorted tuple of (key, value) pairs. The + # constructor tolerates any Mapping (or pair iterable) at runtime and + # canonicalizes here; this closes the caller-aliasing hole and keeps + # Lexicon hashable. Read via capitalization_exceptions_map. + capitalization_exceptions: tuple[tuple[str, str], ...] = () + _cap_map: Mapping[str, str] = field( + init=False, repr=False, compare=False, hash=False, + default_factory=lambda: MappingProxyType({})) + + def __post_init__(self) -> None: + for name in _VOCAB_FIELDS: + object.__setattr__(self, name, _normset(getattr(self, name), name)) + canonical = _normpairs(self.capitalization_exceptions) + object.__setattr__(self, "capitalization_exceptions", canonical) + object.__setattr__(self, "_cap_map", MappingProxyType(dict(canonical))) + if not self.particles_ambiguous <= self.particles: + extra = ", ".join(sorted(self.particles_ambiguous - self.particles)) + raise ValueError( + f"particles_ambiguous must be a subset of particles; " + f"not in particles: {extra}" + ) + if not self.suffix_acronyms_ambiguous <= self.suffix_acronyms: + extra = ", ".join(sorted( + self.suffix_acronyms_ambiguous - self.suffix_acronyms)) + raise ValueError( + f"suffix_acronyms_ambiguous must be a subset of " + f"suffix_acronyms; not in suffix_acronyms: {extra}" + ) + + # -- constructors ---------------------------------------------------- + + @classmethod + def empty(cls) -> Lexicon: + return cls() + + @classmethod + def default(cls) -> Lexicon: + return _default_lexicon() + + # -- dunders ---------------------------------------------------------- + + def _deltas_from(self, baseline: Lexicon) -> list[tuple[str, int, int]]: + deltas = [] + for name in _VOCAB_FIELDS + ("capitalization_exceptions",): + mine = set(getattr(self, name)) + theirs = set(getattr(baseline, name)) + added, removed = len(mine - theirs), len(theirs - mine) + if added or removed: + deltas.append((name, added, removed)) + return deltas + + def __repr__(self) -> str: + # Bounded: renders only which fields deviate from the nearer of + # the two named constructors and by how many entries -- never the + # entries themselves (design rule, see nameparser._types module + # docstring). Diffing empty()-built lexicons against default() + # would tell the wrong story ("default minus the entire + # default vocabulary"). + if self == Lexicon.default(): + return "Lexicon(default)" + if self == Lexicon.empty(): + return "Lexicon(empty)" + candidates = [(label, self._deltas_from(baseline)) + for label, baseline in (("default", Lexicon.default()), + ("empty", Lexicon.empty()))] + label, deltas = min( + candidates, key=lambda c: sum(a + r for _, a, r in c[1])) + rendered = ", ".join( + name + ": " + "".join( + part for part, n in ((f"+{a}", a), (f"-{r}", r)) if n) + for name, a, r in deltas) + return f"Lexicon({label} + {rendered})" + + def __getstate__(self) -> dict[str, object]: + # _cap_map is a MappingProxyType, which pickle rejects; ship every + # other slot and rebuild the proxy from the canonical tuple on load. + return {f.name: getattr(self, f.name) + for f in dataclasses.fields(self) if f.name != "_cap_map"} + + def __setstate__(self, state: dict[str, object]) -> None: + # Fail at the unpickle site if the state comes from a different + # Lexicon field layout (version skew) -- silently loading it + # would defer the failure to some distant attribute read. + # Message kept in sync with _types._guarded_setstate by design + # (layering keeps this module import-free of _types). + expected = {f.name for f in dataclasses.fields(Lexicon)} - {"_cap_map"} + if set(state) != expected: + missing = ", ".join(sorted(expected - set(state))) or "none" + unexpected = ", ".join(sorted(set(state) - expected)) or "none" + raise ValueError( + f"incompatible Lexicon pickle: missing fields: {missing}; " + f"unexpected fields: {unexpected}" + ) + for name, value in state.items(): + object.__setattr__(self, name, value) + object.__setattr__( + self, "_cap_map", + MappingProxyType(dict(self.capitalization_exceptions))) + + def __or__(self, other: Lexicon) -> Lexicon: + if not isinstance(other, Lexicon): + return NotImplemented + updates: dict[str, object] = { + name: getattr(self, name) | getattr(other, name) + for name in _VOCAB_FIELDS + } + # right-biased on key conflicts, mirroring later-wins for scalars + merged = dict(self._cap_map) | dict(other._cap_map) + updates["capitalization_exceptions"] = tuple(sorted(merged.items())) + return dataclasses.replace(self, **updates) # type: ignore[arg-type] + + # -- properties ------------------------------------------------------- + + @property + def capitalization_exceptions_map(self) -> Mapping[str, str]: + return self._cap_map + + # -- editing ---------------------------------------------------------- + + def _edit(self, op: str, entries: Mapping[str, Iterable[str]]) -> Lexicon: + updates: dict[str, frozenset[str]] = {} + for name, words in entries.items(): + if name == "capitalization_exceptions": + raise TypeError( + "capitalization_exceptions holds key->value pairs; " + "use dataclasses.replace(lexicon, " + "capitalization_exceptions={...}) instead of " + f"{op}()" + ) + if name not in _VOCAB_FIELDS: + raise TypeError( + f"unknown Lexicon field {name!r}; valid fields: " + f"{', '.join(_VOCAB_FIELDS)}" + ) + current: frozenset[str] = getattr(self, name) + normalized = _normset(words, name) + updates[name] = (current | normalized if op == "add" + else current - normalized) + # mypy's dataclasses.replace() typing checks a **dict's single + # value type against every field's type (it can't see which keys + # are actually present behind the unpack), so a homogeneous + # frozenset[str] dict is flagged against the tuple/Mapping-typed + # capitalization_exceptions/_cap_map fields even though this dict + # never contains those keys (guarded above). + return dataclasses.replace(self, **updates) # type: ignore[arg-type] + + def add(self, **entries: Iterable[str]) -> Lexicon: + return self._edit("add", entries) + + def remove(self, **entries: Iterable[str]) -> Lexicon: + return self._edit("remove", entries) + + +@functools.cache +def _default_lexicon() -> Lexicon: + # v1 data modules are the single source of vocabulary through 2.x. + from nameparser.config.bound_first_names import BOUND_FIRST_NAMES + from nameparser.config.capitalization import CAPITALIZATION_EXCEPTIONS + from nameparser.config.conjunctions import CONJUNCTIONS + from nameparser.config.maiden_markers import MAIDEN_MARKERS + from nameparser.config.prefixes import NON_FIRST_NAME_PREFIXES, PREFIXES + from nameparser.config.suffixes import ( + SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_NOT_ACRONYMS, + ) + from nameparser.config.titles import FIRST_NAME_TITLES, TITLES + + # v1 data modules export plain `set[str]`; wrap each at this call site + # so the strictly-typed frozenset[str] fields never see a bare set. + # keep in sync with _config_shim.Constants._snapshot() (pinned by the + # default-Constants equality test in tests/v2/test_config_shim.py) + return Lexicon( + titles=frozenset(TITLES), + given_name_titles=frozenset(FIRST_NAME_TITLES), + suffix_acronyms=frozenset(SUFFIX_ACRONYMS), + suffix_words=frozenset(SUFFIX_NOT_ACRONYMS), + suffix_acronyms_ambiguous=frozenset(SUFFIX_ACRONYMS_AMBIGUOUS), + particles=frozenset(PREFIXES), + # FLIPPED from v1: v1 marks the never-given subset; v2 marks the + # may-be-given subset (migration: complement translation). + particles_ambiguous=frozenset(PREFIXES - NON_FIRST_NAME_PREFIXES), + conjunctions=frozenset(CONJUNCTIONS), + bound_given_names=frozenset(BOUND_FIRST_NAMES), + maiden_markers=frozenset(MAIDEN_MARKERS), + # pass canonical pair-tuples so this strictly-typed call site never + # feeds a Mapping to the tuple-annotated field; __post_init__ + # still tolerates a Mapping at runtime for interactive use + capitalization_exceptions=tuple(sorted(CAPITALIZATION_EXCEPTIONS.items())), + ) diff --git a/nameparser/_locale.py b/nameparser/_locale.py new file mode 100644 index 00000000..f22c3eb8 --- /dev/null +++ b/nameparser/_locale.py @@ -0,0 +1,81 @@ +"""The Locale type: a named delta over (Lexicon, Policy). + +A Locale dissolves at parser construction (parser_for, a later plan): +lexicon fragments union onto the base, the PolicyPatch folds via +apply_patch. Packs are pure data; they have no privileged capabilities. + +Layering: imports _lexicon and _policy only (enforced by +tests/v2/test_layering.py). +""" +from __future__ import annotations + +import dataclasses +import re +from dataclasses import dataclass + +from nameparser._lexicon import Lexicon +from nameparser._policy import UNSET, PolicyPatch +from nameparser._types import _guarded_getstate, _guarded_setstate + + +@dataclass(frozen=True, slots=True) +class Locale: + """A named, shareable bundle of vocabulary and behavior for a + naming tradition: a lexicon fragment plus a policy patch, applied + together by :func:`nameparser.parser_for`. The packs shipped with + nameparser live in :mod:`nameparser.locales`; building your own + needs no registration -- construct one and pass it to + ``parser_for``. See the :doc:`locale packs guide ` for + using, creating, and contributing packs.""" + + #: Identifier, lowercase ``[a-z0-9_]+`` (e.g. "ru", "tr_az"). + code: str + #: Vocabulary ADDED to the base parser's lexicon (unioned; a pack + #: never removes base vocabulary). + lexicon: Lexicon + #: Behavior changes folded onto the base policy (set-valued fields + #: union; scalars override, later pack wins). + policy: PolicyPatch = PolicyPatch() + + # in the class body so @dataclass(slots=True) keeps them + __getstate__ = _guarded_getstate + __setstate__ = _guarded_setstate + + def __post_init__(self) -> None: + if not isinstance(self.code, str): + raise TypeError( + f"Locale.code must be a str, got {self.code!r}" + ) + if not self.code.strip(): + raise ValueError( + f"Locale.code must be a non-empty string, got {self.code!r}" + ) + if self.code != self.code.lower(): + raise ValueError( + f"Locale.code must be lowercase, got {self.code!r}" + ) + # Codes are registry keys (parser_for, third-party packs): every + # accepted character is supported forever, so pin the charset + # while relaxing later is still compatible. One separator only -- + # allowing '-' too would make tr-az and tr_az distinct keys. + if not re.fullmatch(r"[a-z0-9_]+", self.code): + raise ValueError( + f"Locale.code must match [a-z0-9_]+, got {self.code!r}" + ) + if not isinstance(self.lexicon, Lexicon): + raise TypeError( + f"Locale.lexicon must be a Lexicon, got {self.lexicon!r}" + ) + if not isinstance(self.policy, PolicyPatch): + raise TypeError( + f"Locale.policy must be a PolicyPatch, got {self.policy!r}" + ) + + def __repr__(self) -> str: + # Bounded: shows the code and which Policy fields the patch sets, + # never the Lexicon contents or the patched values themselves + # (design rule, see nameparser._types module docstring). + patched = [f.name for f in dataclasses.fields(self.policy) + if getattr(self.policy, f.name) is not UNSET] + suffix = f": {', '.join(patched)}" if patched else "" + return f"Locale({self.code!r}{suffix})" diff --git a/nameparser/_parser.py b/nameparser/_parser.py new file mode 100644 index 00000000..93b6545a --- /dev/null +++ b/nameparser/_parser.py @@ -0,0 +1,130 @@ +"""Parser and the module-level parse() for the 2.0 API. + +Layering: sits on _types/_lexicon/_policy/_locale/_pipeline; never +imports _render or the v1 facade (enforced by tests/v2/test_layering.py). + +_default_parser is THE one sanctioned module-level global (conventions +§8): a functools.cache'd frozen Parser over default config. +""" +from __future__ import annotations + +import dataclasses +import functools +import warnings +from dataclasses import dataclass + +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._types import ParsedName, _guarded_getstate, _guarded_setstate + + +@dataclass(frozen=True, slots=True) +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 + 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.)""" + + lexicon: Lexicon = None # type: ignore[assignment] # None -> default() + policy: Policy = None # type: ignore[assignment] # None -> Policy() + + # in the class body so @dataclass(slots=True) keeps them + __getstate__ = _guarded_getstate + __setstate__ = _guarded_setstate + + def __post_init__(self) -> None: + if self.lexicon is None: + object.__setattr__(self, "lexicon", Lexicon.default()) + elif not isinstance(self.lexicon, Lexicon): + raise TypeError( + f"lexicon must be a Lexicon or None, got {self.lexicon!r}") + if self.policy is None: + object.__setattr__(self, "policy", Policy()) + elif not isinstance(self.policy, Policy): + raise TypeError( + f"policy must be a Policy or None, got {self.policy!r}") + + def __repr__(self) -> str: + # composes the two bounded component reprs (spec §2 reprs) + return f"Parser({self.lexicon!r}, {self.policy!r})" + + 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).""" + if isinstance(text, bytes): + raise TypeError( + "parse() takes str, not bytes -- decode first, e.g. " + "raw.decode('utf-8')") + if not isinstance(text, str): + raise TypeError(f"parse() takes str, got {text!r}") + state = ParseState(original=text, lexicon=self.lexicon, + policy=self.policy) + return assemble(run(state)) + + +@functools.cache +def _default_parser() -> Parser: + return Parser() + + +def parse(text: str) -> ParsedName: + """Parse a name with the default configuration and return a + :class:`ParsedName`. Equivalent to ``Parser().parse(text)``; build + your own :class:`Parser` (or use :func:`parser_for`) for custom + vocabulary or behavior. Never raises on string content.""" + return _default_parser().parse(text) + + +def parser_for(*locales: Locale, base: Parser | None = None) -> 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.""" + 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: + if not isinstance(loc, Locale): + 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() + scalar_setters: dict[str, str] = {} + for loc in locales: + for f in dataclasses.fields(PolicyPatch): + if f.metadata.get("compose") == "union": + continue + if getattr(loc.policy, f.name) is UNSET: + continue + if f.name in scalar_setters and scalar_setters[f.name] != loc.code: + warnings.warn( + f"locale {loc.code!r} overrides scalar policy field " + f"{f.name!r} already set by locale " + f"{scalar_setters[f.name]!r}; later wins", + UserWarning, stacklevel=2) + scalar_setters[f.name] = loc.code + try: + lexicon = lexicon | loc.lexicon + policy = apply_patch(policy, loc.policy) + except (TypeError, ValueError) as exc: + # safe: every raise in the apply path (Policy.__post_init__, + # Lexicon.__or__, apply_patch) is a PLAIN TypeError/ValueError -- + # 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) diff --git a/nameparser/_pipeline/__init__.py b/nameparser/_pipeline/__init__.py new file mode 100644 index 00000000..d9a3bfeb --- /dev/null +++ b/nameparser/_pipeline/__init__.py @@ -0,0 +1,35 @@ +"""The 2.0 parse pipeline: pure stages folded over ParseState. + +Stage names and ParseState are NOT public API (core spec §6). The stage +list is data; runners other than the plain fold (explain(), parse_all()) +arrive in 2.x minors without changing any stage signature. + +Layering: imports only nameparser._pipeline.* (enforced by +tests/v2/test_layering.py). +""" +from __future__ import annotations + +from collections.abc import Callable + +from nameparser._pipeline._assign import assign +from nameparser._pipeline._classify import classify +from nameparser._pipeline._extract import extract_delimited +from nameparser._pipeline._group import group +from nameparser._pipeline._post_rules import post_rules +from nameparser._pipeline._segment import segment +from nameparser._pipeline._state import ParseState +from nameparser._pipeline._tokenize import tokenize + +#: The full seven-stage fold (spec §6). +STAGES: tuple[Callable[[ParseState], ParseState], ...] = ( + extract_delimited, tokenize, segment, classify, group, assign, + post_rules, +) + + +def run(state: ParseState) -> ParseState: + """Fold the stages over the initial state. Pure: each stage returns + a new ParseState; content never raises (totality, spec §5a).""" + for stage in STAGES: + state = stage(state) + return state diff --git a/nameparser/_pipeline/_assemble.py b/nameparser/_pipeline/_assemble.py new file mode 100644 index 00000000..d3a6a169 --- /dev/null +++ b/nameparser/_pipeline/_assemble.py @@ -0,0 +1,41 @@ +"""Not a stage: converts the final ParseState into a public ParsedName. + +Consumes: tokens (all roles set), dropped, ambiguities (by index). +Produces: a validated ParsedName -- the constructor re-checks every +invariant (span order/bounds, ambiguity subset), so a pipeline bug +that would produce an invalid result dies HERE, not in a renderer +three layers away. + +Structural tokens (dropped maiden markers) are omitted, like delimiter +characters. A main-stream token that somehow reaches here with no role +takes Role.GIVEN -- parse is total over str (spec §5a) and must not +raise on content; the fallback is deliberately boring. +""" +from __future__ import annotations + +from nameparser._pipeline._state import ParseState +from nameparser._types import Ambiguity, ParsedName, Role, Token + + +def assemble(state: ParseState) -> ParsedName: + dropped = set(state.dropped) + final: dict[int, Token] = {} + for i, t in enumerate(state.tokens): + if i in dropped: + continue + role = t.role if t.role is not None else Role.GIVEN + final[i] = Token(t.text, t.span, role, t.tags) + ambiguities = [] + for pending in state.ambiguities: + materialized = tuple(final[i] for i in pending.indices + if i in final) + if pending.indices and not materialized: + # every referent was dropped: the ambiguity describes + # nothing that survives assembly. Born-empty ambiguities + # (unbalanced delimiters) are token-independent and kept. + continue + ambiguities.append( + Ambiguity(pending.kind, pending.detail, materialized)) + return ParsedName(original=state.original, + tokens=tuple(final.values()), + ambiguities=tuple(ambiguities)) diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py new file mode 100644 index 00000000..1a3d95c7 --- /dev/null +++ b/nameparser/_pipeline/_assign.py @@ -0,0 +1,229 @@ +"""Stage: assign. + +Consumes: pieces + piece_tags (grouped), segments, structure, tokens. +Produces: tokens with roles set on every main-stream token. +Reads: Policy.name_order (#270); 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 +(a title needs a following piece, unless the whole name is one title); +then positional assignment per name_order with the trailing-suffix +rule: the piece from which everything after is a strict suffix is the +last name-position piece, the rest are suffixes. The v1 single-name+ +nickname rule lives here (plan deviation #2): one non-title piece plus +a nonempty nickname puts that piece in FAMILY. +FAMILY_COMMA: segment 0 wholly FAMILY (v1 parity); segment 1 gets +leading titles, then given, then middles with strict-suffix pieces to +suffix; segments 2+ are suffixes (lenient -- segment already flagged +non-suffixy ones COMMA_STRUCTURE). +SUFFIX_COMMA: segment 0 as NO_COMMA; segments 1+ wholly SUFFIX. +Emits PARTICLE_OR_GIVEN when the leading name piece is a lone +particles_ambiguous token with more pieces following ("Van Johnson") -- +whatever role name_order assigns that position. +""" +from __future__ import annotations + +import dataclasses +import re + +from nameparser._pipeline._vocab import is_suffix_lenient +from nameparser._pipeline._group import ( + _is_suffix_piece, _is_title_piece, +) +from nameparser._pipeline._state import ( + ParseState, PendingAmbiguity, Structure, WorkToken, +) +from nameparser._types import AmbiguityKind, Role + +# Ported verbatim from v1 (nameparser/config/regexes.py +# "period_abbreviation" and "roman_numeral") -- layering forbids the +# config import; keep in sync by hand. +_PERIOD_ABBREV = re.compile(r'^[^\W\d_]{2,}\.$') +_ROMAN = re.compile(r'^(X|IX|IV|V?I{0,3})$', re.I) + + +def _set_roles(tokens: list[WorkToken], piece: tuple[int, ...], + role: Role) -> None: + for i in piece: + tokens[i] = dataclasses.replace(tokens[i], role=role) + + +def _is_leading_title(piece: tuple[int, ...], ptags: frozenset[str], + tokens: list[WorkToken]) -> bool: + if _is_title_piece(piece, ptags, tokens): + return True + return (len(piece) == 1 + and bool(_PERIOD_ABBREV.match(tokens[piece[0]].text))) + + +def _peel_leading_titles(pieces: tuple[tuple[int, ...], ...], + ptags: tuple[frozenset[str], ...], + tokens: list[WorkToken]) -> int: + """Assign TITLE to the leading title pieces and return the first + non-title index. A title needs a following piece, unless the whole + segment is one title (v1 parity).""" + n = 0 + while n < len(pieces): + if ((n + 1 < len(pieces) or len(pieces) == 1) + and _is_leading_title(pieces[n], ptags[n], tokens)): + _set_roles(tokens, pieces[n], Role.TITLE) + n += 1 + continue + break + return n + + +def _name_positions(order: tuple[Role, Role, Role], + count: int) -> list[Role]: + """Roles for `count` name pieces (titles/suffixes already peeled), + per name_order. GIVEN_FIRST: given, middles..., family. + FAMILY_FIRST: family, given, middles... FAMILY_FIRST_GIVEN_LAST: + family, middles..., given. One piece takes order[0]'s role + (spec §5a); two pieces take order[0] and the other primary.""" + first, second = order[0], order[1] + if count == 1: + return [first] + if first is Role.GIVEN: # GIVEN_FIRST + return ([Role.GIVEN] + [Role.MIDDLE] * (count - 2) + + [Role.FAMILY]) + if second is Role.GIVEN: # FAMILY_FIRST + return ([Role.FAMILY, Role.GIVEN] + + [Role.MIDDLE] * (count - 2)) + return ([Role.FAMILY] + [Role.MIDDLE] * (count - 2) # F_F_GIVEN_LAST + + [Role.GIVEN]) + + +def _assign_main(seg_idx: int, state: ParseState, + tokens: list[WorkToken], + ambiguities: list[PendingAmbiguity]) -> None: + pieces = state.pieces[seg_idx] + ptags = state.piece_tags[seg_idx] + has_nickname = any(t.role is Role.NICKNAME for t in tokens) + n = _peel_leading_titles(pieces, ptags, tokens) + rest = list(range(n, len(pieces))) + if not rest: + return + # group-flagged suffix pieces (the ph-d merge) are suffixes at ANY + # position -- v1's fix_phd extracted the credential from the string + # before parsing, so position never mattered (PR review I3) + flagged = [k for k in rest if "suffix" in ptags[k]] + for k in flagged: + _set_roles(tokens, pieces[k], Role.SUFFIX) + rest = [k for k in rest if "suffix" not in ptags[k]] + if not rest: + return + # v1 nickname rule (plan deviation #2): v1's p_len == 1 counted + # the WHOLE segment before any title peeling (parser.py:1285) -- + # 'Xyz. (Bud) Smith' has two pieces, so the title peel wins and + # Smith stays the given name (pinned live 2026-07-17) + if len(pieces) == 1 and len(rest) == 1 and has_nickname: + _set_roles(tokens, pieces[rest[0]], Role.FAMILY) + return + # peel the trailing suffix run: k = first index in rest from which + # every piece is a strict suffix (v1's are_suffixes tail rule, with + # the roman-numeral special: a final roman numeral after a + # non-initial piece is a suffix) + k = len(rest) + while k > 0: + piece = pieces[rest[k - 1]] + tags = ptags[rest[k - 1]] + if _is_suffix_piece(piece, tags, tokens): + k -= 1 + continue + if (k == len(rest) and k >= 2 and len(piece) == 1 + and _ROMAN.match(tokens[piece[0]].text) + and "initial" not in tokens[pieces[rest[k - 2]][0]].tags): + k -= 1 + continue + break + name_pieces, suffix_pieces = rest[:k], rest[k:] + if not name_pieces and suffix_pieces: + # everything suffix-shaped after titles: first one is the name + name_pieces, suffix_pieces = suffix_pieces[:1], suffix_pieces[1:] + roles = _name_positions(state.policy.name_order, len(name_pieces)) + for pos, piece_idx in enumerate(name_pieces): + _set_roles(tokens, pieces[piece_idx], roles[pos]) + for piece_idx in suffix_pieces: + _set_roles(tokens, pieces[piece_idx], Role.SUFFIX) + # leading ambiguous particle read as a name (#121 surfaced) + if name_pieces: + head = pieces[name_pieces[0]] + if (len(head) == 1 and len(name_pieces) > 1 + and "vocab:particle-ambiguous" in tokens[head[0]].tags): + ambiguities.append(PendingAmbiguity( + AmbiguityKind.PARTICLE_OR_GIVEN, + f"leading {tokens[head[0]].text!r} may be a family-name " + f"particle; read as a given name", + tuple(head))) + + +def assign(state: ParseState) -> ParseState: + tokens = list(state.tokens) + ambiguities = list(state.ambiguities) + if not state.segments: + return state + if state.structure is Structure.NO_COMMA: + _assign_main(0, state, tokens, ambiguities) + tail = len(state.segments) + elif state.structure is Structure.SUFFIX_COMMA: + _assign_main(0, state, tokens, ambiguities) + tail = 1 + else: # FAMILY_COMMA + # PARTICLE_OR_GIVEN is deliberately not emitted here: after a + # comma the family is already fixed, so a leading given-position + # particle is not meaningfully ambiguous. + # v1: "lastname part may have suffixes in it" (parser.py:1368) + # -- the first piece is always the family even if suffix-shaped; + # any later strict-suffix piece goes to SUFFIX per piece + # ('Smith Jr., John' -> family=Smith, suffix=Jr.) + fam_pieces = state.pieces[0] + fam_tags = state.piece_tags[0] + for k, piece in enumerate(fam_pieces): + if k > 0 and _is_suffix_piece(piece, fam_tags[k], tokens): + _set_roles(tokens, piece, Role.SUFFIX) + else: + _set_roles(tokens, piece, Role.FAMILY) + if len(state.segments) > 1: + pieces = state.pieces[1] + ptags = state.piece_tags[1] + n = _peel_leading_titles(pieces, ptags, tokens) + given_done = False + for m in range(n, len(pieces)): + # v1 walk order (parser.py:1390): the first non-title + # piece is ALWAYS the given, before any suffix check -- + # 'Hardman, RN - CRNA' keeps first='RN'. One deliberate + # 2.0 deviation, classified fix(comma-family): when that + # piece is the segment's ONLY piece and unambiguously + # suffix-shaped ('Andrews, M.D.'), it is a suffix -- v1 + # made it the given. + if not given_done: + if (m == len(pieces) - 1 + and _is_suffix_piece(pieces[m], ptags[m], + tokens)): + _set_roles(tokens, pieces[m], Role.SUFFIX) + continue + _set_roles(tokens, pieces[m], Role.GIVEN) + given_done = True + continue + # trailing piece of a two-part name is unambiguously + # positioned: v1 accepts the lenient test there + # ('Smith, John V' -> suffix='V', #144); with a third + # comma part the trailing token is more likely a middle + # initial, so strict only + last_of_two = (m == len(pieces) - 1 + and len(state.segments) == 2) + if _is_suffix_piece(pieces[m], ptags[m], tokens) or ( + last_of_two and len(pieces[m]) == 1 + and is_suffix_lenient( + tokens[pieces[m][0]].text, state.lexicon)): + _set_roles(tokens, pieces[m], Role.SUFFIX) + else: + _set_roles(tokens, pieces[m], Role.MIDDLE) + tail = 2 + # segments past the structure's name segments are wholly suffixes + for seg_idx in range(tail, len(state.segments)): + for piece in state.pieces[seg_idx]: + _set_roles(tokens, piece, Role.SUFFIX) + return dataclasses.replace(state, tokens=tuple(tokens), + ambiguities=tuple(ambiguities)) diff --git a/nameparser/_pipeline/_classify.py b/nameparser/_pipeline/_classify.py new file mode 100644 index 00000000..317be8f4 --- /dev/null +++ b/nameparser/_pipeline/_classify.py @@ -0,0 +1,76 @@ +"""Stage: classify. + +Consumes: tokens. +Produces: tokens with vocabulary tags added (text/span/role unchanged). +Reads: every Lexicon vocabulary field; Policy is not consulted. + +Tags emitted -- stable (API): "particle", "conjunction", "initial"; +namespaced (unstable): "vocab:title", "vocab:given-title", +"vocab:suffix", "vocab:suffix-word", "vocab:suffix-ambiguous", +"vocab:particle-ambiguous", "vocab:bound-given", "vocab:maiden-marker". +"vocab:suffix" means "counts as a suffix as written": unambiguous +suffix vocabulary, or an ambiguous acronym written with periods +('M.A.' yes, 'Ma' no -- 'Ma' gets only "vocab:suffix-ambiguous"). +The initial veto is assign's job, not classify's: 'V' carries both +"vocab:suffix" and "initial". +""" +from __future__ import annotations + +import dataclasses + +from nameparser._lexicon import _normalize +from nameparser._pipeline._state import ParseState, WorkToken +from nameparser._pipeline._vocab import ( + is_initial, period_joined_vocab, suffix_as_written, +) + + + + +def _tags_for(token: WorkToken, state: ParseState) -> frozenset[str]: + lex = state.lexicon + n = _normalize(token.text) + tags = set(token.tags) + if n in lex.titles: + tags.add("vocab:title") + if n in lex.given_name_titles: + tags.add("vocab:given-title") + if suffix_as_written(n, token.text, lex): + tags.add("vocab:suffix") + if n in lex.suffix_words: + tags.add("vocab:suffix-word") + if n in lex.suffix_acronyms_ambiguous: + tags.add("vocab:suffix-ambiguous") + if n in lex.particles: + tags.add("particle") + if n in lex.particles_ambiguous: + tags.add("vocab:particle-ambiguous") + if n in lex.conjunctions and not is_initial(token.text): + # v1's is_conjunction excludes initials: 'e.' in 'john e. smith' + # is a middle initial, not the Spanish conjunction 'e' + tags.add("conjunction") + if n in lex.bound_given_names: + tags.add("vocab:bound-given") + if n in lex.maiden_markers: + tags.add("vocab:maiden-marker") + if is_initial(token.text): + tags.add("initial") + # v1's period-joined derivation (parse_pieces): a token with a + # period not at the end, ANY of whose period chunks is a title, is + # a title as a whole ('Lt.Gov.', and by the ANY rule 'Mr.Smith'); + # else ANY suffix chunk makes it a suffix ('JD.CPA'). Title wins + # (v1's continue). Skipped when the whole token already matched. + if "vocab:title" not in tags and "vocab:suffix" not in tags: + derived = period_joined_vocab(token.text, lex) + if derived == "title": + tags.add("vocab:title") + elif derived == "suffix": + tags.add("vocab:suffix") + return frozenset(tags) + + +def classify(state: ParseState) -> ParseState: + tokens = tuple( + dataclasses.replace(t, tags=_tags_for(t, state)) + for t in state.tokens) + return dataclasses.replace(state, tokens=tokens) diff --git a/nameparser/_pipeline/_extract.py b/nameparser/_pipeline/_extract.py new file mode 100644 index 00000000..634fb233 --- /dev/null +++ b/nameparser/_pipeline/_extract.py @@ -0,0 +1,182 @@ +"""Stage: extract_delimited. + +Consumes: ParseState.original. +Produces: extracted (role + inner span per delimited region), masked +(full regions incl. delimiter chars, skipped by tokenize), +UNBALANCED_DELIMITER ambiguities for opens with no close. +Reads: Policy.nickname_delimiters, Policy.maiden_delimiters. + +Matching rules (the #273 mechanism): one left-to-right scan over the +original text, no nesting. At each position the LEFTMOST boundary-valid +opener among ALL configured pairs wins -- position order, never pair +order, decides between conventions that share a character in opposite +roles ('“' closes „…“ but opens “…”; '»' closes «…» but opens »…«), so +"Hans „Erster“ und “Zweiter” Müller" extracts both names. For pairs +whose open == close (quotes), the open must sit at a word boundary +(start of text or after whitespace) and the close before one (end, +whitespace, or a comma char) -- this is what keeps the apostrophe in +O'Connor literal. Empty enclosures are masked (removed from the token +stream) but extract nothing; delimiter characters inside a matched +region are literal content for every other pair. + +Bucket precedence is NOT decided here: Policy canonicalizes overlap +away before parsing (a pair listed in maiden_delimiters is dropped +from the effective nickname set -- maiden wins; the v1 facade restores +v1's nickname-wins reading via a pre-subtraction in _config_shim), so +the two buckets are always disjoint by the time this stage runs. The +nickname-before-maiden candidate order below is only a same-position +tie-break for exotic configs where two pairs share an OPEN character. +""" +from __future__ import annotations + +import dataclasses +import functools + +from nameparser._lexicon import Lexicon, _normalize +from nameparser._pipeline._state import ( + COMMA_CHARS, ParseState, PendingAmbiguity, +) +from nameparser._types import AmbiguityKind, Role, Span + + +def _suffix_shaped(content: str, lexicon: Lexicon) -> bool: + """v1 parse_nicknames' escape (parser.py:1125-1141): an unambiguous + suffix_words member (edge-normalized), an unambiguous acronym + (period-free form), or anything ending in a period. No initial + veto -- v1 deliberately skipped it here.""" + stripped = _normalize(content) + acronym = stripped.replace(".", "") + return (stripped in lexicon.suffix_words + or (acronym in lexicon.suffix_acronyms + and acronym not in lexicon.suffix_acronyms_ambiguous) + or content.endswith(".")) + + +def _open_ok(text: str, i: int) -> bool: + return i == 0 or text[i - 1].isspace() + + +def _close_ok(text: str, j: int, width: int) -> bool: + k = j + width + return k >= len(text) or text[k].isspace() or text[k] in COMMA_CHARS + + +def _overlaps(span: Span, taken: list[Span]) -> bool: + return any(span.start < t.end and t.start < span.end for t in taken) + + +@functools.lru_cache(maxsize=128) +def _delimiter_chars( + nickname_pairs: frozenset[tuple[str, str]], + maiden_pairs: frozenset[tuple[str, str]], +) -> frozenset[str]: + """Every character appearing in any configured delimiter, cached on + the (hashable) policy frozensets: the common no-delimiter name pays + one isdisjoint() instead of a per-pair scan.""" + return frozenset( + ch + for pairs in (nickname_pairs, maiden_pairs) + for pair in pairs + for part in pair + for ch in part + ) + + +def _unmatched(open_: str, offset: int) -> tuple[int, PendingAmbiguity]: + return (offset, PendingAmbiguity( + AmbiguityKind.UNBALANCED_DELIMITER, + f"unmatched {open_!r} at offset {offset}; treated as literal text", + )) + + +def extract_delimited(state: ParseState) -> ParseState: + text = state.original + policy = state.policy + if _delimiter_chars(policy.nickname_delimiters, + policy.maiden_delimiters).isdisjoint(text): + return state + # Candidate order matters only as a same-position tie-break (see + # module docstring); the scan itself is position-driven. + order = tuple( + (role, open_, close) + for role, pairs in ((Role.NICKNAME, policy.nickname_delimiters), + (Role.MAIDEN, policy.maiden_delimiters)) + for open_, close in sorted(pairs) + ) + extracted: list[tuple[Role, Span]] = [] + masked: list[Span] = [] + # candidates, not final: each carries the offset of the unmatched + # open so ones consumed by a later match can be filtered at the end + unbalanced: list[tuple[int, PendingAmbiguity]] = [] + # per-candidate cursor cache: next boundary-valid open at or after + # the position it was computed for. find() calls only ever move + # forward, keeping the whole scan linear in len(text) per pair. + cursors: dict[tuple[Role, str, str], int] = {} + exhausted: set[tuple[Role, str, str]] = set() + pos = 0 + while pos < len(text): + best: tuple[int, Role, str, str] | None = None + for key in order: + if key in exhausted: + continue + _, open_, close = key + i = cursors.get(key, -2) + if i != -1 and i < pos: + i = text.find(open_, pos) + while (i != -1 and open_ == close + and not _open_ok(text, i)): + i = text.find(open_, i + 1) + cursors[key] = i + if i != -1 and (best is None or i < best[0]): + best = (i, *key) + if best is None: + break + i, role, open_, close = best + j = text.find(close, i + len(open_)) + while (open_ == close and j != -1 + and not _close_ok(text, j, len(close))): + j = text.find(close, j + 1) + if j == -1: + # No (boundary-valid) close exists anywhere to the right -- + # the walk above ran to end of text -- so every remaining + # open of this pair is unmatched too. Record them all in + # one forward pass and retire the pair; other pairs keep + # scanning from the same position. + unbalanced.append(_unmatched(open_, i)) + scan = i + len(open_) + while (k := text.find(open_, scan)) != -1: + if open_ != close or _open_ok(text, k): + unbalanced.append(_unmatched(open_, k)) + scan = k + 1 + exhausted.add((role, open_, close)) + continue + inner = Span(i + len(open_), j) + if inner.start < inner.end and _suffix_shaped( + text[inner.start:inner.end], state.lexicon): + # v1 parse_nicknames: suffix-shaped delimited content is + # left IN PLACE (undelimited) for normal downstream parsing + # -- 'Andrew Perkins (MBA)' keeps MBA a suffix, not a + # nickname. Spans index the original (anti-#100), so the v2 + # spelling masks only the two delimiter spans and lets the + # inner content join the main token stream. + masked.append(Span(i, i + len(open_))) + masked.append(Span(j, j + len(close))) + else: + if inner.start < inner.end: + extracted.append((role, inner)) + masked.append(Span(i, j + len(close))) + # position-driven scanning makes overlapping matches + # impossible by construction: every later open is found at or + # after this match's end + pos = j + len(close) + extracted.sort(key=lambda pair: pair[1]) + masked.sort() + # An unmatched-open candidate whose character was consumed by a + # later successful match (the bulk pass above runs ahead of the + # main scan) is literal content there, not a dangling delimiter. + ambiguities = tuple( + a for offset, a in unbalanced + if not _overlaps(Span(offset, offset + 1), masked)) + return dataclasses.replace( + state, extracted=tuple(extracted), masked=tuple(masked), + ambiguities=state.ambiguities + ambiguities) diff --git a/nameparser/_pipeline/_group.py b/nameparser/_pipeline/_group.py new file mode 100644 index 00000000..675a88ac --- /dev/null +++ b/nameparser/_pipeline/_group.py @@ -0,0 +1,277 @@ +"""Stage: group. + +Consumes: tokens (classified), segments, structure. +Produces: pieces + piece_tags per segment (runs of token indices -- +tokens are NEVER joined into strings: the anti-#100 invariant); maiden +tail tokens get role=MAIDEN; marker tokens land in dropped. +Reads: token tags (from classify), and Policy.extra_suffix_delimiters +for the tail-segment handling below -- no other Policy field. The v1 +"derived titles/prefixes" registration becomes piece_tags entries -- +per-parse state that dissolves with the state (v1 kept per-parse sets +for the same reason). Reads Policy.extra_suffix_delimiters: tail +segments drop delimiter-core tokens (v1 suffix_delimiter parity). + +Ports v1's join_on_conjunctions + prefix chains + _join_bound_first_name +plus two additions: the "Ph. D."-split merge (v1 fix_phd, recorded plan +deviation #1) and the maiden-marker consuming rule (#274: marker plus +following pieces until a suffix become maiden; the marker itself is +structural, like a delimiter char, and is dropped from assembly). +""" +from __future__ import annotations + +import dataclasses +from collections.abc import Sequence, Set +from enum import IntEnum + +from nameparser._pipeline._state import ParseState, Structure, WorkToken +from nameparser._pipeline._vocab import D as _D +from nameparser._pipeline._vocab import PH as _PH +from nameparser._pipeline._vocab import delimiter_cores +from nameparser._types import Role + +# the credential-pair regexes live in _vocab (shared with segment) + +Piece = list[int] + + +class BoundJoin(IntEnum): + """v1 _join_bound_first_name's reserve_last, as the three states it + actually has. IntEnum: the value IS the non_suffix threshold, so + the >= comparison below reads unchanged.""" + + DISABLED = 0 # the FAMILY_COMMA family segment (v1 never joined it) + LENIENT = 2 # FAMILY_COMMA's post-comma segment (reserve_last=False) + STRICT = 3 # main segments (reserve_last=True: keep a family piece) + + +def _is_title_piece(piece: Sequence[int], ptags: Set[str], + tokens: Sequence[WorkToken]) -> bool: + if "title" in ptags: + return True + return len(piece) == 1 and "vocab:title" in tokens[piece[0]].tags + + +def _is_prefix_piece(piece: Sequence[int], ptags: Set[str], + tokens: Sequence[WorkToken]) -> bool: + if "prefix" in ptags: + return True + return len(piece) == 1 and "particle" in tokens[piece[0]].tags + + +def _is_suffix_piece(piece: Sequence[int], ptags: Set[str], + tokens: Sequence[WorkToken]) -> bool: + if "suffix" in ptags: + return True + if len(piece) != 1: + return False + tags = tokens[piece[0]].tags + return "vocab:suffix" in tags and "initial" not in tags + + +def _is_conj_piece(piece: Sequence[int], ptags: Set[str], + tokens: Sequence[WorkToken]) -> bool: + if "conjunction" in ptags: + return True + return len(piece) == 1 and "conjunction" in tokens[piece[0]].tags + + +def _is_rootname(piece: Sequence[int], ptags: Set[str], + tokens: Sequence[WorkToken]) -> bool: + if len(piece) == 1 and "initial" in tokens[piece[0]].tags: + return False + return not (_is_title_piece(piece, ptags, tokens) + or _is_prefix_piece(piece, ptags, tokens) + or _is_suffix_piece(piece, ptags, tokens)) + + +def _group_segment(seg: tuple[int, ...], additional: int, + tokens: Sequence[WorkToken], + bound_join: BoundJoin = BoundJoin.STRICT, + ) -> tuple[list[Piece], list[set[str]]]: + pieces: list[Piece] = [[i] for i in seg] + ptags: list[set[str]] = [set() for _ in seg] + + def title(k: int) -> bool: + return _is_title_piece(pieces[k], ptags[k], tokens) + + def prefix(k: int) -> bool: + return _is_prefix_piece(pieces[k], ptags[k], tokens) + + def suffix(k: int) -> bool: + return _is_suffix_piece(pieces[k], ptags[k], tokens) + + def conj(k: int) -> bool: + return _is_conj_piece(pieces[k], ptags[k], tokens) + + def merge(lo: int, hi: int, add: Set[str] = frozenset(), + drop: Set[str] = frozenset()) -> None: + # pieces/ptags are parallel arrays; every merge must update + # both in lockstep + pieces[lo:hi] = [[i for piece in pieces[lo:hi] for i in piece]] + ptags[lo:hi] = [(set().union(*ptags[lo:hi]) | add) - drop] + + # ph-d merge first: "Ph." "D." adjacent -> one suffix piece (plan + # deviation #1; v1 fix_phd did this by regex on the raw string) + k = 0 + while k < len(pieces) - 1: + a, b = pieces[k], pieces[k + 1] + if (len(a) == 1 and len(b) == 1 + and _PH.fullmatch(tokens[a[0]].text) + and _D.fullmatch(tokens[b[0]].text)): + merge(k, k + 2, add={"suffix"}) + else: + k += 1 + + if len(pieces) + additional >= 3: + total = sum(_is_rootname(p, t, tokens) + for p, t in zip(pieces, ptags)) + additional + # contiguous conjunction runs merge first (v1: "of the") + k = 0 + while k < len(pieces) - 1: + if conj(k) and conj(k + 1): + merge(k, k + 2, add={"conjunction"}) + else: + k += 1 + # each conjunction joins its neighbors (v1's Google Code issue 11 + # carve-out, the "john e smith" bug: + # a single-letter alphabetic conjunction in a short name is more + # likely an initial) + k = 0 + while k < len(pieces): + if not conj(k): + k += 1 + continue + text = " ".join(tokens[i].text for i in pieces[k]) + if len(text) == 1 and total < 4 and text.isalpha(): + k += 1 + continue + start = max(0, k - 1) + end = min(len(pieces), k + 2) + neighbor = start if start < k else end - 1 + derived = set() + if title(neighbor): + derived.add("title") + if prefix(neighbor): + derived.add("prefix") + merge(start, end, add=derived) + k = start + 1 + # prefix chains: a non-leading prefix run absorbs everything to + # the next prefix or suffix (v1's leading_first_name rule keeps + # the first piece a name: "Van Johnson") + k = 0 + while k < len(pieces): + if k == 0 or not prefix(k): + k += 1 + continue + j = k + 1 + while j < len(pieces) and prefix(j): + j += 1 + while j < len(pieces) and not prefix(j) and not suffix(j): + j += 1 + merge(k, j, drop={"prefix"}) + k += 1 + # bound given names: the first non-title piece joins the next + # ONCE (pairwise, v1 parity: 'Salem, Abdul Rahman Ahmed' keeps + # Ahmed a middle name). BoundJoin encodes v1's reserve_last. + first_name_k = next( + (k for k in range(len(pieces)) if not title(k)), None) + if (bound_join is not BoundJoin.DISABLED + and first_name_k is not None + and first_name_k + 1 < len(pieces) + and len(pieces[first_name_k]) == 1 + and "vocab:bound-given" + in tokens[pieces[first_name_k][0]].tags): + non_suffix = sum(1 for k in range(len(pieces)) + if not title(k) and not suffix(k)) + if non_suffix >= bound_join: + merge(first_name_k, first_name_k + 2) + return pieces, ptags + + +def group(state: ParseState) -> ParseState: + tokens = list(state.tokens) + dropped = list(state.dropped) + all_pieces: list[tuple[tuple[int, ...], ...]] = [] + all_ptags: list[tuple[frozenset[str], ...]] = [] + # v1 parity: additional_parts_count=1 applies only to FAMILY_COMMA + # parts (parser.py:1313, 1369); the SUFFIX_COMMA pre-comma segment + # gets 0 (parser.py:1333). + additional = 1 if state.structure is Structure.FAMILY_COMMA else 0 + # v1 expand_suffix_delimiter parity (#191): tail segments (wholly + # consumed as suffixes by assign) drop delimiter-core tokens, the + # same structural mechanism as the maiden marker below + cores = delimiter_cores(state.policy.extra_suffix_delimiters) + tail_start = {Structure.SUFFIX_COMMA: 1, + Structure.FAMILY_COMMA: 2}.get(state.structure) + family_comma = state.structure is Structure.FAMILY_COMMA + for seg_idx, seg in enumerate(state.segments): + if family_comma: + bound_join = (BoundJoin.LENIENT if seg_idx == 1 + else BoundJoin.DISABLED) + else: + bound_join = BoundJoin.STRICT + pieces, ptags = _group_segment(seg, additional, tokens, + bound_join) + if tail_start is not None and seg_idx >= tail_start: + # v1 renders each tail COMMA SEGMENT as one suffix entry + # ('Smith, V MD' -> suffix 'V MD'); a delimiter core inside + # a segment separates entries and is dropped, but a segment + # that IS only the core stays whole (v1 expand() splits + # within a part, never erases a lone part). Continuation + # tokens within an entry take the stable "joined" tag so + # the suffix view space-joins them (the fix_phd mechanism). + entry_open = False + kept: list[int] = [] + for k in range(len(pieces)): + is_core = (len(pieces[k]) == 1 + and tokens[pieces[k][0]].text in cores + and len(pieces) > 1) + if is_core: + dropped.extend(pieces[k]) + entry_open = False + continue + kept.append(k) + for pos, i in enumerate(pieces[k]): + if entry_open or pos > 0: + tokens[i] = dataclasses.replace( + tokens[i], tags=tokens[i].tags | {"joined"}) + # piece-level state: the NEXT piece continues this entry + entry_open = True + if len(kept) != len(pieces): + pieces = [pieces[k] for k in kept] + ptags = [ptags[k] for k in kept] + # continuation tokens of a suffix-merged piece (the ph-d merge) + # carry the stable "joined" tag: the suffix string view joins + # SUFFIX tokens with ", ", and the tag lets it heal the split + for piece, piece_tags_ in zip(pieces, ptags): + if "suffix" in piece_tags_ and len(piece) > 1: + for i in piece[1:]: + tokens[i] = dataclasses.replace( + tokens[i], tags=tokens[i].tags | {"joined"}) + # maiden markers: a non-leading marker piece consumes following + # pieces until a suffix; consumed tokens become MAIDEN, the + # marker is dropped (#274) + m = next( + (k for k in range(1, len(pieces)) + if len(pieces[k]) == 1 + and "vocab:maiden-marker" in tokens[pieces[k][0]].tags), + None) + if m is not None: + j = m + 1 + consumed: list[int] = [] + while j < len(pieces) and not _is_suffix_piece( + pieces[j], ptags[j], tokens): + consumed.extend(pieces[j]) + j += 1 + if consumed: + dropped.extend(pieces[m]) + for i in consumed: + tokens[i] = dataclasses.replace( + tokens[i], role=Role.MAIDEN) + pieces[m:j] = [] + ptags[m:j] = [] + all_pieces.append(tuple(tuple(p) for p in pieces)) + all_ptags.append(tuple(frozenset(t) for t in ptags)) + return dataclasses.replace( + state, tokens=tuple(tokens), pieces=tuple(all_pieces), + piece_tags=tuple(all_ptags), dropped=tuple(dropped)) diff --git a/nameparser/_pipeline/_post_rules.py b/nameparser/_pipeline/_post_rules.py new file mode 100644 index 00000000..cca58918 --- /dev/null +++ b/nameparser/_pipeline/_post_rules.py @@ -0,0 +1,129 @@ +"""Stage: post_rules. + +Consumes: tokens (roles assigned). +Produces: tokens with roles adjusted by the post rules. +Reads: Policy.patronymic_rules; Lexicon.given_name_titles. + +Rules (each a small pure function over the role-bearing tokens): +1. v1 handle_firstnames: when the parse is exactly a title plus ONE + given token (no other roles), and the title is not a given-name + title ('Sir'), that token is a family name -- "Mr. Johnson". +2. EAST_SLAVIC (opt-in): positional GIVEN/MIDDLE/FAMILY each exactly + one token, the FAMILY-position token carries an East Slavic + patronymic ending, and the MIDDLE-position token does NOT (given + + patronymic + patronymic-derived surname like Abramovich must not + rotate) -> rotate: given<-old MIDDLE, middle<-old FAMILY (the + patronymic), family<-old GIVEN (v1 parity, pinned live 2026-07-12). +3. TURKIC (opt-in): exactly 1 GIVEN + 2 MIDDLE + 1 FAMILY tokens and + the FAMILY-position token is a standalone Turkic marker -> + given<-first MIDDLE, middle<-(second MIDDLE, marker), family<-old + GIVEN. + +Both rotations fire only on Structure.NO_COMMA (v1 gates them on +`not self._had_comma`): a comma already established the family. + +These rules reconstruct token POSITION from roles, which is faithful +to v1 only under the default GIVEN_FIRST order; their interaction with +other name_order values is an open design question for the locale-pack +work (#270). +""" +from __future__ import annotations + +import dataclasses +import re + +from nameparser._lexicon import _normalize +from nameparser._pipeline._state import ParseState, Structure, WorkToken +from nameparser._policy import PatronymicRule +from nameparser._types import FOLDED_TAG, Role + +# Ported verbatim from v1 (nameparser/config/regexes.py) -- layering +# forbids the config import; keep in sync by hand. +_EAST_SLAVIC = re.compile( + r"(ovich|ovna|evich|evna|ichna|ilyich|kuzmich|lukich|fomich|fokich)$", + re.I) +_EAST_SLAVIC_CYR = re.compile( + r"(ович|овна|евич|евна|ична|ильич|кузьмич|лукич|фомич|фокич)$", + re.I) +_TURKIC = re.compile( + r"^(oglu|oğlu|ogly|ogli|o['’ʻ]g['’ʻ]li" + r"|qizi|qızı|kizi|kyzy|gyzy|uly|uulu)$", re.I) +_TURKIC_CYR = re.compile( + r"^(оглу|оглы|оғлу|ўғли|угли|кызы|гызы|қызы|қизи|улы|ұлы|уулу)$", re.I) + + +def _idx(tokens: list[WorkToken], role: Role) -> list[int]: + return [i for i, t in enumerate(tokens) if t.role is role] + + +def _retag(tokens: list[WorkToken], i: int, role: Role) -> None: + tokens[i] = dataclasses.replace(tokens[i], role=role) + + +def post_rules(state: ParseState) -> ParseState: + tokens = list(state.tokens) + titles = _idx(tokens, Role.TITLE) + givens = _idx(tokens, Role.GIVEN) + middles = _idx(tokens, Role.MIDDLE) + families = _idx(tokens, Role.FAMILY) + others = any(t.role in (Role.SUFFIX, Role.NICKNAME, Role.MAIDEN) + for t in tokens) + + # rule 1: title + lone given -> family (v1 handle_firstnames) + if titles and givens and not middles and not families and not others: + joined = " ".join(_normalize(tokens[i].text) for i in titles) + if joined not in state.lexicon.given_name_titles: + for i in givens: + _retag(tokens, i, Role.FAMILY) + + # rule 1b: a leading particle that is NEVER a given name means the + # whole name is a surname -- fold given (and middles) into family + # (v1 handle_non_first_name_prefix; 'de la Vega' -> family, while + # ambiguous 'van Gogh' keeps the given reading). The middle/family + # guard leaves a degenerate bare 'de' as given rather than + # inventing a surname. + if len(givens) == 1 and (middles or families): + gtags = tokens[givens[0]].tags + if "particle" in gtags and "vocab:particle-ambiguous" not in gtags: + for i in givens + middles: + _retag(tokens, i, Role.FAMILY) + # downstream rules key on the role counts: recompute + givens = _idx(tokens, Role.GIVEN) + middles = _idx(tokens, Role.MIDDLE) + families = _idx(tokens, Role.FAMILY) + + # v1 gates both rotations on `not self._had_comma`; the + # middle_as_family fold below runs comma or not (v1 order: + # patronymics first, then handle_middle_name_as_last) + rules = state.policy.patronymic_rules + rotations_apply = state.structure is Structure.NO_COMMA + if rotations_apply and PatronymicRule.EAST_SLAVIC in rules and \ + len(givens) == 1 and len(middles) == 1 and len(families) == 1: + tail = tokens[families[0]].text + mid = tokens[middles[0]].text + if (_EAST_SLAVIC.search(tail) or _EAST_SLAVIC_CYR.search(tail)) \ + and not (_EAST_SLAVIC.search(mid) + or _EAST_SLAVIC_CYR.search(mid)): + g, m, f = givens[0], middles[0], families[0] + _retag(tokens, m, Role.GIVEN) + _retag(tokens, f, Role.MIDDLE) + _retag(tokens, g, Role.FAMILY) + if rotations_apply and PatronymicRule.TURKIC in rules and \ + len(givens) == 1 and len(middles) == 2 and len(families) == 1: + tail = tokens[families[0]].text + if _TURKIC.match(tail) or _TURKIC_CYR.match(tail): + g, m1, m2, f = givens[0], middles[0], middles[1], families[0] + _retag(tokens, m1, Role.GIVEN) + _retag(tokens, m2, Role.MIDDLE) + _retag(tokens, f, Role.MIDDLE) + _retag(tokens, g, Role.FAMILY) + # rule 4: opt-in fold of middles into family (v1 + # handle_middle_name_as_last). v1 PREPENDED middle_list to + # last_list; spans cannot reorder (anti-#100), so folded tokens + # carry a tag and the family views order them first. + if state.policy.middle_as_family: + for i in _idx(tokens, Role.MIDDLE): + tokens[i] = dataclasses.replace( + tokens[i], role=Role.FAMILY, + tags=tokens[i].tags | {FOLDED_TAG}) + return dataclasses.replace(state, tokens=tuple(tokens)) diff --git a/nameparser/_pipeline/_segment.py b/nameparser/_pipeline/_segment.py new file mode 100644 index 00000000..c9762d16 --- /dev/null +++ b/nameparser/_pipeline/_segment.py @@ -0,0 +1,121 @@ +"""Stage: segment. + +Consumes: tokens (role-None main stream), comma_offsets. +Produces: segments (runs of main-token indices; interior segments may +be EMPTY -- doubled commas keep their structural position), structure, +COMMA_STRUCTURE ambiguities for unrecognized extra segments. +Reads: Lexicon suffix vocabulary (via _vocab.is_suffix_lenient) -- +the suffix-comma decision is definitionally vocabulary-dependent +(recorded plan deviation #3); reads Policy.lenient_comma_suffixes +to pick the lenient or strict predicate, and +Policy.extra_suffix_delimiters for v1 suffix_delimiter parity (a +delimiter-core token is transparent in the all-suffix tests). + +Decision (v1 parity): >=1 comma and every post-first segment entirely +lenient-suffix AND >1 word before the first comma -> SUFFIX_COMMA; +otherwise FAMILY_COMMA ("Family, Given ..."), with segments beyond the +second that are not lenient-suffix flagged COMMA_STRUCTURE (they are +still best-effort consumed as suffixes by assign, spec §5a). +""" +from __future__ import annotations + +import bisect +import dataclasses + +from nameparser._pipeline._state import ParseState, PendingAmbiguity, Structure +from nameparser._pipeline._vocab import ( + D as _D, + PH as _PH, + delimiter_cores, is_suffix_lenient, is_suffix_strict, + period_joined_vocab, splits_into_suffixes, +) +from nameparser._types import AmbiguityKind + + + + + +def segment(state: ParseState) -> ParseState: + main = [i for i, t in enumerate(state.tokens) if t.role is None] + if not main: + return dataclasses.replace(state, segments=(), + structure=Structure.NO_COMMA) + if not state.comma_offsets: + return dataclasses.replace(state, segments=(tuple(main),), + structure=Structure.NO_COMMA) + buckets: list[list[int]] = [[] for _ in range(len(state.comma_offsets) + 1)] + for i in main: + # comma_offsets is sorted and no offset ever equals a token + # start, so bisect_left counts the commas before this token + start = state.tokens[i].span.start + bucket = bisect.bisect_left(state.comma_offsets, start) + buckets[bucket].append(i) + groups = [tuple(b) for b in buckets] + # v1 strips exactly ONE trailing comma as cosmetic (parser.py's + # collapse_whitespace); every other empty bucket is STRUCTURAL and + # keeps its position -- in 'Doe,, Jr.' the given segment is empty, + # so 'Jr.' stays a tail suffix instead of masquerading as a lone + # post-comma title (v1 parity, pinned live 2026-07-16) + if len(groups) > 1 and not groups[-1]: + groups.pop() + if len(groups) <= 1: + segs = tuple(groups) if groups and groups[0] else (tuple(main),) + return dataclasses.replace(state, segments=segs, + structure=Structure.NO_COMMA) + + # lenient_comma_suffixes=False drops the post-comma test back to + # the strict predicate (initial-shaped suffix words stop qualifying) + predicate = (is_suffix_lenient if state.policy.lenient_comma_suffixes + else is_suffix_strict) + # v1 expand_suffix_delimiter parity (#191): a configured delimiter + # is TRANSPARENT in the all-suffix tests -- v1 split the part string + # on the delimiter before checking, so the delimiter never counted + cores = delimiter_cores(state.policy.extra_suffix_delimiters) + + def counts_as_suffix(text: str) -> bool: + if text in cores: + return True + return (predicate(text, state.lexicon) + or period_joined_vocab(text, state.lexicon) == "suffix" + or (bool(cores) + and splits_into_suffixes(text, cores, state.lexicon))) + + def suffixy(seg: tuple[int, ...]) -> bool: + # an EMPTY segment is not suffix-shaped: v1's suffix-comma + # detection fails on an empty parts[1] ('John Smith,, MD' is a + # family-comma parse). An adjacent Ph./D. pair counts as ONE + # suffix unit (v1's fix_phd extracted the credential pre-parse, + # so 'Smith, Ph. D.' read as suffix-comma; keep in sync with + # group's _PH/_D merge). + if not seg: + return False + texts = [state.tokens[i].text for i in seg] + k = 0 + while k < len(texts) - 1: + if _PH.fullmatch(texts[k]) and _D.fullmatch(texts[k + 1]): + texts[k:k + 2] = ["phd"] + else: + k += 1 + return all(counts_as_suffix(t) for t in texts) + + # v1 parity: only parts[1] decides the suffix-comma structure + # (parser.py:1318); parts[2:] are consumed as suffixes + # unconditionally either way, so a non-suffix tail segment gets the + # COMMA_STRUCTURE flag, not a structure veto + structure = (Structure.SUFFIX_COMMA + if suffixy(groups[1]) and len(groups[0]) > 1 + else Structure.FAMILY_COMMA) + ambiguities = list(state.ambiguities) + for seg in groups[2:]: + # empty segments are consumed silently (v1 skips them without + # comment); only non-empty non-suffix tails get flagged + if seg and not suffixy(seg): + texts = " ".join(state.tokens[i].text for i in seg) + ambiguities.append(PendingAmbiguity( + AmbiguityKind.COMMA_STRUCTURE, + f"segment {texts!r} beyond the recognized comma " + f"structures; consumed as suffix best-effort", + tuple(seg))) + return dataclasses.replace(state, segments=tuple(groups), + structure=structure, + ambiguities=tuple(ambiguities)) diff --git a/nameparser/_pipeline/_state.py b/nameparser/_pipeline/_state.py new file mode 100644 index 00000000..30d5e5f0 --- /dev/null +++ b/nameparser/_pipeline/_state.py @@ -0,0 +1,85 @@ +"""Internal pipeline state: WorkToken and ParseState. + +WorkTokens are pipeline-internal (no validation -- the tokenizer is the +only producer) and are addressed BY INDEX in every stage: pieces and +segments are runs of token indices, never joined strings, so value-based +lookup (v1's #100 family) is structurally impossible. + +Layering: imports _types, _lexicon, _policy only (enforced by +tests/v2/test_layering.py). +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, auto + +from nameparser._lexicon import Lexicon +from nameparser._policy import Policy +from nameparser._types import AmbiguityKind, Role, Span + + +# The comma characters (ASCII/Arabic/fullwidth, #265). Shared here so +# tokenize (separators/segmentation) and extract (close-quote +# boundaries) cannot drift apart. +COMMA_CHARS = frozenset({",", "\u060c", "\uff0c"}) + +@dataclass(frozen=True, slots=True) +class WorkToken: + """One tokenized word. role stays None until assign; extracted + nickname/maiden tokens arrive with their role pre-set. text is + always the exact original slice (tokenize is the sole producer; + the anti-#100 invariant depends on it).""" + + text: str + span: Span + tags: frozenset[str] = frozenset() + role: Role | None = None + + +class Structure(Enum): + """segment's comma-structure decision.""" + + NO_COMMA = auto() + FAMILY_COMMA = auto() # "Family, Given ..." (v1 lastname-comma) + SUFFIX_COMMA = auto() # "Given Family, Suffix ..." + + +@dataclass(frozen=True, slots=True) +class PendingAmbiguity: + """An ambiguity recorded mid-pipeline by token INDEX; assemble + materializes real Ambiguity objects over the final tokens.""" + + kind: AmbiguityKind + detail: str + indices: tuple[int, ...] = () + + +@dataclass(frozen=True, slots=True) +class ParseState: + """Carried through the stage fold. Frozen; stages return copies via + dataclasses.replace. Fields are filled progressively: + extract_delimited -> extracted/masked; tokenize -> tokens (span- + sorted)/comma_offsets; segment -> segments/structure; classify -> + token tags; group -> pieces/piece_tags/dropped AND maiden token + roles; assign/post_rules -> the remaining token roles; ambiguities are + recorded by extract/segment/assign only (pinned by the ownership + test). 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.""" + + original: str + lexicon: Lexicon + policy: Policy + extracted: tuple[tuple[Role, Span], ...] = () + masked: tuple[Span, ...] = () + tokens: tuple[WorkToken, ...] = () + comma_offsets: tuple[int, ...] = () + segments: tuple[tuple[int, ...], ...] = () + structure: Structure = Structure.NO_COMMA + # pieces[s][p] = run of token indices: piece p of segment s. + # piece_tags[s][p] = derived flags for that piece ("title", "prefix", + # "suffix", "conjunction") set by group's joins. + pieces: tuple[tuple[tuple[int, ...], ...], ...] = () + piece_tags: tuple[tuple[frozenset[str], ...], ...] = () + dropped: tuple[int, ...] = () # structural tokens (maiden markers) + ambiguities: tuple[PendingAmbiguity, ...] = () diff --git a/nameparser/_pipeline/_tokenize.py b/nameparser/_pipeline/_tokenize.py new file mode 100644 index 00000000..85fcbcfa --- /dev/null +++ b/nameparser/_pipeline/_tokenize.py @@ -0,0 +1,94 @@ +"""Stage: tokenize. + +Consumes: original, masked (regions to skip), extracted (regions that +tokenize with a pre-set role). +Produces: tokens (span-sorted WorkTokens; text always == original +slice), comma_offsets (segmentation points; never tokens). +Reads: Policy.strip_emoji, Policy.strip_bidi. + +There is NO text-rewriting normalize stage (core spec §6): 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. + +v1's squash_emoji/squash_bidi REMOVED the char and joined neighbors +('A\U0001f600B' -> 'AB'); here an ignorable char is a SEPARATOR +('A\U0001f600B' -> 'A', 'B') -- the unavoidable consequence of spans +indexing the original exactly. +""" +from __future__ import annotations + +import dataclasses +import re + +from nameparser._pipeline._state import ( + COMMA_CHARS, ParseState, WorkToken, +) +from nameparser._types import Role, Span + +# Ported from v1 (nameparser/config/regexes.py, "emoji" and "bidi") -- +# layering forbids importing the config package here, so the tables are +# duplicated by design with this provenance note. When editing, keep +# both copies in sync (regexes.py builds its public re_emoji from the +# SAME codepoint pairs). Integer ranges, not a regex character class: +# the per-char test needs no regex, and CodeQL's py/overly-large-range +# false-positives on literal astral ranges (surrogate decomposition). +_EMOJI_RANGES = ((0x1F300, 0x1F64F), (0x1F680, 0x1F6FF), + (0x2600, 0x26FF), (0x2700, 0x27BF)) +_BIDI = re.compile('[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+') + + +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 + return False + if state.policy.strip_bidi and _BIDI.match(ch): + return True + if state.policy.strip_emoji: + cp = ord(ch) + return any(lo <= cp <= hi for lo, hi in _EMOJI_RANGES) + return False + + +def _tokenize_region(state: ParseState, start: int, end: int, + role: Role | None, record_commas: bool, + tokens: list[WorkToken], commas: list[int]) -> None: + text = state.original + tok_start: int | None = None + for i in range(start, end): + ch = text[i] + if ch in COMMA_CHARS or _ignorable(ch, state): + if tok_start is not None: + tokens.append(WorkToken(text[tok_start:i], + Span(tok_start, i), role=role)) + tok_start = None + if ch in COMMA_CHARS and record_commas: + commas.append(i) + continue + if tok_start is None: + tok_start = i + if tok_start is not None: + tokens.append(WorkToken(text[tok_start:end], + Span(tok_start, end), role=role)) + + +def tokenize(state: ParseState) -> ParseState: + tokens: list[WorkToken] = [] + commas: list[int] = [] + # main stream: everything outside masked regions + boundaries = [0] + for m in state.masked: + boundaries.extend((m.start, m.end)) + boundaries.append(len(state.original)) + for start, end in zip(boundaries[::2], boundaries[1::2]): + _tokenize_region(state, start, end, None, True, tokens, commas) + # extracted regions: pre-set role, commas are mere separators + for role, inner in state.extracted: + _tokenize_region(state, inner.start, inner.end, role, False, + tokens, commas) + tokens.sort(key=lambda t: t.span) + return dataclasses.replace(state, tokens=tuple(tokens), + comma_offsets=tuple(sorted(commas))) diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py new file mode 100644 index 00000000..699da052 --- /dev/null +++ b/nameparser/_pipeline/_vocab.py @@ -0,0 +1,126 @@ +"""Shared vocabulary predicates for pipeline stages. + +Text-level tests used by more than one stage; token/piece-level +predicates live with their stage. All take normalized-or-raw text +explicitly -- no state. + +Layering: imports _lexicon and _types only. +""" +from __future__ import annotations + +import re + +from nameparser._lexicon import Lexicon, _normalize + +# Ported verbatim from v1 (nameparser/config/regexes.py "initial") minus +# its empty-string alternative -- WorkToken text is never empty. Kept in +# sync by hand; layering forbids importing the config package here. +_INITIAL = re.compile(r"^(\w\.|[A-Z])$") + +# Ported verbatim from v1 (nameparser/config/regexes.py +# "period_not_at_end") -- layering forbids the config import; keep in +# sync by hand. +_PERIOD_NOT_AT_END = re.compile(r".*\..+$", re.I) + +# The fix_phd credential pair ('Ph.' + 'D.' as adjacent tokens), shared +# by segment's suffix-comma detection and group's merge (v1 extracted +# the credential pre-parse; the two stages must agree on the pattern). +PH = re.compile(r"^ph\.?$", re.IGNORECASE) +D = re.compile(r"^d\.?$", re.IGNORECASE) + + +def is_initial(text: str) -> bool: + """'A.' / 'j.' / bare capital -- v1's is_an_initial.""" + return bool(_INITIAL.fullmatch(text)) + + +def suffix_as_written(n: str, text: str, lexicon: Lexicon) -> bool: + """Counts as a suffix as written, with NO initial veto (the veto + differs by caller): unambiguous suffix vocabulary, or an ambiguous + acronym written with periods ('M.A.' yes, 'Ma' no). `n` is + _normalize(text), passed in so callers normalize once. + + Single source for classify's "vocab:suffix" tag and the segment/ + assign predicates. The ambiguous subset is EXCLUDED from the plain + membership test: in the real data suffix_acronyms_ambiguous is a + subset of suffix_acronyms, and without the exclusion the period + gate is dead code (bare 'Ed'/'Jd' would silently become suffixes). + """ + # acronyms may be written with periods ('M.B.A.'): the ACRONYM + # membership alone uses the period-free form (v1's is_suffix + # removed periods only for the suffix_acronyms test); suffix WORDS + # match on the plain normalized form + a = n.replace(".", "") + if "." in text and a in lexicon.suffix_acronyms_ambiguous: + return True + return (a in lexicon.suffix_acronyms + and a not in lexicon.suffix_acronyms_ambiguous) \ + or n in lexicon.suffix_words + + +def _is_suffix_strict_n(n: str, text: str, lexicon: Lexicon) -> bool: + if is_initial(text): + # period-written ambiguous acronyms are exempt from the veto + return "." in text and \ + n.replace(".", "") in lexicon.suffix_acronyms_ambiguous + return suffix_as_written(n, text, lexicon) + + +def is_suffix_strict(text: str, lexicon: Lexicon) -> bool: + """v1's is_suffix: suffix_as_written with the initial veto ('V.' in + 'John V. Smith' is a middle initial, not roman five).""" + return _is_suffix_strict_n(_normalize(text), text, lexicon) + + +def is_suffix_lenient(text: str, lexicon: Lexicon) -> bool: + """v1's is_suffix_lenient: suffix_words accepted unconditionally, + bypassing the initial veto -- only safe in unambiguous positions + (after a comma).""" + n = _normalize(text) + return n in lexicon.suffix_words \ + or _is_suffix_strict_n(n, text, lexicon) + + +def delimiter_cores(policy_delimiters: frozenset[str]) -> frozenset[str]: + """Configured suffix delimiters with surrounding whitespace + stripped: ' - ' -> '-'. Whitespace-padded delimiters surface as + standalone tokens; the stripped core is what tokenize produced.""" + return frozenset(d.strip() for d in policy_delimiters if d.strip()) + + +def splits_into_suffixes(text: str, cores: frozenset[str], + lexicon: Lexicon) -> bool: + """v1 expand_suffix_delimiter parity for delimiters WITHOUT + whitespace ('RN/CRNA' with '/'): the token counts as a suffix when + some core splits it into >=2 non-empty parts that are all suffixes. + The token text is never rewritten (anti-#100): it takes Role.SUFFIX + whole, which renders 'RN/CRNA' where v1 rendered 'RN, CRNA' -- the + documented divergence, release-log classified.""" + for core in cores: + if core in text: + parts = [part for part in text.split(core) if part] + if len(parts) >= 2 and all( + is_suffix_lenient(part, lexicon) for part in parts): + return True + return False + + + +def period_joined_vocab(text: str, lexicon: Lexicon) -> str | None: + """v1's parse_pieces derivation for interior-period tokens + ('Lt.Gov.', 'Msc.Ed.', and by the ANY rule 'Mr.Smith'): ANY title + chunk makes the token a title (checked first, v1's continue); else + ANY suffix chunk makes it a suffix. Chunk-level suffix membership + is v1's is_suffix: bare ambiguous acronyms COUNT ('Msc.Ed.' + derives via 'ed') -- the ambiguous period-gate applies to whole + tokens only. Returns "title", "suffix", or None.""" + if not _PERIOD_NOT_AT_END.match(text): + return None + chunks = [_normalize(c) for c in text.split(".") if c] + if any(c in lexicon.titles for c in chunks): + return "title" + if any(c in lexicon.suffix_acronyms or c in lexicon.suffix_words + for c in chunks): + return "suffix" + return None + diff --git a/nameparser/_policy.py b/nameparser/_policy.py new file mode 100644 index 00000000..9aa6d859 --- /dev/null +++ b/nameparser/_policy.py @@ -0,0 +1,380 @@ +"""Immutable behavior configuration for the 2.0 API. + +Layering: imports nameparser._types only (enforced by +tests/v2/test_layering.py). +""" +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass, field +from enum import Enum, StrEnum, auto + +from nameparser._types import Role, _guarded_getstate, _guarded_setstate + + +class PatronymicRule(StrEnum): + """Stable rule names (API); implementations live in the pipeline. + Enable via ``Policy(patronymic_rules={...})`` or, more commonly, a + locale pack (:mod:`nameparser.locales`).""" + + #: East Slavic formal order: "Sidorov Ivan Petrovich" + #: (family, given, patronymic) is detected by the patronymic + #: ending and reordered. Enabled by locales.RU. + EAST_SLAVIC = "east-slavic" + #: Turkic patronymic markers: a standalone "oglu"/"qizi"/"kyzy" + #: (etc.) binds to the preceding name as a patronymic. Enabled by + #: locales.TR_AZ. + TURKIC = "turkic" + + +# Order-spec constants (#270). Each reads as its contents because roles +# are named given/family, not first/last. + +#: Western order (the default): the first word of positional input is +#: the given name, the last is the family name, everything between is +#: middle. One of the three valid ``Policy(name_order=...)`` values. +GIVEN_FIRST = (Role.GIVEN, Role.MIDDLE, Role.FAMILY) +#: Family name first, given name second, remaining words middle +#: (e.g. Hungarian, or East Asian order). One of the three valid +#: ``Policy(name_order=...)`` values. +FAMILY_FIRST = (Role.FAMILY, Role.GIVEN, Role.MIDDLE) +#: Family name first, given name LAST, words between middle +#: (e.g. Vietnamese full-name order). One of the three valid +#: ``Policy(name_order=...)`` values. +FAMILY_FIRST_GIVEN_LAST = (Role.FAMILY, Role.MIDDLE, Role.GIVEN) + +_NAME_ROLES = frozenset({Role.GIVEN, Role.MIDDLE, Role.FAMILY}) + +# Single source for the migration hint raised by both Policy and +# PolicyPatch when patronymic_rules gets a non-iterable (True is the +# likeliest wrong value -- v1's flag was a bool that enabled BOTH rules). +_PATRONYMIC_MIGRATION_HINT = ( + "v1's patronymic_name_order=True enabled both rules -- " + "patronymic_rules={PatronymicRule.EAST_SLAVIC, " + "PatronymicRule.TURKIC} (or pick one via " + "parser_for(locales.RU) / locales.TR_AZ)" +) + +#: Policy.nickname_delimiters' default. Public and named so +#: customizations read as set math against a documented value -- e.g. +#: ``DEFAULT_NICKNAME_DELIMITERS | {("⦅", "⦆")}`` -- instead of a +#: rebuilt literal the user had to go discover. The v1 trio (straight +#: quotes + parentheses) plus the typographic conventions (#273): +#: smart quotes, low-high and right-right quotes, guillemets both +#: directions, CJK corner brackets, fullwidth parentheses. Curly +#: SINGLE quotes are deliberately absent: U+2019 is the typographic +#: apostrophe ("O’Connor"). +DEFAULT_NICKNAME_DELIMITERS = frozenset({ + ("'", "'"), ('"', '"'), ("(", ")"), # v1 trio + ("“", "”"), # smart quotes (en, zh) + ("„", "“"), # low-high (de, pl, cs, hu) + ("”", "”"), # right-right (sv, fi) + ("«", "»"), # guillemets (fr, ru, it, el) + ("»", "«"), # reversed guillemets (de alt) + ("「", "」"), ("『", "』"), # CJK corner brackets (ja) + ("(", ")"), # fullwidth parentheses (CJK) +}) + + +def _reject_bare_string_order(value: object) -> None: + # tuple("gmf") would be ("g", "m", "f") -- catch the bare string + # with the same TypeError every other iterable field raises. + # Single-sourced: called from Policy AND PolicyPatch __post_init__. + if isinstance(value, str): + raise TypeError( + f"name_order must be an iterable of three Roles, " + f"not a bare string: {value!r}" + ) + + +@dataclass(frozen=True, slots=True) +class Policy: + """The behavior switches a parser runs with: name order, + patronymic rules, delimiter routing, input scrubbing. Immutable + and hashable; every field has a safe default, so construct with + only what you change -- ``Policy(maiden_delimiters={("(", ")")})`` + -- and pass the result to ``Parser(policy=...)``.""" + + #: How positional (no-comma) input maps onto given/middle/family. + #: Valid values are exactly the three exported + #: :ref:`name-order constants ` -- + #: GIVEN_FIRST (the default), FAMILY_FIRST, and + #: FAMILY_FIRST_GIVEN_LAST; any other tuple of Roles raises + #: ValueError. Ignored when a comma separates family from given: + #: "Thomas, John" puts the family name first no matter which words + #: could otherwise be either ("Thomas" and "John" both work as + #: given or family names). A comma that only sets off suffixes + #: ("John Smith, Jr.") leaves name_order governing the name part. + name_order: tuple[Role, Role, Role] = GIVEN_FIRST + #: Opt-in detectors that reorder patronymic-shaped names + #: (EAST_SLAVIC, TURKIC); usually set via a locale pack. + patronymic_rules: frozenset[PatronymicRule] = frozenset() + #: Folds middle into family instead of splitting them (v1's + #: middle_name_as_last) -- for data where unrecognized interior + #: words are surname parts, not middle names: multi-part surnames + #: like Spanish/Portuguese dual surnames ("Gabriel García Márquez" + #: -> family "García Márquez" instead of middle "García"). + middle_as_family: bool = False # v1's middle_name_as_last + #: (open, close) pairs whose enclosed content becomes the nickname + #: field. Defaults to + #: :data:`~nameparser.DEFAULT_NICKNAME_DELIMITERS` (#273). + nickname_delimiters: frozenset[tuple[str, str]] = DEFAULT_NICKNAME_DELIMITERS + #: (open, close) pairs whose enclosed content becomes the maiden + #: field instead; a pair listed here is dropped from the effective + #: nickname set (maiden wins, see __post_init__), so + #: maiden_delimiters={("(", ")")} is the whole recipe (#274). + maiden_delimiters: frozenset[tuple[str, str]] = frozenset() + #: Additional separators that split suffix groups (e.g. " - " for + #: "Jane Smith, RN - CRNA"). Additive only: the comma always + #: splits suffix groups and cannot be replaced -- comma handling + #: is structural (the same comma reading that parses + #: "Family, Given" input), not a configurable delimiter. + extra_suffix_delimiters: frozenset[str] = frozenset() + #: Governs "Family, Suffix"-shaped input where the suffix word is + #: also initial-shaped (a single letter, bare or period-written -- + #: of the default vocabulary that means the roman numerals "I" and + #: "V"): "John Smith, V" reads as John Smith the fifth when True + #: (the default, v1 behavior); False reads "V" as a given-name + #: initial instead (family "John Smith", given "V"). Multi-letter + #: suffixes ("III", "MD") parse the same either way. + lenient_comma_suffixes: bool = True + #: Excludes emoji from tokenization: they appear in no token, + #: field, or rendered view. The original string keeps them (input + #: is never modified -- spans stay true). + strip_emoji: bool = True + #: Excludes bidirectional control characters from tokenization: + #: they appear in no token, field, or rendered view; the original + #: string keeps them. + strip_bidi: bool = True # =False replaces v1's opt-out CONSTANTS.regexes.bidi = False + + # in the class body so @dataclass(slots=True) keeps them + __getstate__ = _guarded_getstate + __setstate__ = _guarded_setstate + + def __post_init__(self) -> None: + _reject_bare_string_order(self.name_order) + order = tuple(self.name_order) + for element in order: + if not isinstance(element, Role): + raise TypeError( + f"name_order elements must be Role members, " + f"got {element!r}" + ) + # Only the three exported orders have implemented assignment + # semantics; the unnamed permutations would silently misassign. + # Pre-2.0 strictness is free -- relaxing later is compatible. + if order not in (GIVEN_FIRST, FAMILY_FIRST, + FAMILY_FIRST_GIVEN_LAST): + raise ValueError( + f"name_order must be one of the exported orders, got " + f"{order!r}; use GIVEN_FIRST, FAMILY_FIRST, or " + f"FAMILY_FIRST_GIVEN_LAST" + ) + object.__setattr__(self, "name_order", order) + if isinstance(self.patronymic_rules, str): + raise TypeError( + f"patronymic_rules must be an iterable of rule names, " + f"not a bare string: {self.patronymic_rules!r}" + ) + # Probe with iter() rather than wrapping tuple(): non-iterables + # (True especially -- v1's patronymic_name_order was a bool flag, + # so it's the likeliest wrong value here) get the migration- + # pointing message, while an exception raised inside a caller's + # generator still propagates untouched from the tuple() below + # instead of being rewritten. Only the enum lookup itself gets + # the unknown-rule message, naming the offender. + try: + rule_iter = iter(self.patronymic_rules) + except TypeError: + raise TypeError( + f"patronymic_rules must be an iterable of PatronymicRule " + f"names, got {self.patronymic_rules!r}; " + f"{_PATRONYMIC_MIGRATION_HINT}" + ) from None + items = tuple(rule_iter) + rules = set() + for r in items: + try: + rules.add(PatronymicRule(r)) + except ValueError: + valid = ", ".join(v.value for v in PatronymicRule) + raise ValueError( + f"unknown patronymic rule {r!r}; valid rules: {valid}" + ) from None + object.__setattr__(self, "patronymic_rules", frozenset(rules)) + for pairs_name in ("nickname_delimiters", "maiden_delimiters"): + pairs = tuple(getattr(self, pairs_name)) + for pair in pairs: + if (not isinstance(pair, tuple) or len(pair) != 2 + or not all(isinstance(s, str) for s in pair)): + raise TypeError( + f"{pairs_name} entries must be (open, close) tuples " + f"of strings, got {pair!r}" + ) + if not all(pair): + raise ValueError( + f"{pairs_name} entries must be pairs of non-empty " + f"strings, got {pair!r}" + ) + object.__setattr__(self, pairs_name, frozenset(pairs)) + # Maiden wins: a pair can route to exactly one field, and listing + # it in maiden_delimiters is the specific intent, so the effective + # nickname set drops it. Canonicalization, not validation (the + # name_order coercion precedent): differently-written but + # equivalent Policies converge to equal values. The v1 facade + # keeps v1's nickname-wins precedence via a pre-subtraction in + # _config_shim's snapshot instead. + object.__setattr__( + self, "nickname_delimiters", + self.nickname_delimiters - self.maiden_delimiters) + if isinstance(self.extra_suffix_delimiters, str): + raise TypeError( + f"extra_suffix_delimiters must be an iterable of strings, " + f"not a bare string: {self.extra_suffix_delimiters!r}" + ) + delimiters = tuple(self.extra_suffix_delimiters) + for d in delimiters: + if not isinstance(d, str): + raise TypeError( + f"extra_suffix_delimiters entries must be strings, " + f"got {d!r}" + ) + if not d: + raise ValueError( + "extra_suffix_delimiters entries must be non-empty strings" + ) + object.__setattr__( + self, "extra_suffix_delimiters", frozenset(delimiters) + ) + # Truthy strings ("no", "false") would silently invert the + # caller's intent downstream; bools are the one field kind the + # coercing checks above can't cover. + for flag in ("middle_as_family", "lenient_comma_suffixes", + "strip_emoji", "strip_bidi"): + value = getattr(self, flag) + if not isinstance(value, bool): + raise TypeError( + f"{flag} must be a bool, got {value!r}" + ) + + def __repr__(self) -> str: + # Bounded: only fields that deviate from the default are shown + # (design rule, see nameparser._types module docstring). + constant_names = { + GIVEN_FIRST: "GIVEN_FIRST", + FAMILY_FIRST: "FAMILY_FIRST", + FAMILY_FIRST_GIVEN_LAST: "FAMILY_FIRST_GIVEN_LAST", + } + parts = [] + for f in dataclasses.fields(self): + value = getattr(self, f.name) + if value == f.default: + continue + if f.name == "name_order": + # __post_init__ restricts to the three named orders, so + # the fallback is unreachable via the constructor; kept + # because repr must never raise (e.g. a smuggled + # __setstate__ value -- layout is validated, values not). + order_repr = constant_names.get( + value, "(" + ", ".join(r.name for r in value) + ")") + parts.append(f"name_order={order_repr}") + else: + parts.append(f"{f.name}={value!r}") + return f"Policy({', '.join(parts)})" + + +class _Unset(Enum): + UNSET = auto() + + +#: Sentinel for "this patch does not set this field" (picklable enum +#: member, distinguishable from every real value including None/False). +UNSET = _Unset.UNSET + +_UNION = {"compose": "union"} # field metadata: set-valued -> union + + +@dataclass(frozen=True, slots=True) +class PolicyPatch: + """A partial Policy: one field per Policy field, all defaulting to + UNSET. Composition per field is DECLARED via metadata -- set-valued + fields union, scalars override (later wins). Kept in lockstep with + Policy by the parity test in tests/v2/test_policy.py. + + Values are validated when the patch is applied (Policy's constructor + re-runs), not at patch construction. + """ + + name_order: tuple[Role, Role, Role] | _Unset = UNSET + patronymic_rules: frozenset[PatronymicRule] | _Unset = field( + default=UNSET, metadata=_UNION) + middle_as_family: bool | _Unset = UNSET + nickname_delimiters: frozenset[tuple[str, str]] | _Unset = field( + default=UNSET, metadata=_UNION) + maiden_delimiters: frozenset[tuple[str, str]] | _Unset = field( + default=UNSET, metadata=_UNION) + extra_suffix_delimiters: frozenset[str] | _Unset = field( + default=UNSET, metadata=_UNION) + lenient_comma_suffixes: bool | _Unset = UNSET + strip_emoji: bool | _Unset = UNSET + strip_bidi: bool | _Unset = UNSET + + # in the class body so @dataclass(slots=True) keeps them + __getstate__ = _guarded_getstate + __setstate__ = _guarded_setstate + + def __post_init__(self) -> None: + # Canonicalize (but do NOT validate) collection fields so a patch + # built from a set/list literal is hashable and unions cleanly in + # apply_patch. name_order needs the same treatment: Policy would + # coerce a list at apply time, but the patch itself (and any + # Locale holding it) must already be hashable. + if self.name_order is not UNSET: + _reject_bare_string_order(self.name_order) + object.__setattr__(self, "name_order", tuple(self.name_order)) + for f in dataclasses.fields(self): + if f.metadata.get("compose") != "union": + continue + value = getattr(self, f.name) + if value is UNSET: + continue + if isinstance(value, str): + raise TypeError( + f"{f.name} must be an iterable, " + f"not a bare string: {value!r}" + ) + # same iter() probe as Policy: curated message for + # non-iterables (with the v1-flag hint where it applies), + # caller-generator exceptions propagate from frozenset() + try: + iter(value) + except TypeError: + hint = ("; " + _PATRONYMIC_MIGRATION_HINT + if f.name == "patronymic_rules" else "") + raise TypeError( + f"{f.name} must be an iterable, got {value!r}{hint}" + ) from None + object.__setattr__(self, f.name, frozenset(value)) + + +def apply_patch(policy: Policy, patch: PolicyPatch) -> Policy: + """Fold a PolicyPatch onto a Policy. Policy.__post_init__ re-runs via + dataclasses.replace, so patched values are revalidated for free -- + including the maiden-wins canonicalization: a patch that adds a + maiden pair removes that pair from the base's effective nickname + set, exactly as if the combined Policy had been constructed + directly. Intended (decided 2026-07-19): maiden_delimiters + membership IS the routing decision, whoever contributes it.""" + updates: dict[str, object] = {} + for f in dataclasses.fields(PolicyPatch): + value = getattr(patch, f.name) + if value is UNSET: + continue + if f.metadata.get("compose") == "union": + value = getattr(policy, f.name) | value + updates[f.name] = value + if not updates: + return policy + # Known mypy limitation with **dict-unpacked replace; see the full + # explanation at Lexicon._edit in _lexicon.py. + return dataclasses.replace(policy, **updates) # type: ignore[arg-type] diff --git a/nameparser/_render.py b/nameparser/_render.py new file mode 100644 index 00000000..18399b71 --- /dev/null +++ b/nameparser/_render.py @@ -0,0 +1,166 @@ +"""Rendering for the 2.0 API: ParsedName -> display strings. + +Layering: imports nameparser._types, and nameparser._lexicon for +Lexicon.default() (capitalized() with lexicon=None) and _normalize +(enforced by tests/v2/test_layering.py). Parsing code never imports +this module; ParsedName's rendering methods delegate here via +call-time imports. + +Malformed str.format specs beyond unknown keys (positional fields, +bad conversions) surface the raw str.format error; only unknown KEYS +get the enriched KeyError. +""" +from __future__ import annotations + +import re + +from nameparser._lexicon import Lexicon, _normalize +from nameparser._types import Ambiguity, ParsedName, Role, Token + +_SPACES = re.compile(r"\s+") +_SPACE_BEFORE_COMMA = re.compile(r"\s+,") +_COMMA_CHAR = re.compile(r"[,،,]") # ASCII, Arabic, fullwidth +_MAC = re.compile(r"^(ma?c)(\w{2,})", re.IGNORECASE) +_WORD = re.compile(r"(\w|\.)+") + +#: str.format keys render() accepts: the seven role fields in canonical +#: order (derived from Role -- never restated) plus the derived views. +_DERIVED_VIEWS = ("family_base", "family_particles", "surnames", "given_names") +_RENDER_KEYS = tuple(r.value for r in Role) + _DERIVED_VIEWS + +#: str.format keys initials() accepts: the three name-bearing roles. +_INITIALS_KEYS = (Role.GIVEN.value, Role.MIDDLE.value, Role.FAMILY.value) + +#: Tags whose tokens contribute no initial outside the given group. +#: Not STABLE_TAGS -- that also contains "initial", which must contribute. +_SKIP_TAGS = frozenset({"particle", "conjunction"}) + +# Ported verbatim from v1 (nameparser/config/regexes.py "initial", +# minus the empty alternative) -- layering forbids importing the +# pipeline here; keep in sync with _pipeline/_vocab.py by hand. +_INITIAL = re.compile(r"^(\w\.|[A-Z])$") + + +def _collapse(rendered: str) -> str: + """The #254 collapse, normative (core spec §5b): empty fields + substitute '' and every artifact of that is removed -- dangling + empty-nickname wrappers, space runs, space-before-comma, one + trailing comma character (any script), leading/trailing ', ' + debris.""" + rendered = (rendered.replace(" ()", "") + .replace(" ''", "") + .replace(' ""', "")) + rendered = _SPACE_BEFORE_COMMA.sub(",", rendered) + rendered = _SPACES.sub(" ", rendered.strip()) + if rendered and _COMMA_CHAR.fullmatch(rendered[-1]): + rendered = rendered[:-1] + return rendered.strip(", ") + + +def _format_spec(spec: str, values: dict[str, str], noun: str, + keys: tuple[str, ...]) -> str: + """Shared tail of render()/initials(): fill the spec, enrich + unknown-KEY errors with the valid key list, collapse.""" + if not isinstance(spec, str): + raise TypeError(f"spec must be a str, got {spec!r}") + try: + rendered = spec.format(**values) + except KeyError as exc: + raise KeyError( + f"unknown {noun} field {exc.args[0]!r}; valid fields: " + f"{', '.join(keys)}" + ) from None + return _collapse(rendered) + + +def render(name: ParsedName, spec: str) -> str: + """Fill the str.format spec from the seven role fields and the + derived views (empty fields substitute ''), then apply the #254 + collapse. Unknown keys raise KeyError naming the valid fields.""" + values = {key: getattr(name, key) for key in _RENDER_KEYS} + return _format_spec(spec, values, "render", _RENDER_KEYS) + + +def initials(name: ParsedName, spec: str, delimiter: str, separator: str) -> str: + """First letter of each contributing token per group, v1 semantics: + delimiter follows each initial, separator sits between initials + within a group. Tokens tagged particle/conjunction contribute no + initial in middle/family (given-name tokens always contribute); + tags come from the pipeline -- hand-built untagged tokens all + contribute. Valid spec keys: given, middle, family.""" + if not isinstance(delimiter, str): + raise TypeError(f"delimiter must be a str, got {delimiter!r}") + if not isinstance(separator, str): + raise TypeError(f"separator must be a str, got {separator!r}") + values: dict[str, str] = {} + for key in _INITIALS_KEYS: + role = Role(key) + tokens = name.tokens_for(role) + if role is not Role.GIVEN: + tokens = tuple(t for t in tokens + if not (_SKIP_TAGS & t.tags)) + values[key] = separator.join( + t.text[0] + delimiter for t in tokens) + return _format_spec(spec, values, "initials", _INITIALS_KEYS) + + +def _cap_word(word: str, role: Role, lex: Lexicon) -> str: + # v1 cap_word order: particle/conjunction rule first, then the + # exceptions map, then Mac/Mc, then str.capitalize + normalized = _normalize(word) + # v1's is_conjunction excludes initials: 'E.' in 'Scott E. Werner' + # is an initial, not the conjunction 'e' (pinned live 2026-07-17) + if ((normalized in lex.particles and role in (Role.MIDDLE, Role.FAMILY)) + or (normalized in lex.conjunctions + and not _INITIAL.fullmatch(word))): + return word.lower() + # v1 cap_word tries the edge-stripped form, then the period-free + # form ('Ph.D.' -> 'ph.d' -> 'phd' hits the exceptions map) + for key in (normalized, normalized.replace(".", "")): + exception = lex.capitalization_exceptions_map.get(key) + if exception is not None: + return exception + if _MAC.match(word): + return _MAC.sub( + lambda m: m.group(1).capitalize() + m.group(2).capitalize(), + word) + return word.capitalize() + + +def _cap_text(text: str, role: Role, lex: Lexicon) -> str: + # word-by-word within the token text: hyphenated names capitalize + # both sides ("macdole-eisenhower" -> "MacDole-Eisenhower") + return _WORD.sub(lambda m: _cap_word(m.group(0), role, lex), text) + + +def capitalized(name: ParsedName, lexicon: Lexicon | None, *, + force: bool) -> ParsedName: + """Case-fixing transform -> new ParsedName, same spans, new token + texts (core spec §5b). Gate (v1 parity): only single-case input is + touched unless force=True; the gate reads the joined token texts + (not render() output -- the case gate stays decoupled from spec + formatting and the #254 collapse). + Idempotent: without force, a capitalized result is mixed-case and + the gate returns it unchanged; with force, every _cap_word rule is + a fixpoint on its own output.""" + if lexicon is not None and not isinstance(lexicon, Lexicon): + # eager, before the gate: a garbage argument must not become a + # silent no-op on mixed-case input or a deep AttributeError + raise TypeError(f"lexicon must be a Lexicon or None, got {lexicon!r}") + lex = Lexicon.default() if lexicon is None else lexicon + joined = " ".join(t.text for t in name.tokens) + if not force and joined not in (joined.upper(), joined.lower()): + return name + new_tokens = tuple( + Token(_cap_text(t.text, t.role, lex), t.span, t.role, t.tags) + for t in name.tokens) + # equal tokens (possible only for synthetic span=None duplicates) + # collapse to one mapping entry -- benign: the rebuilt ambiguity + # references an equal token, so the subset invariant still holds + replacement = dict(zip(name.tokens, new_tokens)) + new_ambiguities = tuple( + Ambiguity(a.kind, a.detail, + tuple(replacement[t] for t in a.tokens)) + for a in name.ambiguities) + return ParsedName(original=name.original, tokens=new_tokens, + ambiguities=new_ambiguities) diff --git a/nameparser/_types.py b/nameparser/_types.py new file mode 100644 index 00000000..8be0953c --- /dev/null +++ b/nameparser/_types.py @@ -0,0 +1,559 @@ +"""Core value types for the 2.0 API. + +Layering (enforced by tests/v2/test_layering.py): this module imports +nothing from nameparser at module level -- it is the bottom of the +module-import dependency graph. The rendering delegates import _render +and matches() imports _parser at call time; TYPE_CHECKING-only imports +supply the Lexicon/Parser annotations. + +Repr policy (applies to every v2 type's __repr__, across this module and +_lexicon.py/_policy.py/_locale.py): bounded output only. No repr may scale +with vocabulary size -- collections render as counts or deltas, never +contents. +""" +from __future__ import annotations + +import dataclasses +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum, StrEnum +from typing import TYPE_CHECKING, NamedTuple, NoReturn, TypeVar + +if TYPE_CHECKING: + from nameparser._lexicon import Lexicon + from nameparser._parser import Parser + + +class Role(Enum): + """The seven fields of a parsed name, one per :class:`Token`. + Declaration order is the canonical field order everywhere + (``as_dict()``, ``comparison_key()``, rendering). Pass a member to + :meth:`ParsedName.tokens_for`; a member's ``.value`` is the field's + string name (``Role.GIVEN.value == "given"``).""" + + # Declaration order IS the canonical field order (conventions §3): + # every listing of the seven fields anywhere derives from this. + + #: Pre-nominal titles and honorifics ("Dr.", "Sir", "Capt."). + TITLE = "title" + #: The given (first) name, or its initial. + GIVEN = "given" + #: Names between given and family -- middle names or initials. + MIDDLE = "middle" + #: The family (last) name, including any particles ("de la Vega"). + FAMILY = "family" + #: Post-nominal pieces ("III", "Jr.", "PhD"). + SUFFIX = "suffix" + #: Delimited nickname content ("Jonathan 'Jack' Kennedy" -> "Jack"). + NICKNAME = "nickname" + #: A birth surname, from a marker word ("Jane Smith née Jones" -> + #: "Jones") or a delimiter pair routed via Policy.maiden_delimiters. + MAIDEN = "maiden" + + +class Span(NamedTuple): + """Where a :class:`Token` came from: a character range into + :attr:`ParsedName.original` such that ``original[start:end]`` is + the token's source text (``end`` exclusive). A plain two-int + NamedTuple; ``None`` in :attr:`Token.span` marks a synthetic token + with no source position.""" + + #: First character index (0-based). + start: int + #: One past the last character index. + end: int + + def __add__(self, other: object) -> NoReturn: # type: ignore[override] + # Inherited tuple + would concatenate two spans into a 4-tuple. + # There is deliberately NO covering-span operation: grouping is + # index-run based (spec §6, the anti-#100 invariant) and never + # merges spans. + raise TypeError( + "Span does not support +; tuple concatenation is not a " + "covering span" + ) + + +#: Stable, documented tag vocabulary (API). All other tags are +#: namespaced ("vocab:...", "patronymic:...") and unstable. "joined" +#: marks a continuation token of a merged piece (the ph-d merge) and +#: drives the suffix view's space-vs-comma join. +STABLE_TAGS = frozenset({"particle", "conjunction", "initial", "joined"}) + +#: The one sanctioned view-reorder marker (namespaced = unstable API). +#: Tokens cannot reorder (span order is validated), so a role fold that +#: must render BEFORE the role's original tokens tags them with this; +#: _text_for and the facade lists prepend carriers. Single-sourced here +#: so the emitter (_pipeline/_post_rules) and the consumers cannot +#: drift. +FOLDED_TAG = "vocab:folded-middle" + +_E = TypeVar("_E", bound=Enum) + + +def _coerce_enum(value: object, enum_cls: type[_E], noun: str, plural: str) -> _E: + """Coerce value to enum_cls, or raise the enriched ValueError listing + every valid member (enum lookups stay ValueError for any input -- + stdlib EnumType precedent, see AGENTS.md's taxonomy rule).""" + if isinstance(value, enum_cls): + return value + try: + return enum_cls(value) + except ValueError: + valid = ", ".join(str(m.value) for m in enum_cls) + raise ValueError( + f"unknown {noun} {value!r}; valid {plural}: {valid}" + ) from None + + +# Pickle support shared by the frozen slots dataclasses: fail at the +# LOAD site when a pickle's field layout does not match this version of +# the class (version skew) -- silently loading would defer the failure +# to a distant attribute read. Values are deliberately NOT re-validated: +# pickle is not a security boundary (arbitrary pickles can execute code +# anyway), and canonical state only comes from a validated instance. +# These are ASSIGNED IN EACH CLASS BODY (not inherited from a mixin): +# @dataclass(slots=True) regenerates the class and installs its own +# pickle methods unless __getstate__/__setstate__ are in the class's +# own __dict__. Lexicon duplicates this logic by design (its slots also +# carry a rebuilt mappingproxy) -- layering keeps _lexicon import-free +# of _types. + + +def _guarded_getstate(self: object) -> dict[str, object]: + fields = dataclasses.fields(self) # type: ignore[arg-type] + return {f.name: getattr(self, f.name) for f in fields} + + +def _guarded_setstate(self: object, state: dict[str, object]) -> None: + fields = dataclasses.fields(self) # type: ignore[arg-type] + expected = {f.name for f in fields} + if set(state) != expected: + missing = ", ".join(sorted(expected - set(state))) or "none" + unexpected = ", ".join(sorted(set(state) - expected)) or "none" + raise ValueError( + f"incompatible {type(self).__name__} pickle: missing " + f"fields: {missing}; unexpected fields: {unexpected}" + ) + for name, value in state.items(): + object.__setattr__(self, name, value) + + +@dataclass(frozen=True, slots=True) +class Token: + """One classified word of a parsed name: its text, where it came + from, which field it belongs to, and how it was classified. Read + tokens off :attr:`ParsedName.tokens` or + :meth:`ParsedName.tokens_for`; you only construct one directly + when hand-building a :class:`ParsedName`.""" + + #: The word exactly as written in the input (never empty). + text: str + #: Position in ParsedName.original; None marks a synthetic token + #: (e.g. introduced by replace()) with no source position. + span: Span | None + #: The field this token belongs to. + role: Role + #: Classification labels. Exactly the four in STABLE_TAGS + #: ("particle", "conjunction", "initial", "joined") are API; + #: namespaced tags like "vocab:..." are unstable debugging + #: provenance -- never match against them. + tags: frozenset[str] = frozenset() + + # in the class body so @dataclass(slots=True) keeps them + __getstate__ = _guarded_getstate + __setstate__ = _guarded_setstate + + def __post_init__(self) -> None: + if not isinstance(self.text, str): + raise TypeError( + f"Token.text must be a str, got {self.text!r}" + ) + if not self.text: + raise ValueError("Token.text must be a non-empty string") + object.__setattr__( + self, "role", _coerce_enum(self.role, Role, "Role", "roles")) + if self.span is not None: + if not ( + isinstance(self.span, tuple) + and len(self.span) == 2 + # bool is an int subclass: (False, True) is a comparison + # result leaking into a coordinate slot, not a span + and all(isinstance(v, int) and not isinstance(v, bool) + for v in self.span) + ): + raise TypeError( + f"invalid span {self.span!r}: expected a (start, end) " + "pair of ints or None" + ) + start, end = self.span + if start < 0 or end < start: + raise ValueError( + f"invalid span ({start}, {end}): need 0 <= start <= end" + ) + object.__setattr__(self, "span", Span(start, end)) + # The same guards _normset applies to Lexicon vocabulary: a bare + # string would become its character set, a mapping would silently + # contribute only its keys. + if isinstance(self.tags, str): + raise TypeError( + "Token.tags must be an iterable of strings, " + "not a bare string" + ) + if isinstance(self.tags, Mapping): + raise TypeError( + "Token.tags must be an iterable of strings, not a mapping" + ) + tags = frozenset(self.tags) + for tag in tags: + if not isinstance(tag, str): + raise TypeError( + f"Token.tags must contain only strings, got {tag!r}" + ) + object.__setattr__(self, "tags", tags) + + def __repr__(self) -> str: + # Bounded output: a single token's text/span/role/tags, never + # scales with vocabulary size (design rule -- see module docstring). + where = (f"@{self.span.start}:{self.span.end}" + if self.span is not None else "@synthetic") + tags = f" {{{', '.join(sorted(self.tags))}}}" if self.tags else "" + return f"Token({self.text!r} {where} {self.role.name}{tags})" + + +class AmbiguityKind(StrEnum): + """The stable vocabulary of :class:`Ambiguity` kinds. A StrEnum: + members ARE their string values, so ``kind == "particle-or-given"`` + compares directly. New kinds may be added in minor releases; + existing values never change meaning.""" + + #: Reserved: the name's field order itself is uncertain (e.g. a + #: two-word name under a non-default name_order). Not yet emitted; + #: planned for 2.x. + ORDER = "order" + #: Reserved: a trailing word reads plausibly as either a suffix or + #: a nickname. Not yet emitted; planned for 2.x. + SUFFIX_OR_NICKNAME = "suffix-or-nickname" + #: A leading ambiguous particle was read as a given name -- "Van + #: Johnson" parses given="Van", but "Van" is also a family-name + #: particle in other names. + PARTICLE_OR_GIVEN = "particle-or-given" + #: A nickname/maiden delimiter opened without closing (or closed + #: without opening); the text was kept as literal name content. + #: May carry no tokens. + UNBALANCED_DELIMITER = "unbalanced-delimiter" + #: More comma-separated segments than any recognized name shape; + #: the parse is best-effort over the extra segments. + COMMA_STRUCTURE = "comma-structure" + + +@dataclass(frozen=True, slots=True) +class Ambiguity: + """A call the parser made that could legitimately have gone the + other way, surfaced on :attr:`ParsedName.ambiguities` instead of + silently guessed away. The parse still commits to one reading -- + an Ambiguity is a flag for review, not an error.""" + + #: Which known ambiguity shape this is (stable API values). + kind: AmbiguityKind + #: Human-readable specifics of this occurrence (wording unstable). + detail: str + #: The tokens involved -- always a subset of the owning + #: ParsedName's tokens; may be empty (e.g. unbalanced-delimiter). + tokens: tuple[Token, ...] + + # in the class body so @dataclass(slots=True) keeps them + __getstate__ = _guarded_getstate + __setstate__ = _guarded_setstate + + def __post_init__(self) -> None: + object.__setattr__( + self, "kind", + _coerce_enum(self.kind, AmbiguityKind, "AmbiguityKind", "kinds")) + if not isinstance(self.detail, str): + raise TypeError( + f"Ambiguity.detail must be a str, got {self.detail!r}" + ) + if not self.detail: + raise ValueError("Ambiguity.detail must be a non-empty string") + toks = tuple(self.tokens) + for tok in toks: + if not isinstance(tok, Token): + raise TypeError( + f"Ambiguity.tokens must contain only Token instances, " + f"got {tok!r}" + ) + object.__setattr__(self, "tokens", toks) + + def __repr__(self) -> str: + texts = "/".join(repr(t.text) for t in self.tokens) + return f"Ambiguity({self.kind.value!r}: {texts})" + + +@dataclass(frozen=True, slots=True) +class ParsedName: + """The immutable result of parsing one name string. Read the seven + fields as strings (``.given``, ``.family``, ...); inspect structure + through :attr:`tokens` / :meth:`tokens_for`; correct a parse with + :meth:`replace` (returns a new value); produce output with + :meth:`render`, :meth:`initials`, :meth:`capitalized`, or ``str()``. + + Constructor-enforced invariants: spans ascending, non-overlapping, + in bounds of `original`; every Ambiguity's tokens are a subset of + `tokens`. Provenance semantics (text == original[span] for + parser-produced names) are documented, not enforced -- transforms + like replace() legitimately break them. + """ + + #: The input string exactly as passed to parse(). + original: str + #: Every classified token, in document order. + tokens: tuple[Token, ...] + #: Judgment calls that could have gone the other way; empty for + #: most names (see Ambiguity). + ambiguities: tuple[Ambiguity, ...] = () + + # in the class body so @dataclass(slots=True) keeps them + __getstate__ = _guarded_getstate + __setstate__ = _guarded_setstate + + def __post_init__(self) -> None: + if not isinstance(self.original, str): + raise TypeError( + f"ParsedName.original must be a str, got {self.original!r}" + ) + object.__setattr__(self, "tokens", tuple(self.tokens)) + object.__setattr__(self, "ambiguities", tuple(self.ambiguities)) + for tok in self.tokens: + if not isinstance(tok, Token): + raise TypeError( + f"ParsedName.tokens must contain only Token instances, " + f"got {tok!r}" + ) + for amb in self.ambiguities: + if not isinstance(amb, Ambiguity): + raise TypeError( + f"ParsedName.ambiguities must contain only Ambiguity " + f"instances, got {amb!r}" + ) + prev_end = 0 + for tok in self.tokens: + if tok.span is None: + continue + if tok.span.end > len(self.original): + raise ValueError( + f"token {tok.text!r} span {tuple(tok.span)} is out of " + f"bounds for original of length {len(self.original)}" + ) + if tok.span.start < prev_end: + raise ValueError( + f"token spans must be ascending and non-overlapping; " + f"token {tok.text!r} at {tuple(tok.span)} begins before " + f"offset {prev_end}" + ) + prev_end = tok.span.end + for amb in self.ambiguities: + for tok in amb.tokens: + if tok not in self.tokens: + raise ValueError( + f"Ambiguity token {tok.text!r} is not a subset of " + f"this ParsedName's tokens" + ) + + def __bool__(self) -> bool: + return bool(self.tokens) + + def __str__(self) -> str: + return self.render() + + def __repr__(self) -> str: + # 4-space indent, matching HumanName's repr (v1 style) + lines = [] + for role in Role: + text = self._text_for(role) + if text: + lines.append(f" {role.value}: {text!r}") + if self.ambiguities: + kinds = [a.kind.value for a in self.ambiguities] + lines.append(f" ambiguities: {kinds!r}") + body = "\n".join(lines) + return f"" if lines else "" + + # -- string views (canonical order = Role declaration order) -------- + + def _text_for(self, *roles: Role, tag: str | None = None, + without_tag: str | None = None) -> str: + suffix_join = roles == (Role.SUFFIX,) + parts: list[str] = [] + folded: list[str] = [] + for tok in self.tokens: + if tok.role not in roles: + continue + if tag is not None and tag not in tok.tags: + continue + if without_tag is not None and without_tag in tok.tags: + continue + # "joined" (stable tag) marks a continuation of the previous + # token ("Ph." + "D."): attach with a space so the suffix + # view's ", " join does not split one credential in two + if suffix_join and "joined" in tok.tags and parts: + parts[-1] += " " + tok.text + elif FOLDED_TAG in tok.tags: + # middle_as_family fold: v1 PREPENDED middle_list to + # last_list; spans cannot reorder, so the view does + folded.append(tok.text) + else: + parts.append(tok.text) + return (", " if suffix_join else " ").join(folded + parts) + + @property + def title(self) -> str: + return self._text_for(Role.TITLE) + + @property + def given(self) -> str: + return self._text_for(Role.GIVEN) + + @property + def middle(self) -> str: + return self._text_for(Role.MIDDLE) + + @property + def family(self) -> str: + return self._text_for(Role.FAMILY) + + @property + def suffix(self) -> str: + return self._text_for(Role.SUFFIX) + + @property + def nickname(self) -> str: + return self._text_for(Role.NICKNAME) + + @property + def maiden(self) -> str: + return self._text_for(Role.MAIDEN) + + # -- derived views (filters over roles + STABLE tags only) ---------- + + @property + def family_particles(self) -> str: + return self._text_for(Role.FAMILY, tag="particle") + + @property + def family_base(self) -> str: + return self._text_for(Role.FAMILY, without_tag="particle") + + @property + def surnames(self) -> str: + return self._text_for(Role.MIDDLE, Role.FAMILY) + + @property + def given_names(self) -> str: + return self._text_for(Role.GIVEN, Role.MIDDLE) + + # -- structured access ---------------------------------------------- + + def tokens_for(self, role: Role) -> tuple[Token, ...]: + return tuple(t for t in self.tokens if t.role is role) + + def as_dict(self, include_empty: bool = True) -> dict[str, str]: + # _text_for handles the suffix ", "-join (single-role SUFFIX call) + d = {role.value: self._text_for(role) for role in Role} + if not include_empty: + d = {k: v for k, v in d.items() if v} + return d + + # -- editing ---------------------------------------------------------- + + def replace(self, **fields: str) -> ParsedName: + """Return a new ParsedName with the named fields re-tokenized as + synthetic tokens (span=None). Whitespace-splits each value; an + empty value clears the field. original is unchanged (provenance). + Ambiguities referencing replaced tokens are dropped. + """ + by_value = {role.value: role for role in Role} + for key, value in fields.items(): + if key not in by_value: + raise TypeError( + f"unknown field {key!r}; expected one of " + f"{', '.join(by_value)}" + ) + if not isinstance(value, str): + raise TypeError( + f"field {key!r} must be a str, got {value!r}" + ) + + def synthetic(value: str, role: Role) -> list[Token]: + return [Token(word, None, role) for word in value.split()] + + replaced = {by_value[k]: v for k, v in fields.items()} + new_tokens: list[Token] = [] + emitted: set[Role] = set() + for tok in self.tokens: + if tok.role in replaced: + if tok.role not in emitted: + new_tokens.extend(synthetic(replaced[tok.role], tok.role)) + emitted.add(tok.role) + continue + new_tokens.append(tok) + for role in Role: + if role in replaced and role not in emitted: + new_tokens.extend(synthetic(replaced[role], role)) + kept = tuple( + amb for amb in self.ambiguities + if all(t in new_tokens for t in amb.tokens) + ) + return ParsedName(self.original, tuple(new_tokens), kept) + + # -- comparison ------------------------------------------------------- + + def comparison_key(self) -> tuple[str, ...]: + """One casefolded component per Role, in canonical order, for + dedup, dict keys, and sorting. The semantic layer; __eq__ stays + strict. + """ + return tuple(self._text_for(role).casefold() for role in Role) + + def matches(self, other: str | ParsedName, *, + parser: Parser | None = None) -> bool: + """Component-wise case-insensitive comparison (the semantic + layer; __eq__ stays strict). A str argument is parsed with + `parser`, or the default parser when None.""" + if isinstance(other, str): + import nameparser._parser as _parser + active = parser if parser is not None else _parser._default_parser() + other = active.parse(other) + if not isinstance(other, ParsedName): + raise TypeError( + f"matches() takes a str or ParsedName, got {other!r}") + return self.comparison_key() == other.comparison_key() + + # -- rendering delegates ---------------------------------------------- + # One-line delegation to nameparser._render (core spec §5b): parsing + # code physically cannot import formatting logic, so these import at + # call time -- module level stays internal-import-free. + + def render(self, spec: str = "{title} {given} {middle} {family} {suffix}") -> str: + """Fill the str.format spec from the seven role fields and the + derived views; empty fields collapse (#254). Unknown keys raise + KeyError naming the valid fields.""" + import nameparser._render as _render + return _render.render(self, spec) + + def initials(self, spec: str = "{given} {middle} {family}", + delimiter: str = ".", separator: str = " ") -> str: + """Initials per group; v1's initials_format/_delimiter/_separator + become call-site arguments (core spec §5b). Valid spec keys: + given, middle, family.""" + import nameparser._render as _render + return _render.initials(self, spec, delimiter, separator) + + def capitalized(self, lexicon: Lexicon | None = None, *, + force: bool = False) -> ParsedName: + """Case-fixing transform -> new ParsedName, same spans, new + token texts. Needs a lexicon for capitalization_exceptions and + particle rules; None uses the default lexicon. force=False + preserves mixed-case input (v1 parity). Idempotent.""" + import nameparser._render as _render + return _render.capitalized(self, lexicon, force=force) diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index 6f1e7984..05dbae09 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -1,996 +1,20 @@ +"""v1 import-path preservation (migration spec §3): the Constants shim +lives in nameparser._config_shim. The vocabulary data modules in this +package (titles, suffixes, ...) remain the single source through 2.x. +This package is deleted in 3.0. + +``RegexTupleManager`` is re-exported unchanged from the shim purely for +pickle compatibility: a v1.4 ``Constants`` blob's ``regexes`` field was +pickled as ``nameparser.config.RegexTupleManager(...)``, and unpickling +resolves and reconstructs that nested object before +``Constants.__setstate__`` ever runs. Without this name, loading such a +blob raises ``AttributeError`` looking up the class, not a clean +compatibility failure. """ -The :py:mod:`nameparser.config` module manages the configuration of the -nameparser. +from nameparser._config_shim import CONSTANTS as CONSTANTS +from nameparser._config_shim import Constants as Constants +from nameparser._config_shim import RegexTupleManager as RegexTupleManager +from nameparser._config_shim import SetManager as SetManager +from nameparser._config_shim import TupleManager as TupleManager -:py:class:`~nameparser.config.Constants` is for application-level -configuration, set once at startup. ``CONSTANTS``, the module-level instance -used by every ``HumanName`` created without its own config, is the only -channel that reaches parses happening in code you don't own (helpers, -pipelines, a third-party library using nameparser internally) -- the same -role ``logging`` and ``locale`` play elsewhere. Import it and change it -directly: - -:: - - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.titles.remove('hon').add('chemistry','dean') # doctest: +SKIP - -For anything scoped -- one dataset, one library, one test -- pass your own -:py:class:`Constants` instance as the second argument upon instantiation -instead: ``Constants()`` for fresh library defaults, or ``CONSTANTS.copy()`` -for a private snapshot of the current module config. - -:: - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> hn = HumanName("Dean Robert Johns", Constants()) - >>> hn.C.titles.add('dean') # doctest: +SKIP - >>> hn.parse_full_name() # need to run this again after config changes - -Mixing the two up is where the surprises come from, not the API itself: if -you do not pass your own :py:class:`Constants` instance as the second -argument, ``hn.C`` will be a reference to the module config, and a change -there reaches every other instance sharing it. See `Customizing the Parser -`_. - -.. deprecated:: 1.4.0 - Passing ``None`` as the second argument also builds a fresh - ``Constants()``, but is deprecated in favor of the explicit spellings - above; it will raise ``TypeError`` in 2.0 (issue #260). -""" -import copy -import inspect -import re -import sys -import warnings -from collections.abc import Callable, Iterable, Iterator, Mapping, Set -from typing import Any, TypeVar, overload - -if sys.version_info >= (3, 11): - from typing import Self -else: - from typing_extensions import Self - -from nameparser.util import lc -from nameparser.config.prefixes import PREFIXES, NON_FIRST_NAME_PREFIXES -from nameparser.config.bound_first_names import BOUND_FIRST_NAMES -from nameparser.config.capitalization import CAPITALIZATION_EXCEPTIONS -from nameparser.config.conjunctions import CONJUNCTIONS -from nameparser.config.suffixes import SUFFIX_ACRONYMS -from nameparser.config.suffixes import SUFFIX_NOT_ACRONYMS -from nameparser.config.suffixes import SUFFIX_ACRONYMS_AMBIGUOUS -from nameparser.config.titles import TITLES -from nameparser.config.titles import FIRST_NAME_TITLES -from nameparser.config.regexes import EMPTY_REGEX, REGEXES - -DEFAULT_ENCODING = 'UTF-8' - - -def _reject_bare_str_or_bytes(value: object, expected: str) -> None: - # A bare string is an iterable of its characters, so e.g. set('dr') or - # dict('ab') would silently shred it, and bytes iterates to ints, which - # can never match parsed str tokens -- shared by SetManager's constructor/ - # operands (#238) and TupleManager's constructor (#242). - if isinstance(value, bytes): - raise TypeError( - f"expected {expected}, got a single bytes; " - f"decode it first: [{value!r}.decode()]" - ) - if isinstance(value, str): - raise TypeError( - f"expected {expected}, got a single str; wrap it in a list: [{value!r}]" - ) - - -class SetManager(Set): - ''' - Easily add and remove config variables per module or instance. Subclass of - ``collections.abc.Set``. - - Special functionality beyond that provided by set() is to normalize - constants for comparison (lowercase, leading/trailing periods stripped) - when they are add()ed and remove()d, and to allow passing multiple - string arguments to the :py:func:`add()` and :py:func:`remove()` - methods. The constructor and the set operators apply the same - normalization to their elements and operands, so every entry is stored - in the form the parser's lookups expect, and they reject a bare string - with ``TypeError``, since e.g. ``set('dr')`` would silently build a set - of single characters. - - ''' - - _on_change: Callable[[], None] | None - - @classmethod - def _normalized_elements(cls, elements: Iterable[str]) -> set[str]: - # a SetManager's elements were validated and normalized when it was - # built, so copy them instead of re-validating — this is what keeps - # chained unions (suffixes_prefixes_titles) and default Constants() - # construction from re-checking ~1,400 entries per step - if isinstance(elements, SetManager): - return set(elements.elements) - _reject_bare_str_or_bytes(elements, "an iterable of strings") - # apply the same lc() normalization (lowercase, strip leading/ - # trailing periods) that add() applies, and reject junk elements: - # lc() on bytes or int crashes without naming the culprit, and - # lc(None) silently transmutes to ''. Divergence from add() is - # deliberate: add_with_encoding() decodes bytes for back-compat, - # bulk boundaries stay strict. - normalized = set() - for s in elements: - if isinstance(s, bytes): - raise TypeError( - f"expected str elements, got bytes; decode it first: {s!r}.decode()" - ) - if not isinstance(s, str): - raise TypeError( - f"expected str elements, got {type(s).__name__}: {s!r}" - ) - normalized.add(lc(s)) - return normalized - - @classmethod - def _from_normalized(cls, elements: set[str]) -> 'SetManager': - # Private fast constructor: bypasses __init__ so results aren't - # re-validated element by element. This performs NO validation or - # normalization of `elements` -- the caller is fully responsible - # for guaranteeing every element is already a str that has passed - # through lc(). Only call this with a set built from other - # SetManagers' already-normalized .elements (operator results, - # prebuilt default copies); passing anything else silently defeats - # the constructor's #238 guarantees with no error raised here. - obj = cls.__new__(cls) - obj.elements = elements - obj._on_change = None - return obj - - def __init__(self, elements: Iterable[str]) -> None: - self.elements = self._normalized_elements(elements) - # Optional invalidation hook, wired by an owning Constants so that - # in-place add()/remove() can clear its cached suffixes_prefixes_titles - # union. None when the manager is used standalone. - self._on_change = None - - def __call__(self) -> Set[str]: - """ - .. deprecated:: 1.3.0 - Removed in 2.0 (see issue #243). Returns the raw underlying set, - so mutating it bypasses normalization and cache invalidation; - iterate the manager or copy with ``set(manager)`` instead. - """ - warnings.warn( - "Calling a SetManager to get the raw underlying set is " - "deprecated and will be removed in 2.0; iterate the manager or " - "copy it with set(manager) instead. See " - "https://github.com/derek73/python-nameparser/issues/243", - DeprecationWarning, - stacklevel=2, - ) - return self.elements - - def __repr__(self) -> str: - # Sorted so repr is stable across runs -- set() iteration order - # depends on string hash randomization, which varies per process. - elements = "{" + ", ".join(repr(e) for e in sorted(self.elements)) + "}" if self.elements else "set()" - return f"SetManager({elements})" # used for docs - - def __iter__(self) -> Iterator[str]: - return iter(self.elements) - - def __contains__(self, value: object) -> bool: - # add()/remove()/the constructor/the operators all normalize (lowercase, - # strip leading/trailing periods) before comparing; without the same - # normalization here, `'Dr.' in c.titles` returns False even though - # every other operation on the same value succeeds (#244). The parser's - # own lookups (e.g. `piece.lower() in self.C.conjunctions`) already pass - # an lc()-normalized value, which is the hot path during parsing, so - # try the raw value first and only pay for lc() on a miss. - if value in self.elements: - return True - return isinstance(value, str) and lc(value) in self.elements - - def __len__(self) -> int: - return len(self.elements) - - # The ABC mixins compare raw operand elements against stored (normalized) - # ones, and their __or__/__and__ accept a bare str as Iterable, so every - # operand is validated and normalized here. Results are built with plain - # set ops on already-normalized elements instead of delegating to the - # mixins, whose _from_iterable would re-validate the whole result - # through __init__. - # - # the runtime ABC accepts any Iterable operand, so annotate honestly and - # ignore typeshed's narrower AbstractSet declarations - def __or__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override] - return self._from_normalized(self.elements | self._normalized_elements(other)) - - __ror__ = __or__ - - def __and__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override] - return self._from_normalized(self.elements & self._normalized_elements(other)) - - __rand__ = __and__ - - def __sub__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override] - return self._from_normalized(self.elements - self._normalized_elements(other)) - - def __rsub__(self, other: Iterable[str]) -> 'SetManager': - return self._from_normalized(self._normalized_elements(other) - self.elements) - - def __xor__(self, other: Iterable[str]) -> 'SetManager': # type: ignore[override] - return self._from_normalized(self.elements ^ self._normalized_elements(other)) - - __rxor__ = __xor__ - - def _add_normalized(self, s: str | bytes, encoding: str | None, *, stacklevel: int) -> None: - # Shared by add() and add_with_encoding() so each can call it - # directly with a stacklevel that attributes the warning to *its own* - # caller -- add() delegating to add_with_encoding() would otherwise - # add a frame and misattribute the warning to this module. - stdin_encoding = None - if sys.stdin: - stdin_encoding = sys.stdin.encoding - encoding = encoding or stdin_encoding or DEFAULT_ENCODING - if isinstance(s, bytes): - warnings.warn( - "Passing bytes to SetManager.add()/add_with_encoding() is " - "deprecated and will raise TypeError in 2.0; decode it " - "first, e.g. value.decode('utf-8'). See " - "https://github.com/derek73/python-nameparser/issues/245", - DeprecationWarning, - stacklevel=stacklevel, - ) - s = s.decode(encoding) - normalized = lc(s) - if normalized not in self.elements: - self.elements.add(normalized) - if self._on_change: - self._on_change() - - def add_with_encoding(self, s: str | bytes, encoding: str | None = None) -> None: - """ - Add the lowercased, leading/trailing-periods-stripped version of the string to the set. Pass an - explicit `encoding` parameter to specify the encoding of binary strings that - are not DEFAULT_ENCODING (UTF-8). - - .. deprecated:: 1.3.0 - ``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue - #245); decode before adding. - - .. deprecated:: 1.4.0 - The method itself is removed in 2.0 (see issue #245); use - :py:func:`add` instead, decoding bytes first. - """ - warnings.warn( - "SetManager.add_with_encoding() is deprecated and will be " - "removed in 2.0; use add() instead (decode bytes first). See " - "https://github.com/derek73/python-nameparser/issues/245", - DeprecationWarning, - stacklevel=2, - ) - self._add_normalized(s, encoding, stacklevel=3) - - def add(self, *strings: str) -> Self: - """ - Add the lowercased, leading/trailing-periods-stripped version of the string arguments to the set. - Returns ``self`` for chaining. - - .. deprecated:: 1.3.0 - ``bytes`` arguments will raise ``TypeError`` in 2.0 (see issue - #245); decode before adding. - """ - for s in strings: - self._add_normalized(s, None, stacklevel=3) - - return self - - def remove(self, *strings: str) -> Self: - """ - Remove the lower case and no-period version of the string arguments from the set. - Returns ``self`` for chaining. - - .. deprecated:: 1.3.0 - Removing a *missing* member currently does nothing but will - raise ``KeyError`` in 2.0, matching ``set.remove`` (see issue - #243); use :py:func:`discard` to ignore missing members. - """ - changed = False - for s in strings: - if (lower := lc(s)) in self.elements: - self.elements.remove(lower) - changed = True - else: - warnings.warn( - "SetManager.remove() of a missing member currently does " - "nothing, but will raise KeyError in 2.0; use discard() " - "to ignore missing members. See " - "https://github.com/derek73/python-nameparser/issues/243", - DeprecationWarning, - stacklevel=2, - ) - if changed and self._on_change: - self._on_change() - return self - - def discard(self, *strings: str) -> Self: - """ - Remove the lower case and no-period version of the string arguments - from the set if present; missing members are ignored, like - ``set.discard``. Returns ``self`` for chaining. - """ - changed = False - for s in strings: - if (lower := lc(s)) in self.elements: - self.elements.remove(lower) - changed = True - if changed and self._on_change: - self._on_change() - return self - - def clear(self) -> Self: - """Remove all entries from the set. Returns ``self`` for chaining.""" - if self.elements: - self.elements.clear() - if self._on_change: - self._on_change() - return self - - -T = TypeVar('T') - - -def _is_dunder(attr: str) -> bool: - # Dunder names are Python's protocol probes (copy looks up __deepcopy__, - # inspect.unwrap looks up __wrapped__, typing's GenericAlias.__call__ sets - # __orig_class__, ...), never config keys. The TupleManager attribute hooks - # all route dunders to normal object-attribute behavior so those probes - # work instead of being mistaken for dict entries. - return attr.startswith("__") and attr.endswith("__") - - -# The default config sets are module constants that never change, so -# validate and normalize each one exactly once at import. Constants() -# copies these via _normalized_elements' SetManager fast path instead of -# re-checking ~1,400 elements per construction — a cost that otherwise -# repeats on the per-instance-config path, HumanName(constants=Constants()). -# -# This snapshot is taken once, at import time: mutating a raw constant -# (e.g. `TITLES.add('x')`) after import is *not* picked up by Constants() -# built afterward, since the identity check in Constants.__init__ reuses -# this frozen SetManager rather than re-wrapping the (now-changed) raw -# set. That's a behavior change from re-wrapping every time, but the -# documented customization path mutates the SetManager wrapper on a -# Constants instance (``CONSTANTS.titles.add(...)``), not the raw -# constant, so this only affects an unsupported/undocumented pattern. -_DEFAULT_PREFIXES = SetManager(PREFIXES) -_DEFAULT_SUFFIX_ACRONYMS = SetManager(SUFFIX_ACRONYMS) -_DEFAULT_SUFFIX_NOT_ACRONYMS = SetManager(SUFFIX_NOT_ACRONYMS) -_DEFAULT_SUFFIX_ACRONYMS_AMBIGUOUS = SetManager(SUFFIX_ACRONYMS_AMBIGUOUS) -_DEFAULT_TITLES = SetManager(TITLES) -_DEFAULT_FIRST_NAME_TITLES = SetManager(FIRST_NAME_TITLES) -_DEFAULT_CONJUNCTIONS = SetManager(CONJUNCTIONS) -_DEFAULT_BOUND_FIRST_NAMES = SetManager(BOUND_FIRST_NAMES) -_DEFAULT_NON_FIRST_NAME_PREFIXES = SetManager(NON_FIRST_NAME_PREFIXES) - - -class TupleManager(dict[str, T]): - ''' - A dictionary with dot.notation access. Subclass of ``dict``. Wraps the - mapping config constants (``capitalization_exceptions``, ``regexes``, and - the nickname/maiden delimiter buckets). The name is historical: before - 1.3.0 these constants were tuples of pairs. - ''' - - def __init__( - self, - arg: Mapping[str, T] | Iterable[tuple[str, T]] = (), - **kwargs: T, - ) -> None: - # dict.__init__ accepts a bare str/bytes as an iterable-of-pairs - # argument (each character iterates further, and dict() only - # complains once it hits a "pair" of the wrong length) and accepts an - # iterable of 2-character strings as if each one were a (key, value) - # pair, silently shredding it -- mirrors SetManager's guard against - # the same class of mistake (#238), applied to the mapping - # constructor's own failure modes (#242). - _reject_bare_str_or_bytes(arg, "a mapping or iterable of (key, value) pairs") - if not isinstance(arg, Mapping): - checked = [] - for item in arg: - if isinstance(item, (str, bytes)): - raise TypeError( - "expected (key, value) pairs, got a " - f"{'bytes' if isinstance(item, bytes) else 'str'} " - f"element {item!r}; a 2-character string silently " - "splits into a key and a value" - ) - checked.append(item) - arg = checked - super().__init__(arg, **kwargs) - - def _warn_unknown_key(self, attr: str) -> None: - # Deprecated 1.4.0, raises AttributeError in 2.0 (#256): a misspelled - # key otherwise degrades silently with no traceback pointing at the - # typo. - warnings.warn( - f"{attr!r} is not a known key ({', '.join(sorted(self))}); " - "unknown-key attribute access is deprecated and will raise " - "AttributeError in 2.0. Use .get() for intentional soft access. " - "See https://github.com/derek73/python-nameparser/issues/256", - DeprecationWarning, - stacklevel=3, - ) - - def __getattr__(self, attr: str) -> T | None: - # Otherwise the dict default (None) is mistaken for a real protocol hook. - if _is_dunder(attr): - raise AttributeError(attr) - # Single-underscore introspection probes (IPython/Jupyter's - # _repr_html_, _ipython_canary_method_should_not_exist_, etc.) are - # never config keys either -- no real config key starts with '_'. - if attr not in self and not attr.startswith('_'): - self._warn_unknown_key(attr) - return self.get(attr) - - def __setattr__(self, attr: str, value: T) -> None: - # Fall back to normal object attribute storage for dunders; everything - # else keeps the dict-backed dot-notation behavior this class exists - # for. Concretely: constructing a subscripted generic, e.g. - # TupleManager[re.Pattern[str] | str](...), makes typing's - # GenericAlias.__call__ set `__orig_class__` on the new instance right - # after __init__ returns. Without this guard that assignment falls - # through to dict.__setitem__ and silently inserts a bogus - # '__orig_class__' entry into the dict itself, corrupting - # .values()/iteration. - if _is_dunder(attr): - object.__setattr__(self, attr, value) - else: - self[attr] = value - - def __delattr__(self, attr: str) -> None: - if _is_dunder(attr): - object.__delattr__(self, attr) - else: - del self[attr] - - def __getstate__(self) -> Mapping[str, T]: - return dict(self) - - def __setstate__(self, state: Mapping[str, T]) -> None: - self.update(state) - - def __reduce__(self) -> tuple[type, tuple[()], Mapping[str, T]]: - # Use type(self), not TupleManager, so subclasses such as - # RegexTupleManager survive a pickle round-trip instead of being - # downgraded to a plain TupleManager (which loses the EMPTY_REGEX - # default for unknown keys). - return (type(self), (), self.__getstate__()) - - -class RegexTupleManager(TupleManager[re.Pattern[str]]): - def __getattr__(self, attr: str) -> re.Pattern[str]: - # Otherwise EMPTY_REGEX is returned for a dunder probe; copy.deepcopy - # then tries to call the returned re.Pattern and raises TypeError. - if _is_dunder(attr): - raise AttributeError(attr) - if attr not in self and not attr.startswith('_'): - self._warn_unknown_key(attr) - return self.get(attr, EMPTY_REGEX) - - -class _SetManagerAttribute: - """Descriptor enforcing ``isinstance(value, SetManager)`` on assignment. - - Backs the five plain SetManager attributes (``first_name_titles``, - ``conjunctions``, ``bound_first_names``, ``non_first_name_prefixes``, - ``suffix_acronyms_ambiguous``). Without this guard, e.g. - ``c.conjunctions = 'and'`` is accepted silently, and every later - ``piece.lower() in self.C.conjunctions`` becomes a substring test against - the plain str instead of a set membership test (#241). - - ``_CachedUnionMember`` subclasses this to add ``_pst`` cache invalidation - for the four attributes whose union ``Constants`` caches. - """ - - _attr: str - - def __set_name__(self, owner: type, name: str) -> None: - self._attr = '_' + name - - @overload - def __get__(self, obj: None, objtype: type | None = None) -> '_SetManagerAttribute': ... - @overload - def __get__(self, obj: 'Constants', objtype: type | None = None) -> SetManager: ... - - def __get__(self, obj: 'Constants | None', objtype: type | None = None) -> 'SetManager | _SetManagerAttribute': - if obj is None: - return self - return getattr(obj, self._attr) - - def _validate(self, value: SetManager) -> None: - if not isinstance(value, SetManager): - raise TypeError( - f"Expected a SetManager instance, got {type(value).__name__!r}. " - "Wrap your iterable: SetManager(['mr', 'ms'])" - ) - - def __set__(self, obj: 'Constants', value: SetManager) -> None: - self._validate(value) - setattr(obj, self._attr, value) - - -class _CachedUnionMember(_SetManagerAttribute): - """Descriptor for the four ``SetManager`` attributes whose union ``Constants`` - caches in ``_pst`` (``prefixes``, ``suffix_acronyms``, ``suffix_not_acronyms``, - ``titles``). - - Assigning a new manager — or mutating one in place via ``add()`` / ``remove()`` - — invalidates that cache. Keeping the behavior on a descriptor scopes it to - exactly these attributes, beside their declarations, rather than spreading it - across a catch-all ``__setattr__`` and a separate attribute-name list. - """ - - def __set__(self, obj: 'Constants', value: SetManager) -> None: - self._validate(value) - previous = getattr(obj, self._attr, None) - if isinstance(previous, SetManager): - previous._on_change = None # detach the replaced manager so it no longer invalidates - value._on_change = obj._invalidate_pst - obj._invalidate_pst() - setattr(obj, self._attr, value) - - -class _EmptyAttributeDefaultAttribute: - """Descriptor backing ``Constants.empty_attribute_default``. - - .. deprecated:: 1.4.0 - Assignment is deprecated (see issue #255): the only legal value - left once ``None`` support goes in 2.0 is the default ``''``, so a - dial with one position isn't configuration. - """ - - _attr = '_empty_attribute_default' - - def __get__(self, obj: 'Constants | None', objtype: type | None = None) -> str: - # Annotated `str`, not `str | None`, to match the pre-descriptor - # plain-attribute inference: None is documented/supported (see the - # class docstring), but typing it honestly cascades `| None` - # through every public str-typed name accessor (title, first, ...). - # Returning '' rather than `self` on class access (unlike - # _SetManagerAttribute, which returns `self`) is also load-bearing - # for Constants.__repr__'s `getattr(type(self), name)` default - # comparison in _repr_scalar_attrs -- returning `self` there would - # make every Constants() show this attribute as "customized". - if obj is None: - return '' - return getattr(obj, self._attr, '') - - def __set__(self, obj: 'Constants', value: str | None) -> None: - if value is not None and not isinstance(value, str): - raise TypeError( - f"empty_attribute_default must be a str or None, got " - f"{type(value).__name__!r}" - ) - warnings.warn( - "Assigning Constants.empty_attribute_default is deprecated and " - "will raise TypeError in 2.0; empty attributes will always " - "return ''. See " - "https://github.com/derek73/python-nameparser/issues/255", - DeprecationWarning, - stacklevel=2, - ) - setattr(obj, self._attr, value) - - -class Constants: - """ - An instance of this class hold all of the configuration constants for the parser. - - :param set prefixes: - :py:attr:`prefixes` wrapped with :py:class:`SetManager`. - :param set titles: - :py:attr:`titles` wrapped with :py:class:`SetManager`. - :param set first_name_titles: - :py:attr:`~titles.FIRST_NAME_TITLES` wrapped with :py:class:`SetManager`. - :param set suffix_acronyms: - :py:attr:`~suffixes.SUFFIX_ACRONYMS` wrapped with :py:class:`SetManager`. - :param set suffix_not_acronyms: - :py:attr:`~suffixes.SUFFIX_NOT_ACRONYMS` wrapped with :py:class:`SetManager`. - :param set suffix_acronyms_ambiguous: - :py:attr:`~suffixes.SUFFIX_ACRONYMS_AMBIGUOUS` wrapped with :py:class:`SetManager`. - :param set conjunctions: - :py:attr:`conjunctions` wrapped with :py:class:`SetManager`. - :param set bound_first_names: - :py:attr:`~bound_first_names.BOUND_FIRST_NAMES` wrapped with :py:class:`SetManager`. - :param set non_first_name_prefixes: - :py:attr:`~prefixes.NON_FIRST_NAME_PREFIXES` wrapped with :py:class:`SetManager`. - The subset of prefixes that are never a first name, so a *leading* one - marks the whole name as a surname. Must stay disjoint from - ``bound_first_names``. - :type capitalization_exceptions: dict or iterable of (key, value) tuples - :param capitalization_exceptions: - :py:attr:`~capitalization.CAPITALIZATION_EXCEPTIONS` wrapped with :py:class:`TupleManager`. - :type regexes: dict or iterable of (name, compiled pattern) tuples - :param regexes: - :py:attr:`~regexes.REGEXES` wrapped with :py:class:`RegexTupleManager`. - - :py:attr:`nickname_delimiters` and :py:attr:`maiden_delimiters` are not - constructor arguments -- they're always set in ``__init__`` (see the - comment there for the string-sentinel-vs-compiled-pattern mechanism) -- - but are documented here since they're the two `Constants` attributes a - caller is most likely to want to look up: per-bucket - :py:class:`TupleManager` collections that :py:meth:`~nameparser.parser.HumanName.parse_nicknames` - consults to route delimited content into ``nickname``/``maiden``. See - the "Adding Custom Nickname Delimiters" and "Routing to Maiden Name" - sections of the customization docs. - """ - - prefixes = _CachedUnionMember() - suffix_acronyms = _CachedUnionMember() - suffix_not_acronyms = _CachedUnionMember() - titles = _CachedUnionMember() - first_name_titles = _SetManagerAttribute() - conjunctions = _SetManagerAttribute() - bound_first_names = _SetManagerAttribute() - non_first_name_prefixes = _SetManagerAttribute() - suffix_acronyms_ambiguous = _SetManagerAttribute() - capitalization_exceptions: TupleManager[str] - regexes: RegexTupleManager - nickname_delimiters: TupleManager[re.Pattern[str] | str] - maiden_delimiters: TupleManager[re.Pattern[str] | str] - _pst: Set[str] | None - - string_format: str | None = "{title} {first} {middle} {last} {suffix} ({nickname})" - """ - The default string format use for all new `HumanName` instances. - """ - - initials_format = "{first} {middle} {last}" - """ - The default initials format used for all new `HumanName` instances. - """ - - initials_delimiter = "." - """ - The default initials delimiter used for all new `HumanName` instances. - Will be used to add a delimiter between each initial. - """ - - initials_separator = " " - """ - The default separator placed between consecutive initials within a name - group (first, middle, or last). Distinct from ``initials_delimiter``, - which is the trailing character after each individual initial. - - With defaults ``initials_delimiter="."`` and ``initials_separator=" "``, - ``initials()`` produces ``"J. A. D."``. Setting ``initials_separator=""`` - with ``initials_delimiter="."`` and ``initials_format="{first}{middle}{last}"`` - produces ``"J.A.D."``. With the default ``initials_format``, group-level - spacing from the template is still applied. - """ - - suffix_delimiter: str | None = None - """ - If set, an additional delimiter used to split suffix groups after - comma-splitting. For example, setting ``suffix_delimiter=" - "`` allows - ``"RN - CRNA"`` to be parsed as two separate suffixes. Default is - ``None`` (no additional splitting beyond the standard comma split). - - Note: setting this to ``","`` or ``", "`` has no additional effect — - the full name is already split on comma characters first (including the - Arabic ``،`` and fullwidth ``,`` variants), and each resulting part is - stripped of surrounding whitespace before this step runs. - - The delimiter is only applied to parts once they've been identified as - a suffix group, so it never leaks into a first- or middle-name part. For - example, in inverted format (``"Last, First, suffix"``) a hyphenated - given name like ``"Doe, Mary - Kate, RN"`` with ``suffix_delimiter=" - "`` - does not get mistaken for a suffix split. - """ - - empty_attribute_default = _EmptyAttributeDefaultAttribute() - """ - Default return value for empty attributes. - - .. deprecated:: 1.4.0 - Assignment emits ``DeprecationWarning``; the option is removed in - 2.0 (see issue #255) and empty attributes will always return ``''``. - - .. doctest:: - - >>> import warnings - >>> from nameparser.config import CONSTANTS - >>> with warnings.catch_warnings(): - ... warnings.simplefilter('ignore', DeprecationWarning) - ... CONSTANTS.empty_attribute_default = None - >>> name = HumanName("John Doe") - >>> print(name.title) - None - >>> name.first - 'John' - >>> with warnings.catch_warnings(): - ... warnings.simplefilter('ignore', DeprecationWarning) - ... CONSTANTS.empty_attribute_default = '' - - """ - - capitalize_name = False - """ - If set, applies :py:meth:`~nameparser.parser.HumanName.capitalize` to - :py:class:`~nameparser.parser.HumanName` instance. - - .. doctest:: - - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.capitalize_name = True - >>> name = HumanName("bob v. de la macdole-eisenhower phd") - >>> str(name) - 'Bob V. de la MacDole-Eisenhower Ph.D.' - >>> CONSTANTS.capitalize_name = False - - """ - - force_mixed_case_capitalization = False - """ - If set, forces the capitalization of mixed case strings when - :py:meth:`~nameparser.parser.HumanName.capitalize` is called. - - .. doctest:: - - >>> from nameparser.config import CONSTANTS - >>> CONSTANTS.force_mixed_case_capitalization = True - >>> name = HumanName('Shirley Maclaine') - >>> name.capitalize() - >>> str(name) - 'Shirley MacLaine' - >>> CONSTANTS.force_mixed_case_capitalization = False - - """ - - patronymic_name_order = False - """ - If set, detects names in Russian formal order (``Surname GivenName Patronymic``) - by recognizing a trailing East-Slavic patronymic suffix on the last token, and - rotates the three name parts so that ``first``/``middle``/``last`` map to - given name / patronymic / surname respectively. Detection requires exactly one - token in each of first, middle, and last; names with multi-part given names or - multiple middle names are left unchanged. - - Also detects reversed-order Azerbaijani/Central-Asian Turkic patronymics - (``Surname GivenName PatronymicRoot Marker``, e.g. ``oglu``/``qizi``), a - structurally different, standalone-marker-word patronymic family. Detection - requires exactly one token in each of first and last, exactly two tokens in - middle, and the last token a recognised Turkic marker. - - Opt-in because a Western person whose surname happens to end in a patronymic - suffix (e.g. ``"David Michael Abramovich"``) will be reordered incorrectly - when the flag is on. Enable only when your data is predominantly Russian - formal-order names. - - For per-instance control without a shared ``Constants``, pass a dedicated - instance: ``HumanName("...", constants=Constants(patronymic_name_order=True))``. - - .. doctest:: - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> C = Constants(patronymic_name_order=True) - >>> hn = HumanName("Ivanov Ivan Ivanovich", constants=C) - >>> hn.first, hn.middle, hn.last - ('Ivan', 'Ivanovich', 'Ivanov') - >>> hn2 = HumanName("Aliyev Vusal Said oglu", constants=C) - >>> hn2.first, hn2.middle, hn2.last - ('Vusal', 'Said oglu', 'Aliyev') - - """ - - middle_name_as_last = False - """ - If set, folds middle names into the last name: ``middle_list`` is prepended - to ``last_list`` and ``middle_list`` is cleared, so ``.last`` becomes what - ``.surnames`` already was and ``.middle`` becomes empty. Useful for naming - systems with no middle-name concept, where everything after the given name - is lineage/family (e.g. Arabic patronymic chaining: given + father + - grandfather + family). - - The fold is uniform across both no-comma and comma ("Last, First Middle") - input, so the two written forms of a name converge on the same result. - - For per-instance control without a shared ``Constants``, pass a dedicated - instance: ``HumanName("...", constants=Constants(middle_name_as_last=True))``. - - .. doctest:: - - >>> from nameparser import HumanName - >>> from nameparser.config import Constants - >>> C = Constants(middle_name_as_last=True) - >>> hn = HumanName("Mohamad Ahmad Ali Hassan", constants=C) - >>> hn.first, hn.middle, hn.last - ('Mohamad', '', 'Ahmad Ali Hassan') - - """ - - def __init__(self, - prefixes: Iterable[str] = PREFIXES, - suffix_acronyms: Iterable[str] = SUFFIX_ACRONYMS, - suffix_not_acronyms: Iterable[str] = SUFFIX_NOT_ACRONYMS, - suffix_acronyms_ambiguous: Iterable[str] = SUFFIX_ACRONYMS_AMBIGUOUS, - titles: Iterable[str] = TITLES, - first_name_titles: Iterable[str] = FIRST_NAME_TITLES, - conjunctions: Iterable[str] = CONJUNCTIONS, - bound_first_names: Iterable[str] = BOUND_FIRST_NAMES, - non_first_name_prefixes: Iterable[str] = NON_FIRST_NAME_PREFIXES, - capitalization_exceptions: Mapping[str, str] | Iterable[tuple[str, str]] = CAPITALIZATION_EXCEPTIONS, - regexes: Mapping[str, re.Pattern[str]] | Iterable[tuple[str, re.Pattern[str]]] = REGEXES, - patronymic_name_order: bool = False, - middle_name_as_last: bool = False, - ) -> None: - # These four descriptor assignments call _CachedUnionMember.__set__, which - # calls _invalidate_pst() and establishes self._pst. They must come before - # any read of suffixes_prefixes_titles. - # untouched defaults (identity check) copy the prebuilt module-level - # managers instead of re-validating the raw constants element by - # element; user-supplied iterables still get the full check - self.prefixes = SetManager(_DEFAULT_PREFIXES if prefixes is PREFIXES else prefixes) - self.suffix_acronyms = SetManager(_DEFAULT_SUFFIX_ACRONYMS if suffix_acronyms is SUFFIX_ACRONYMS else suffix_acronyms) - self.suffix_not_acronyms = SetManager(_DEFAULT_SUFFIX_NOT_ACRONYMS if suffix_not_acronyms is SUFFIX_NOT_ACRONYMS else suffix_not_acronyms) - self.titles = SetManager(_DEFAULT_TITLES if titles is TITLES else titles) - self.first_name_titles = SetManager(_DEFAULT_FIRST_NAME_TITLES if first_name_titles is FIRST_NAME_TITLES else first_name_titles) - self.conjunctions = SetManager(_DEFAULT_CONJUNCTIONS if conjunctions is CONJUNCTIONS else conjunctions) - self.bound_first_names = SetManager(_DEFAULT_BOUND_FIRST_NAMES if bound_first_names is BOUND_FIRST_NAMES else bound_first_names) - self.non_first_name_prefixes = SetManager(_DEFAULT_NON_FIRST_NAME_PREFIXES if non_first_name_prefixes is NON_FIRST_NAME_PREFIXES else non_first_name_prefixes) - self.suffix_acronyms_ambiguous = SetManager(_DEFAULT_SUFFIX_ACRONYMS_AMBIGUOUS if suffix_acronyms_ambiguous is SUFFIX_ACRONYMS_AMBIGUOUS else suffix_acronyms_ambiguous) - self.capitalization_exceptions = TupleManager(capitalization_exceptions) - self.regexes = RegexTupleManager(regexes) - # Per-bucket delimiter collections that parse_nicknames() consults to - # route delimited content into nickname_list / maiden_list. Each value - # is either a compiled re.Pattern (a custom delimiter a caller adds -- - # the old extra_nickname_delimiters use case, see issue #112) or the - # string name of a self.regexes entry to resolve live at parse time. - # The latter is how the three built-ins (quoted_word, double_quotes, - # parenthesis) stay linked to self.regexes, so overriding e.g. - # self.regexes.parenthesis keeps affecting nickname/maiden parsing - # exactly as before. Move a key between the two dicts - # (`maiden_delimiters['parenthesis'] = - # nickname_delimiters.pop('parenthesis')`) to change which bucket it - # routes to without losing that live link. maiden_delimiters starts - # empty -- maiden is off until a caller routes a delimiter to it. - # See issue #22. - # Only seed a built-in name if it's actually present in self.regexes -- - # a caller who overrides regexes with a minimal custom set (dropping - # e.g. "parenthesis" entirely) shouldn't end up with a dangling - # string sentinel that parse_nicknames() would treat as a mistake. - # See parse_nicknames()'s fail-loud check on an unresolvable sentinel. - self.nickname_delimiters = TupleManager[re.Pattern[str] | str]({ - name: name for name in ('quoted_word', 'double_quotes', 'parenthesis') - if name in self.regexes - }) - self.maiden_delimiters = TupleManager[re.Pattern[str] | str]() - self.patronymic_name_order = patronymic_name_order - self.middle_name_as_last = middle_name_as_last - - def _invalidate_pst(self) -> None: - self._pst = None - - @property - def suffixes_prefixes_titles(self) -> Set[str]: - if self._pst is None: - self._pst = self.prefixes | self.suffix_acronyms | self.suffix_not_acronyms | self.titles - return self._pst - - _repr_collection_attrs = ( - 'prefixes', 'suffix_acronyms', 'suffix_not_acronyms', 'titles', - 'first_name_titles', 'conjunctions', 'bound_first_names', - 'non_first_name_prefixes', 'suffix_acronyms_ambiguous', - ) - _repr_scalar_attrs = ( - 'string_format', 'initials_format', 'initials_delimiter', - 'initials_separator', 'suffix_delimiter', 'empty_attribute_default', - 'capitalize_name', 'force_mixed_case_capitalization', - 'patronymic_name_order', 'middle_name_as_last', - ) - - def __repr__(self) -> str: - # Collections (some with hundreds of entries, e.g. titles/prefixes) - # are summarized as counts rather than dumped in full. Scalars are - # only shown when they differ from the class default, so a plain - # Constants() reads as just the collection sizes. - lines = [f" {name}: {len(getattr(self, name))}" for name in self._repr_collection_attrs] - lines += [ - f" {name}: {value!r}" for name in self._repr_scalar_attrs - if (value := getattr(self, name)) != getattr(type(self), name) - ] - return "" - - def copy(self) -> 'Constants': - """ - Return a detached deep copy of this ``Constants`` instance, preserving - its current customizations -- unlike :py:class:`Constants`'s own - constructor, which always starts from library defaults. Useful for - snapshotting the shared module-level ``CONSTANTS`` (including - whatever it's been customized with) into a private instance, e.g. - ``CONSTANTS.copy()``. Relies on the same ``__getstate__``/``__setstate__`` - pair pickling uses, so it's as cheap and correct as pickle round-tripping. - """ - return copy.deepcopy(self) - - def __setstate__(self, state: Mapping[str, Any]) -> None: - # Restore each saved attribute directly. The previous implementation - # passed the whole state dict to __init__ as the ``prefixes`` argument, - # which silently reverted every collection to its module default on - # unpickling. - self._pst = None - legacy_format = False - for name, value in state.items(): - # inspect.getattr_static, not getattr, so descriptors are - # inspected directly rather than triggering their __get__. - descriptor = inspect.getattr_static(type(self), name, None) - # Migration shim: pickles written before this fix (1.3.0 and earlier, - # including 1.2.1) used a dir() sweep for __getstate__, so their state - # carries the read-only ``suffixes_prefixes_titles`` property. Skip any - # such computed property rather than raising AttributeError on its - # missing setter; the real config is restored from the other keys. We - # don't promise to read pre-fix blobs forever — this only smooths - # migration for anyone persisting them, and can be dropped a release - # or two after 1.3.0 once they've re-pickled. - if isinstance(descriptor, property): - legacy_format = True - continue - if isinstance(descriptor, _EmptyAttributeDefaultAttribute): - # Bypass the descriptor's setter: restoring saved state isn't - # a user assignment, so it shouldn't emit #255's deprecation - # warning on every unpickle/copy() of a customized instance. - setattr(self, descriptor._attr, value) - continue - setattr(self, name, value) - if legacy_format: - # Once per __setstate__ call, not once per skipped key (see - # issue #279): the 2.0 removal turns this into a ValueError - # naming the same fix. - warnings.warn( - "Loading a legacy-format Constants pickle (written by " - "nameparser <= 1.2.x, before the 1.3.0 pickle fix) is " - "deprecated and will raise ValueError in 2.0; re-pickle " - "under 1.3/1.4 to migrate. See " - "https://github.com/derek73/python-nameparser/issues/279", - DeprecationWarning, - stacklevel=2, - ) - # Verify each descriptor-backed attr was restored. Without this, a missing - # key surfaces later as AttributeError: 'Constants' object has no attribute - # '_prefixes' — the private mangled name, not the public one, making it - # very hard to diagnose. - for attr in (n for n, v in vars(type(self)).items() if isinstance(v, _SetManagerAttribute)): - if not hasattr(self, '_' + attr): - raise ValueError( - f"Pickle state is missing required field {attr!r}. " - "The state blob may be truncated or from an incompatible version." - ) - - def __getstate__(self) -> Mapping[str, Any]: - # Pickle the instance's own configuration: the collections built in - # __init__ plus any instance-level scalar overrides. - # _CachedUnionMember descriptors store their values with a leading - # underscore (e.g. `_prefixes` for `prefixes`) so that the descriptor's - # __set__ owns assignment. We map those back to the public names so - # __setstate__ can restore them through the descriptor, re-wiring the - # invalidation callbacks. All other underscore-prefixed names (_pst, etc.) - # are private/cache and are intentionally excluded. - state: dict[str, Any] = {} - for name, val in self.__dict__.items(): - if name.startswith('_'): - public = name[1:] - descriptor = inspect.getattr_static(type(self), public, None) - if isinstance(descriptor, (_SetManagerAttribute, _EmptyAttributeDefaultAttribute)): - state[public] = val - else: - state[name] = val - return state - - -#: A module-level instance of the :py:class:`Constants()` class. -#: Provides a common instance for the module to share -#: to easily adjust configuration for the entire module. -#: See `Customizing the Parser with Your Own Configuration `_. -CONSTANTS = Constants() +__all__ = ["CONSTANTS", "Constants", "RegexTupleManager", "SetManager", "TupleManager"] diff --git a/nameparser/config/bound_first_names.py b/nameparser/config/bound_first_names.py index 07bd2bce..d0602417 100644 --- a/nameparser/config/bound_first_names.py +++ b/nameparser/config/bound_first_names.py @@ -9,4 +9,15 @@ 'abu', 'abou', 'umm', + + # #269 follow-up: the Arabic-script originals of the entries above. + # Script writes "Abdul Rahman" as two words (عبد + الرحمن -- the + # article attaches to the following word), so عبد alone covers the + # abdul/abdel/abdal variants. Both kunya spellings ship, matching + # the أبو/ابو prefix pair. + 'عبد', # "abd" (servant of) -- عبد الرحمن -> given "عبد الرحمن" + 'أبو', # "abu" (father of), hamza spelling + 'ابو', # "abu", hamza-less spelling + 'أم', # "umm" (mother of), hamza spelling + 'ام', # "umm", hamza-less spelling } diff --git a/nameparser/config/conjunctions.py b/nameparser/config/conjunctions.py index 003f8c89..52f01b34 100644 --- a/nameparser/config/conjunctions.py +++ b/nameparser/config/conjunctions.py @@ -7,6 +7,19 @@ 'the', 'und', 'y', + # #269: Cyrillic (ru/uk/bg) "and": и, і, та. + 'и', + 'і', + 'та', + # #269: Greek "and": και. + 'και', + # #269 follow-up: Arabic "and". Formal script attaches و to the + # following word (وفاطمة), so a standalone و token appears only in + # informal spacing -- common in real data. Single-character like + # 'y'/'и': the single-letter carve-out (Google Code issue 11, + # the "john e smith" bug) protects short names (joins + # only with enough rootname pieces). + 'و', } """ Pieces that should join to their neighboring pieces, e.g. "and", "y" and "&". diff --git a/nameparser/config/maiden_markers.py b/nameparser/config/maiden_markers.py new file mode 100644 index 00000000..637028b1 --- /dev/null +++ b/nameparser/config/maiden_markers.py @@ -0,0 +1,37 @@ +MAIDEN_MARKERS = { + 'née', + 'né', + 'nee', + 'geb', + 'geborene', + 'geboren', + 'roz', + 'rozená', + 'født', + 'fødd', + 'född', + 'урожд', + 'урождённая', + 'урожденная', + 'урождённый', + 'урожденный', +} +""" +Marker words that introduce a birth surname, e.g. "Jane Smith née Jones" +(#274). French née/né/nee, German geb./geborene, Dutch geboren, +Czech/Slovak roz./rozená, Danish/Norwegian født (Nynorsk fødd), Swedish +född, Russian урожд./урождённая/урождённый (both ё and е spellings — +case normalization does not fold them, and running text routinely +writes е). Both grammatical genders are listed where #274 or review +attested them (née/né, урождённая/урождённый); Czech masculine rozený +awaits the same vetting. Entries are stored normalized: lowercase, no +periods. + +Consumed by the 2.0 parser's default lexicon. The 1.x parser does not +read this module. + +Deliberately absent: Polish "z domu" (a two-token marker; pending the +2.0 pipeline's multi-token matching decision) and the Scandinavian +abbreviation "f." (collides with the initial "F." — only the full +participles are safe). +""" diff --git a/nameparser/config/prefixes.py b/nameparser/config/prefixes.py index 43ee5414..efa92bac 100644 --- a/nameparser/config/prefixes.py +++ b/nameparser/config/prefixes.py @@ -32,6 +32,30 @@ 'vd', 'vom', 'zu', + + # #269: Arabic native-script patronymic/clan particles. Unlike their + # Latin transliterations, these live in a script namespace with no + # collision against an unrelated Latin given name, so each is judged + # on its own semantics rather than mirrored blindly: + 'بن', # "bin"/"ibn" (son of) -- never a bare given name. Latin + # 'bin' is in PREFIXES but not in this set; that judgment + # is unchanged by adding the Arabic-script form. + 'بنت', # "bint" (daughter of) -- mirrors Latin 'bint' above. + 'ابن', # "ibn" (son of, alternate spelling) -- mirrors Latin + # 'ibn' above. + 'آل', # "aal" (family/clan of, e.g. "Al Saud") -- distinct from + # the excluded definite article "ال" (#269 explicitly + # excludes standalone "ال"); a clan prefix, never a bare + # given name. + + # #269: Hebrew native-script patronymic particles -- same + # reasoning as the Arabic ones above: no Latin-script collision, + # and neither functions as a standalone given name in Hebrew usage. + # Deferred under the collision rule: 'בר' (Aramaic son-of, as in + # Bar-Lev) -- Bar is a common modern Israeli given name, and the + # surname spelling is hyphenated anyway. + 'בן', # "ben" (son of) + 'בת', # "bat" (daughter of) } #: Name pieces that appear before a last name. Prefixes join to the piece @@ -86,6 +110,13 @@ 'vander', 'vel', 'von', + + # #269: Arabic "abu" (father of), left ambiguous like its Latin + # transliteration 'abu' above (both spellings): "Abu Bakr" reads + # "Abu" as a given name, so this stays a PREFIXES-only member, not + # NON_FIRST_NAME_PREFIXES. + 'أبو', + 'ابو', } # Guard the two invariants the docstring above promises, so a future edit that diff --git a/nameparser/config/regexes.py b/nameparser/config/regexes.py index 792e826e..17716df3 100644 --- a/nameparser/config/regexes.py +++ b/nameparser/config/regexes.py @@ -1,10 +1,13 @@ import re -# emoji regex from https://stackoverflow.com/questions/26568722/remove-unicode-emoji-using-re-in-python -re_emoji = re.compile('[' # lgtm[py/overly-large-range] - '\U0001F300-\U0001F64F' - '\U0001F680-\U0001F6FF' - '\u2600-\u26FF\u2700-\u27BF]+') +# emoji ranges from https://stackoverflow.com/questions/26568722/remove-unicode-emoji-using-re-in-python +# Built from codepoint pairs rather than a literal character class: +# the compiled pattern is identical, but CodeQL's py/overly-large-range +# false-positives on literal astral ranges (surrogate decomposition). +_EMOJI_RANGES = ((0x1F300, 0x1F64F), (0x1F680, 0x1F6FF), + (0x2600, 0x26FF), (0x2700, 0x27BF)) +re_emoji = re.compile( + '[' + ''.join(f'{chr(lo)}-{chr(hi)}' for lo, hi in _EMOJI_RANGES) + ']+') # Invisible bidirectional formatting characters: ALM, LRM, RLM, the # embedding/override marks (LRE/RLE/PDF/LRO/RLO) and the isolates diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 41bb6fdf..3582df0d 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -1,4 +1,9 @@ SUFFIX_NOT_ACRONYMS = { + # #269: Cyrillic мл/ст (junior/senior, the jr/sr analogs) deferred + # pending the within-script collision vetting the issue asks for; + # 'ст' especially is a plausible false-positive risk (many two- + # letter Cyrillic abbreviations exist), and both are short enough + # to worry about. Not shipped in this pass. 'dr', 'esq', 'esquire', @@ -21,6 +26,14 @@ # literally instead of going through nickname/suffix disambiguation). 'ret', 'vet', + + # #269 follow-up: Hebrew post-nominals, both gershayim spellings + # (ASCII '"' and U+05F4); the mid-word quote is inert in + # extraction, like the ד"ר title. Neither is ever a name. + 'ז"ל', # "of blessed memory" (deceased), ASCII quote + 'ז״ל', # same, U+05F4 gershayim + 'שליט"א', # honorific for a living rabbi, ASCII quote + 'שליט״א', # same, U+05F4 gershayim } """ @@ -37,11 +50,17 @@ # in ambiguous, delimiter-only context. # # When adding a new entry to SUFFIX_ACRONYMS, also add it here only if - # the exact letter sequence could plausibly be someone's given name or - # common nickname on its own (e.g. 'jd', 'ed'). Unambiguous - # certifications/degrees (e.g. 'mba', 'cpa', 'phd') don't need an entry. + # the exact letter sequence could plausibly be someone's name on its + # own -- a given name or nickname (e.g. 'jd', 'ed') or a common + # surname (e.g. 'ma', 'do'). Unambiguous certifications/degrees + # (e.g. 'mba', 'cpa', 'phd') don't need an entry. In 2.0 this set + # also gates bare recognition: an ambiguous acronym counts as a + # suffix only when written with periods ('M.A.' yes, 'Ma' no), so + # 'Jack Ma' keeps its family name. + 'do', 'ed', 'jd', + 'ma', } """ diff --git a/nameparser/config/titles.py b/nameparser/config/titles.py index dfc88daa..0f1d9c86 100644 --- a/nameparser/config/titles.py +++ b/nameparser/config/titles.py @@ -21,6 +21,31 @@ 'shaikh', 'cheikh', 'shekh', + # #269: Arabic -- "الشيخ" ("the sheikh") is the native-script form of + # the transliterated sheikh/sheik/... cluster above; same + # single-first-name-follows convention ("Sheikh Mohammed"). + 'الشيخ', + # #269 follow-up: Arabic honorifics precede the GIVEN name, so all + # belong here rather than in plain TITLES. Article forms are never + # given names; the bare doctor/professor/engineer forms are not + # used as given names either. Deferred under the collision rule + # (like мл/ст in suffixes.py): bare سيد (Sayyid is a common given + # name), bare شيخ (Shaikha is a common female given name), أمير + # and سلطان (Amir/Sultan, common given names), and the 'د.' + # abbreviation -- edge-period normalization would leave bare 'د', + # which swallows single-letter initials (the bare-κ trap above). + 'الدكتور', # "the doctor" (m) + 'الدكتورة', # "the doctor" (f) + 'دكتور', # doctor (m), article-less + 'دكتورة', # doctor (f), article-less + 'الأستاذ', # "the professor"/Mr. honorific (m) + 'الأستاذة', # professor/Mrs. honorific (f) + 'أستاذ', # professor (m), article-less + 'أستاذة', # professor (f), article-less + 'الحاج', # hajj honorific (m) + 'الحاجة', # hajj honorific (f) + 'الشيخة', # female counterpart of الشيخ + 'مهندس', # engineer (a genuine title in Egyptian usage) } """ When these titles appear with a single other name, that name is a first name, e.g. @@ -683,4 +708,65 @@ 'woodman', 'writer', 'zoologist', + + # #269: Cyrillic (ru/uk) -- mr/mrs/dr/prof/academician/pan(i) + # honorifics, same title-then-family convention as 'mr'/'dr'/'prof' + # above (not FIRST_NAME_TITLES: "г-н Петров" families the surname + # just like "Mr. Smith" does). + 'г-н', + 'г-жа', + 'д-р', + 'проф', + 'акад', + 'пан', + 'пані', + + # #269: Greek -- kyria/kyrios(abbr)/doctor/professor abbreviations; + # same plain-title convention as above. + # #269: bare κ deferred -- collides with the initial+surname shape + # ('Κ. Παπαδόπουλος' would parse as title 'Κ.' with an empty given; + # _normalize strips the edge period, so the entry matches the + # initial). Latin TITLES deliberately has no bare single-letter + # entries for the same reason. + 'κα', + 'κος', + 'δρ', + 'καθ', + + # #269: Hebrew -- "מר" ("Mr."), plain title like its Latin analog. + # Geresh/gershayim forms of "doctor"/"Mrs." -- both the ASCII-quote + # spelling ('ד"ר', "גב'") and the typographic Unicode spelling + # ('ד״ר' U+05F4 gershayim, 'גב׳' U+05F3 geresh) ship: probed live + # against extract_delimited's _open_ok/_close_ok boundary rules + # (2026-07-17) -- the quote chars sit mid-word (no preceding + # whitespace before the internal quote and, for the closing "'" one, + # no following boundary), so both fail the open/close boundary test + # and are left untouched as literal text. Extraction is provably + # inert on these two ASCII spellings; no delimiter-interaction risk. + 'מר', + 'ד"ר', + "גב'", + 'ד״ר', + 'גב׳', + # #269 follow-up: the rest of the common Israeli honorifics, same + # plain-title bucket (family follows) and the same dual geresh/ + # gershayim spelling rule as above. Deferred under the collision + # rule: bare 'רב' (also the ordinary word "many"). The 'בר' + # particle deferral is recorded in prefixes.py with the other + # particle decisions. + 'גברת', # Mrs./Ms., full form + "פרופ'", # professor abbreviation, ASCII apostrophe + 'פרופ׳', # professor abbreviation, U+05F3 geresh + 'פרופסור', # professor, full form + 'עו"ד', # advocate/lawyer, ASCII quote + 'עו״ד', # advocate/lawyer, U+05F4 gershayim + 'הרב', # "the rabbi" (article form; bare רב deferred) + + # #269 follow-up: Devanagari (hi/mr). NO Latin twins on purpose: + # transliterated sri/shri collide with real given names (Sri + # Mulyani); the native-script forms cannot. "डॉ." matches via the + # edge-period normalization, like Latin "Dr.". + 'श्री', # Shri (Mr.) + 'श्रीमती', # Shrimati (Mrs.) + 'डॉ', # Dr. abbreviation } diff --git a/nameparser/locales/__init__.py b/nameparser/locales/__init__.py new file mode 100644 index 00000000..7b9a99d5 --- /dev/null +++ b/nameparser/locales/__init__.py @@ -0,0 +1,62 @@ +"""Locale packs: named (lexicon fragment, PolicyPatch) deltas folded in +by parser_for (locales spec §2). Packs are pure data with no +privileged capabilities; they dissolve at parser construction. + +Loaded lazily (PEP 562): importing nameparser.locales never imports a +pack module; ``locales.RU`` triggers ``nameparser.locales.ru`` on +first access and caches the Locale here. + +Layering: this package imports pack modules, which import _locale/ +_lexicon/_policy only (enforced by tests/v2/test_layering.py). +""" +from __future__ import annotations + +import importlib + +from nameparser._locale import Locale + +#: attribute name -> (module name, module attribute). Codes are the +#: lowercase module names; attribute constants are uppercase (spec §2). +_REGISTRY = { + "RU": ("nameparser.locales.ru", "RU"), + "TR_AZ": ("nameparser.locales.tr_az", "TR_AZ"), +} + + +def __getattr__(name: str) -> Locale: + entry = _REGISTRY.get(name) + if entry is None: + raise AttributeError( + f"module {__name__!r} has no locale {name!r}; available: " + f"{', '.join(sorted(_REGISTRY))}" + ) + module_name, attr = entry + locale: Locale = getattr(importlib.import_module(module_name), attr) + # cache: next access skips __getattr__. Benign race under free + # threading: two threads racing here both import the same module + # object (import machinery serializes/dedupes that) and assign the + # same singleton Locale back to the same name, so the last write + # wins with no observable difference. + globals()[name] = locale + return locale + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_REGISTRY)) + + +def available() -> tuple[str, ...]: + """The registered locale codes, lowercase, sorted.""" + return tuple(sorted(attr.lower() for attr in _REGISTRY)) + + +def get(code: str) -> Locale: + """Dynamic lookup by code ('ru'); raises KeyError listing the + available codes (spec §2).""" + attr = code.upper() if isinstance(code, str) else code + if not isinstance(code, str) or attr not in _REGISTRY: + raise KeyError( + f"unknown locale code {code!r}; available: " + f"{', '.join(available())}" + ) + return __getattr__(attr) diff --git a/nameparser/locales/ru.py b/nameparser/locales/ru.py new file mode 100644 index 00000000..cea49b99 --- /dev/null +++ b/nameparser/locales/ru.py @@ -0,0 +1,59 @@ +"""The Russian locale pack (locales spec §3): policy-only -- it turns +on the EAST_SLAVIC patronymic rule. The morphology data (-ovich/-ovna +endings, Cyrillic and transliterated) lives inside the rule +implementation in nameparser/_pipeline/_post_rules.py, not in the +Lexicon (mirrors v1's patronymic_name_order flag design, v1.3.0). +Cyrillic titles/conjunctions are default-lexicon vocabulary (#269), +not pack data (spec §2 sorting rule). + +Data sources: the v1.3.0 patronymic rule (PR #154 discussion and the +east-slavic test bank); no external lists -- the pack itself carries +no vocabulary. + +Declared deviations (spec §2 authoring requirement 3): applying this +pack changes only NO_COMMA names whose final token carries an East +Slavic patronymic ending while the middle token does not -- +DEVIATES(name) below is the machine-readable declaration the +non-interference gate checks. +""" +from __future__ import annotations + +import re + +from nameparser._lexicon import Lexicon +from nameparser._locale import Locale +from nameparser._policy import PatronymicRule, PolicyPatch + +RU = Locale( + code="ru", + lexicon=Lexicon.empty(), + policy=PolicyPatch( + patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})), +) + +# Ported by hand from nameparser/_pipeline/_post_rules.py's +# _EAST_SLAVIC/_EAST_SLAVIC_CYR (the rule is the source of truth; this +# predicate only DECLARES the deviation surface for the non-interference +# gate -- layering forbids importing the pipeline from a pack). Keep +# both alternations byte-identical to those two patterns. +_EAST_SLAVIC = re.compile( + r"(ovich|ovna|evich|evna|ichna|ilyich|kuzmich|lukich|fomich|fokich)$", + re.I) +_EAST_SLAVIC_CYR = re.compile( + r"(ович|овна|евич|евна|ична|ильич|кузьмич|лукич|фомич|фокич)$", re.I) + + +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).""" + # Scan per token: the rule fires on the family-POSITION token, not + # the name's final characters, so a whole-string search would miss + # 'Ivan Petr Sidorovich Jr.' (a suffix after the ending -> the $ + # anchor never lands: under-declaration, the unsafe direction for + # the gate). Per-token scanning instead OVER-declares (e.g. + # 'Sidorovich Anna' matches though the rule's 1+1+1 shape never + # fires) -- the safe direction: DEVIATES may claim more than the + # rule changes, never less. + return any(_EAST_SLAVIC.search(tok) or _EAST_SLAVIC_CYR.search(tok) + for tok in name.split()) diff --git a/nameparser/locales/tr_az.py b/nameparser/locales/tr_az.py new file mode 100644 index 00000000..7e2133a6 --- /dev/null +++ b/nameparser/locales/tr_az.py @@ -0,0 +1,49 @@ +"""The Turkish/Azerbaijani locale pack (locales spec §3): policy-only +-- it turns on the TURKIC patronymic rule. The marker data (oglu/qizi/ +uulu/kyzy and Cyrillic forms) lives inside the rule implementation in +nameparser/_pipeline/_post_rules.py (mirrors v1's flag design). + +Data sources: the v1 Turkic patronymic rule (issue #185 and its test +bank, tests/test_turkic_patronymic_order.py); the pack carries no +vocabulary. + +Declared deviations (spec §2 authoring requirement 3): applying this +pack changes only NO_COMMA names where some token is a standalone +Turkic patronymic marker -- DEVIATES(name) below is the +machine-readable declaration the non-interference gate checks. +""" +from __future__ import annotations + +import re + +from nameparser._lexicon import Lexicon +from nameparser._locale import Locale +from nameparser._policy import PatronymicRule, PolicyPatch + +TR_AZ = Locale( + code="tr_az", + lexicon=Lexicon.empty(), + policy=PolicyPatch( + patronymic_rules=frozenset({PatronymicRule.TURKIC})), +) + +# Ported by hand from nameparser/_pipeline/_post_rules.py's +# _TURKIC/_TURKIC_CYR (the rule is the source of truth; this predicate +# only DECLARES the deviation surface for the non-interference gate -- +# layering forbids importing the pipeline from a pack). Keep both +# alternations byte-identical to those two patterns. Note both are +# fullmatch-shaped (^...$): the rule tests a single WHOLE token, so +# DEVIATES below scans per-token rather than searching the whole name. +_TURKIC = re.compile( + r"^(oglu|oğlu|ogly|ogli|o['’ʻ]g['’ʻ]li" + r"|qizi|qızı|kizi|kyzy|gyzy|uly|uulu)$", re.I) +_TURKIC_CYR = re.compile( + r"^(оглу|оглы|оғлу|ўғли|угли|кызы|гызы|қызы|қизи|улы|ұлы|уулу)$", re.I) + + +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(_TURKIC.match(tok) or _TURKIC_CYR.match(tok) + for tok in name.split()) diff --git a/nameparser/parser.py b/nameparser/parser.py index 1e1e7865..aa85bfe4 100644 --- a/nameparser/parser.py +++ b/nameparser/parser.py @@ -1,1654 +1,6 @@ -import re -import warnings -from collections.abc import Callable, Iterable, Iterator -from operator import itemgetter -from itertools import groupby +"""v1 import-path preservation (migration spec §3): the 2.0 HumanName +facade lives in nameparser._facade. This module is deleted in 3.0. +""" +from nameparser._facade import HumanName as HumanName -from typing import overload - -from nameparser.util import HumanNameAttributeT, lc -from nameparser.util import log -from nameparser.config import CONSTANTS -from nameparser.config import Constants -from nameparser.config import DEFAULT_ENCODING - -def group_contiguous_integers(data: Iterable[int]) -> list[tuple[int, int]]: - """ - return list of tuples containing first and last index - position of contiguous numbers in a series - """ - ranges: list[tuple[int, int]] = [] - for key, group_with_indices in groupby(enumerate(data), lambda i: i[0] - i[1]): - group = list(map(itemgetter(1), group_with_indices)) - if len(group) > 1: - ranges.append((group[0], group[-1])) - return ranges - - -class HumanName: - """ - Parse a person's name into individual components. - - Instantiation assigns to ``full_name``, and assignment to - :py:attr:`full_name` triggers :py:func:`parse_full_name`. After parsing the - name, these instance attributes are available. Alternatively, you can pass - any of the instance attributes to the constructor method and skip the parsing - process. If any of the the instance attributes are passed to the constructor - as keywords, :py:func:`parse_full_name` will not be performed. - - **HumanName Instance Attributes** - - * :py:attr:`title` - * :py:attr:`first` - * :py:attr:`middle` - * :py:attr:`last` - * :py:attr:`suffix` - * :py:attr:`nickname` - * :py:attr:`maiden` - * :py:attr:`surnames` - * :py:attr:`given_names` - - :param str full_name: The name string to be parsed. - :param constants: - a :py:class:`~nameparser.config.Constants` instance (subclasses are - honored). Defaults to the shared module-level ``CONSTANTS``. For - `per-instance config `_, pass ``Constants()`` for - fresh library defaults, or ``CONSTANTS.copy()`` for a private - snapshot of the current shared config. Passing ``None`` also builds - a fresh ``Constants()``, but is deprecated (warns; raises - ``TypeError`` in 2.0, see issue #260) since it silently discards any - customizations the caller may have expected to carry over. Anything - else raises ``TypeError``. - :param str encoding: string representing the encoding of your input - (deprecated with ``bytes`` input, removal in 2.0 — decode before - passing; see issue #245) - :param str string_format: python string formatting - :param str initials_format: python initials string formatting - :param str initials_delimter: string delimiter for initials - :param str initials_separator: string separator between consecutive initials - :param str suffix_delimiter: additional delimiter to split post-comma parts - before suffix detection, e.g. ``" - "`` for ``"RN - CRNA"`` - :param str first: first name - :param str middle: middle name - :param str last: last name - :param str title: The title or prenominal - :param str suffix: The suffix or postnominal - :param str nickname: Nicknames - :param str maiden: Maiden name - """ - - original: str | bytes = '' - """ - The original string, untouched by the parser. - """ - - _members = ['title', 'first', 'middle', 'last', 'suffix', 'nickname', 'maiden'] - _full_name = '' - - title_list: list[str] - first_list: list[str] - middle_list: list[str] - last_list: list[str] - suffix_list: list[str] - nickname_list: list[str] - maiden_list: list[str] - _had_comma: bool - - def __init__( - self, - full_name: str | bytes = "", - constants: Constants | None = CONSTANTS, - encoding: str = DEFAULT_ENCODING, - string_format: str | None = None, - initials_format: str | None = None, - initials_delimiter: str | None = None, - initials_separator: str | None = None, - suffix_delimiter: str | None = None, - first: str | list[str] | None = None, - middle: str | list[str] | None = None, - last: str | list[str] | None = None, - title: str | list[str] | None = None, - suffix: str | list[str] | None = None, - nickname: str | list[str] | None = None, - maiden: str | list[str] | None = None, - ) -> None: - # calls _validate_constants directly (not through the C setter) so - # the deprecation warning below attributes to this constructor's - # caller rather than to the setter, mirroring _apply_full_name below - self._C = self._validate_constants(constants, stacklevel=3) - - # Lookup entries derived while parsing this instance (period-joined - # titles/suffixes like "Lt.Gov.", conjunction-joined pieces like - # "Mr. and Mrs." or "von und zu"). Kept separate from self.C so that - # parsing never writes into the config — which is usually the shared - # module-level CONSTANTS — keeping results independent of what was - # parsed before and config reads safe across threads. Values are - # lc()-normalized, mirroring how SetManager stores them. Reset at the - # start of each parse_full_name() run. - self._derived_titles: set[str] = set() - self._derived_suffixes: set[str] = set() - self._derived_conjunctions: set[str] = set() - self._derived_prefixes: set[str] = set() - - self.encoding = encoding - self.string_format = string_format if string_format is not None else self.C.string_format - self.initials_format = initials_format if initials_format is not None else self.C.initials_format - self.initials_delimiter = initials_delimiter if initials_delimiter is not None else self.C.initials_delimiter - self.initials_separator = initials_separator if initials_separator is not None else self.C.initials_separator - self.suffix_delimiter = suffix_delimiter if suffix_delimiter is not None else self.C.suffix_delimiter - self._had_comma = False - if (first or middle or last or title or suffix or nickname or maiden): - self.first = first - self.middle = middle - self.last = last - self.title = title - self.suffix = suffix - self.nickname = nickname - self.maiden = maiden - else: - # calls _apply_full_name directly (not the setter) so the - # deprecation warning below attributes to this constructor's - # caller rather than to the setter - self._apply_full_name(full_name, stacklevel=3) - - @staticmethod - def _validate_constants(constants: 'Constants | None', *, stacklevel: int) -> 'Constants': - # Shared by the constructor and the C setter so both assignment paths - # give the same immediate TypeError instead of one bypassing the - # other and failing far from the cause (#239). - if constants is None: - # deprecated 1.4.0, raises TypeError in 2.0 (#260, removal #261): - # None means "build a fresh private Constants()", the opposite of - # what None conventionally means (the default is the *shared* - # CONSTANTS) -- an easy trap since customizing CONSTANTS then - # passing None elsewhere silently drops those customizations with - # no error. CONSTANTS.copy() is the explicit spelling for the - # other reading: a private snapshot of the current shared config. - warnings.warn( - "Passing constants=None is deprecated and will raise " - "TypeError in 2.0; use constants=Constants() for fresh " - "library defaults, or constants=CONSTANTS.copy() to snapshot " - "the current shared config. See " - "https://github.com/derek73/python-nameparser/issues/260", - DeprecationWarning, - stacklevel=stacklevel, - ) - return Constants() - if not isinstance(constants, Constants): - # passing the class itself is the likeliest mistake, and - # reporting it as "got type" would only add confusion - hint = (" (a class was passed; did you mean Constants()?)" - if isinstance(constants, type) else "") - raise TypeError( - "constants must be a Constants instance or None, " - f"got {type(constants).__name__}{hint}" - ) - return constants - - @property - def C(self) -> 'Constants': - """ - A reference to the configuration for this instance, which may or may not be - a reference to the shared, module-wide instance at - :py:mod:`~nameparser.config.CONSTANTS`. See `Customizing the Parser - `_. - - Assigning a non-``Constants`` value (besides ``None``, which builds a - fresh private ``Constants()`` and emits a ``DeprecationWarning`` -- - see :py:meth:`~nameparser.parser.HumanName.__init__`) raises the same - ``TypeError`` as passing an invalid ``constants`` argument to the - constructor (#239). - """ - return self._C - - @C.setter - def C(self, constants: 'Constants | None') -> None: - self._C = self._validate_constants(constants, stacklevel=3) - - def __getstate__(self) -> dict: - state = self.__dict__.copy() - c = state.pop('_C') - state['C'] = None if c is CONSTANTS else c # sentinel: restore shared singleton on load - return state - - def __setstate__(self, state: dict) -> None: - state = dict(state) - c = state.pop('C', None) - self._C = CONSTANTS if c is None else c - self.__dict__.update(state) - # pickles from before the per-parse derived sets existed lack them; - # backfill so the is_* predicates work without a re-parse - for attr in ('_derived_titles', '_derived_suffixes', - '_derived_conjunctions', '_derived_prefixes'): - self.__dict__.setdefault(attr, set()) - - def __iter__(self) -> Iterator[str]: - return (value for member in self._members - if (value := getattr(self, member))) - - def __len__(self) -> int: - return sum(1 for member in self._members if getattr(self, member)) - - def __eq__(self, other: object) -> bool: - """ - .. deprecated:: 1.3.0 - Removed in 2.0 (see issue #223); use :py:meth:`matches`. - - HumanName instances are equal to other objects whose - lower case unicode representation is the same. Note the - differences from :py:meth:`matches`: this compares formatted - output, so it depends on ``string_format`` and cannot see - ``maiden``, and it stringifies operands of any type. - """ - warnings.warn( - "HumanName == comparison is deprecated and will be removed in " - "2.0; use matches() instead. See " - "https://github.com/derek73/python-nameparser/issues/223", - DeprecationWarning, - stacklevel=2, - ) - return str(self).lower() == str(other).lower() - - @overload - def __getitem__(self, key: slice) -> list[str]: ... - @overload - def __getitem__(self, key: str) -> str: ... - def __getitem__(self, key: slice | str) -> str | list[str]: - """ - .. deprecated:: 1.4.0 - Slice access (``name[1:-3]``) is removed in 2.0 (see issue - #258); field access by position has no real use case. - String-key access (``name['first']``) is unaffected. - """ - if isinstance(key, slice): - warnings.warn( - "Slicing a HumanName by position is deprecated and will be " - "removed in 2.0; access the named attributes instead. See " - "https://github.com/derek73/python-nameparser/issues/258", - DeprecationWarning, - stacklevel=2, - ) - return [getattr(self, x) for x in self._members[key]] - else: - return getattr(self, key) - - def __setitem__(self, key: str, value: str | list[str] | None) -> None: - """ - .. deprecated:: 1.4.0 - Removed in 2.0 (see issue #258); it duplicates plain attribute - assignment. Use ``name.first = value`` instead. - """ - warnings.warn( - "HumanName item assignment is deprecated and will be removed " - "in 2.0; it duplicates plain attribute assignment, use " - "name.first = value instead. See " - "https://github.com/derek73/python-nameparser/issues/258", - DeprecationWarning, - stacklevel=2, - ) - if key in self._members: - self._set_list(key, value) - else: - raise KeyError("Not a valid HumanName attribute", key) - - def __str__(self) -> str: - if self.string_format is not None: - # string_format = "{title} {first} {middle} {last} {suffix} ({nickname})" - # Empty attributes must render as '' (not empty_attribute_default, - # which may be None) so str.format does not interpolate the - # literal "None" into the output, which cannot be scrubbed - # afterward without corrupting name text containing the same - # substring (#254). - _s = self.string_format.format(**{k: v or '' for k, v in self.as_dict().items()}) - # remove trailing punctuation from missing nicknames - _s = _s.replace(" ()", "").replace(" ''", "").replace(' ""', "") - _s = self.C.regexes.space_before_comma.sub(',', _s) - return self.collapse_whitespace(_s).strip(', ') - return " ".join(self) - - def __hash__(self) -> int: - """ - .. deprecated:: 1.3.0 - Removed in 2.0 (see issue #223); use :py:meth:`comparison_key` - for sets, dicts, and dedup. - """ - warnings.warn( - "hash(HumanName) is deprecated and will be removed in 2.0; use " - "comparison_key() for sets and dicts. See " - "https://github.com/derek73/python-nameparser/issues/223", - DeprecationWarning, - stacklevel=2, - ) - # __eq__ compares lowercased strings, so hash the lowercased string - # to keep equal instances in the same hash bucket. - return hash(str(self).lower()) - - def __repr__(self) -> str: - attrs = ( - f" title: {self.title or ''!r}\n" - f" first: {self.first or ''!r}\n" - f" middle: {self.middle or ''!r}\n" - f" last: {self.last or ''!r}\n" - f" suffix: {self.suffix or ''!r}\n" - f" nickname: {self.nickname or ''!r}\n" - f" maiden: {self.maiden or ''!r}" - ) - return f"<{self.__class__.__name__} : [\n{attrs}\n]>" - - def as_dict(self, include_empty: bool = True) -> dict[str, str]: - """ - Return the parsed name as a dictionary of its attributes. - - :param bool include_empty: Include keys in the dictionary for empty name attributes. - :rtype: dict - - .. doctest:: - - >>> name = HumanName("Bob Dole") - >>> name.as_dict() - {'title': '', 'first': 'Bob', 'middle': '', 'last': 'Dole', 'suffix': '', 'nickname': '', 'maiden': ''} - >>> name.as_dict(False) - {'first': 'Bob', 'last': 'Dole'} - - """ - d = {} - for m in self._members: - if include_empty: - d[m] = getattr(self, m) - else: - val = getattr(self, m) - if val: - d[m] = val - return d - - def comparison_key(self) -> tuple[str, ...]: - """ - The seven name components (title, first, middle, last, suffix, - nickname, maiden) as a lowercased tuple: a canonical, hashable - identity for the parsed name. Use it for dedup, dict keys, and - sorting or grouping, e.g. - ``unique = {n.comparison_key(): n for n in names}.values()``. - - Built from the ``*_list`` attributes, so it is unaffected by - display settings like ``string_format`` and - ``empty_attribute_default``. - - Empty or unparsable input yields the all-empty key, so such names - all compare equal and collide in dedup; screen them out with - ``len(name) == 0`` first. - - .. doctest:: - - >>> HumanName("Dr. Juan Q. Xavier de la Vega III").comparison_key() - ('dr.', 'juan', 'q. xavier', 'de la vega', 'iii', '', '') - - """ - return tuple( - " ".join(getattr(self, member + "_list")).lower() - for member in self._members - ) - - def matches(self, other: 'str | HumanName') -> bool: - """ - Compare parsed components case-insensitively; the semantic - replacement for the deprecated ``==``. A ``str`` argument is parsed - first, using this instance's configuration, so any written form of - the same name matches; a ``HumanName`` argument is compared as - already parsed — its own configuration determined its components. - Two empty or unparsable names match each other; check - ``len(name) == 0`` to screen them. - - .. doctest:: - - >>> name = HumanName("Dr. Juan Q. Xavier de la Vega III") - >>> name.matches("de la vega, dr. juan Q. xavier III") - True - >>> name.matches("Juan de la Vega") - False - - Unlike the deprecated ``==``, all seven components participate - (including ``maiden``, which the default ``string_format`` omits) - and display settings have no effect. Raises ``TypeError`` for - anything that is not a ``str`` or ``HumanName``; guard optional - values explicitly, e.g. ``x is not None and name.matches(x)``. - - Parses string arguments on every call. When matching one name - against many candidates, parse the candidates once or compare - :py:meth:`comparison_key` values instead. - """ - if isinstance(other, HumanName): - return self.comparison_key() == other.comparison_key() - if isinstance(other, str): - return self.comparison_key() == type(self)(other, self.C).comparison_key() - raise TypeError( - f"matches() requires a str or HumanName, got {type(other).__name__}" - ) - - def _process_initial(self, name_part: str, firstname: bool = False) -> str: - """ - Name parts may include prefixes or conjunctions. This function filters these from the name unless it is - a first name, since first names cannot be conjunctions or prefixes. - """ - # split() rather than split(" "): *_list attributes assigned directly - # bypass parse_pieces whitespace normalization, and split(" ") yields - # empty strings for repeated spaces (#232) - parts = name_part.split() - initials = [] - for part in parts: - if not (self.is_prefix(part) or self.is_conjunction(part)) or firstname: - initials.append(part[0]) - if len(initials) > 0: - return self.initials_separator.join(initials) - # Return '' (never empty_attribute_default, which may be None) when a - # part has no initialable words, e.g. a middle name consisting only of - # prefixes ("de la"). Callers drop these parts entirely. - return '' - - def _initials_lists(self) -> tuple[list[str], list[str], list[str]]: - """Initials for the first, middle and last name groups. Parts that - yield no initials (e.g. a prefix-only middle name like "de la") are - dropped rather than kept as empty strings. - """ - def group_initials(names: list[str], firstname: bool = False) -> list[str]: - return [i for i in (self._process_initial(n, firstname) for n in names if n) if i] - return (group_initials(self.first_list, True), - group_initials(self.middle_list), - group_initials(self.last_list)) - - def initials_list(self) -> list[str]: - """ - Returns the initials as a list - - .. doctest:: - - >>> name = HumanName("Sir Bob Andrew Dole") - >>> name.initials_list() - ['B', 'A', 'D'] - >>> name = HumanName("J. Doe") - >>> name.initials_list() - ['J', 'D'] - """ - first_initials_list, middle_initials_list, last_initials_list = self._initials_lists() - return first_initials_list + middle_initials_list + last_initials_list - - def initials(self) -> str: - """ - Return formatted initials for the name, controlled by - ``initials_format``, ``initials_delimiter``, and ``initials_separator``. - - ``initials_delimiter`` is appended after each individual initial. - ``initials_separator`` is placed between consecutive initials within - a name group (first, middle, or last). Both can be set as - ``Constants`` attributes or as ``HumanName`` constructor kwargs. - - .. doctest:: - - >>> name = HumanName("Sir Bob Andrew Dole") - >>> name.initials() - 'B. A. D.' - >>> name = HumanName("Sir Bob Andrew Dole", initials_format="{first} {middle}") - >>> name.initials() - 'B. A.' - >>> name = HumanName("Doe, John A.", initials_delimiter="", initials_separator="") - >>> name.initials() - 'J A D' - """ - - first_initials_list, middle_initials_list, last_initials_list = self._initials_lists() - - # Empty name groups must render as '' (not empty_attribute_default, - # which may be None) so str.format does not interpolate the literal - # "None" into the output. A fully-empty result falls back to - # empty_attribute_default, matching the other attribute accessors - # (e.g. ``first``). - initials_dict = { - "first": (self.initials_delimiter + self.initials_separator).join(first_initials_list) + self.initials_delimiter - if len(first_initials_list) else "", - "middle": (self.initials_delimiter + self.initials_separator).join(middle_initials_list) + self.initials_delimiter - if len(middle_initials_list) else "", - "last": (self.initials_delimiter + self.initials_separator).join(last_initials_list) + self.initials_delimiter - if len(last_initials_list) else "" - } - - _s = self.initials_format.format(**initials_dict) # noqa: UP032 - return self.collapse_whitespace(_s) or self.C.empty_attribute_default - - @property - def has_own_config(self) -> bool: - """ - True if this instance is not using the shared module-level - configuration. - """ - return self.C is not CONSTANTS - - # attributes - - @property - def title(self) -> str: - """ - The person's titles. Any string of consecutive pieces in - :py:mod:`~nameparser.config.titles` or - :py:mod:`~nameparser.config.conjunctions` - at the beginning of :py:attr:`full_name`. - """ - return " ".join(self.title_list) or self.C.empty_attribute_default - - @title.setter - def title(self, value: str | list[str] | None) -> None: - self._set_list('title', value) - - @property - def first(self) -> str: - """ - The person's first name. The first name piece after any known - :py:attr:`title` pieces parsed from :py:attr:`full_name`. - """ - return " ".join(self.first_list) or self.C.empty_attribute_default - - @first.setter - def first(self, value: str | list[str] | None) -> None: - self._set_list('first', value) - - @property - def middle(self) -> str: - """ - The person's middle names. All name pieces after the first name and - before the last name parsed from :py:attr:`full_name`. - """ - return " ".join(self.middle_list) or self.C.empty_attribute_default - - @middle.setter - def middle(self, value: str | list[str] | None) -> None: - self._set_list('middle', value) - - @property - def last(self) -> str: - """ - The person's last name. The last name piece parsed from - :py:attr:`full_name`. - """ - return " ".join(self.last_list) or self.C.empty_attribute_default - - @last.setter - def last(self, value: str | list[str] | None) -> None: - self._set_list('last', value) - - @property - def suffix(self) -> str: - """ - The persons's suffixes. Pieces at the end of the name that are found in - :py:mod:`~nameparser.config.suffixes`, or pieces that are at the end - of comma separated formats, e.g. - "Lastname, Title Firstname Middle[,] Suffix [, Suffix]" parsed - from :py:attr:`full_name`. - """ - return ", ".join(self.suffix_list) or self.C.empty_attribute_default - - @suffix.setter - def suffix(self, value: str | list[str] | None) -> None: - self._set_list('suffix', value) - - @property - def nickname(self) -> str: - """ - The person's nicknames. Any text found inside of quotes (``""``) or - parenthesis (``()``) - """ - return " ".join(self.nickname_list) or self.C.empty_attribute_default - - @nickname.setter - def nickname(self, value: str | list[str] | None) -> None: - self._set_list('nickname', value) - - @property - def maiden(self) -> str: - """ - The person's maiden (alternate/prior) last name. Empty unless a - delimiter has been routed to it via - :py:attr:`~nameparser.config.Constants.maiden_delimiters` -- see the - "Routing to Maiden Name" section of the customization docs. - """ - return " ".join(self.maiden_list) or self.C.empty_attribute_default - - @maiden.setter - def maiden(self, value: str | list[str] | None) -> None: - self._set_list('maiden', value) - - @property - def surnames_list(self) -> list[str]: - """ - List of middle names followed by last name. - """ - return self.middle_list + self.last_list - - @property - def surnames(self) -> str: - """ - A string of all middle names followed by the last name. - """ - return " ".join(self.surnames_list) or self.C.empty_attribute_default - - @property - def given_names_list(self) -> list[str]: - """ - List of first name followed by middle names. - """ - return self.first_list + self.middle_list - - @property - def given_names(self) -> str: - """ - A string of the first name followed by all middle names. - """ - return " ".join(self.given_names_list) or self.C.empty_attribute_default - - def _split_last(self) -> tuple[list[str], list[str]]: - """Return (prefix_particles, base_words) split from the last name. - - The base_words list is never empty: if every word in the last name - matches a prefix particle, the guard fires and all words are returned - as the base with an empty prefix list (heuristic: a family name is - assumed not to consist entirely of particles). - - >>> HumanName("Vincent van Gogh")._split_last() - (['van'], ['Gogh']) - >>> HumanName("Anh Do")._split_last() - ([], ['Do']) - """ - words = " ".join(self.last_list).split() - i = 0 - while i < len(words) and self.is_prefix(words[i]): - i += 1 - if i == len(words): - # Heuristic: assume a family name isn't entirely composed of - # particles (e.g. surname "Do" which also appears in PREFIXES). - # Don't strip — treat the whole last name as the base. - return [], words - return words[:i], words[i:] - - @property - def last_prefixes_list(self) -> list[str]: - """ - List of leading prefix particles in the last name (the *tussenvoegsel*). - Returns ``[]`` when there are none, including the case where every word - in the last name matches a prefix — see :py:meth:`_split_last`. - - >>> HumanName("Juan de la Vega").last_prefixes_list - ['de', 'la'] - """ - return self._split_last()[0] - - @property - def last_base_list(self) -> list[str]: - """ - List of last-name words after stripping leading prefix particles. - Never empty: when every word matches a prefix, no stripping occurs and - the full last name is returned — see :py:meth:`_split_last`. - - >>> HumanName("Vincent van Gogh").last_base_list - ['Gogh'] - """ - return self._split_last()[1] - - @property - def last_base(self) -> str: - """ - The last name with leading prefix particles removed (the core surname). - For ``"van Gogh"`` this is ``"Gogh"``; for ``"Smith"`` it is ``"Smith"``. - ``last`` is always unchanged. When every word in the last name matches a - prefix particle, no stripping occurs and the full last name is returned. - - >>> HumanName("Vincent van Gogh").last_base - 'Gogh' - >>> HumanName("John Smith").last_base - 'Smith' - """ - return " ".join(self.last_base_list) or self.C.empty_attribute_default - - @property - def last_prefixes(self) -> str: - """ - The leading prefix particle(s) of the last name (the *tussenvoegsel*). - Returns ``""`` (or ``empty_attribute_default``) when there are none, - including when every word in the last name matches a prefix particle - (the all-particles guard; see :py:meth:`_split_last`). - - >>> HumanName("Vincent van Gogh").last_prefixes - 'van' - >>> HumanName("Juan de la Vega").last_prefixes - 'de la' - """ - return " ".join(self.last_prefixes_list) or self.C.empty_attribute_default - - # setter methods - - def _set_list(self, attr: str, value: str | list[str] | None) -> None: - if isinstance(value, list): - val = value - elif isinstance(value, (str, bytes)): - val = [value] - elif value is None: - val = [] - else: - raise TypeError( - "Can only assign strings, lists or None to name attributes." - f" Got {type(value)}") - setattr(self, attr+"_list", self.parse_pieces(val)) - - # Parse helpers - def is_title(self, value: str) -> bool: - """Is in the :py:data:`~nameparser.config.titles.TITLES` set or was - derived as a title earlier in this parse (e.g. ``"Lt.Gov."``, - ``"Mr. and Mrs."``).""" - word = lc(value) - return word in self.C.titles or word in self._derived_titles - - def is_leading_title(self, piece: str) -> bool: - """ - True if ``piece`` is a known title, or an unrecognized multi-letter - word ending in a single trailing period (e.g. ``"Major."``). The - ``{2,}`` in the ``period_abbreviation`` regex, not a separate - ``is_an_initial()`` check, is what excludes single-letter initials - like ``"J."``. Only meaningful for pieces in the title position - (before the first name is set) — a period-abbreviation appearing - later in the name is left as a middle name. The match is not - registered in ``C.titles`` or the per-parse derived titles, so - matching ``"Major."`` here never makes ``"Major"`` (or ``"Major."``) - a recognized title elsewhere, even within the same parse. - """ - return self.is_title(piece) or bool(self.C.regexes.period_abbreviation.match(piece)) - - def is_conjunction(self, piece: str | list[str]) -> bool: - """Is in the conjunctions set — config or derived earlier in this - parse (e.g. ``"of the"``) — and not :py:func:`is_an_initial()`.""" - if isinstance(piece, list): - for item in piece: - if self.is_conjunction(item): - return True - return False - return (piece.lower() in self.C.conjunctions - or piece.lower() in self._derived_conjunctions) \ - and not self.is_an_initial(piece) - - def is_prefix(self, piece: str | list[str]) -> bool: - """ - Lowercased, leading/trailing-periods-stripped version of piece is in the - :py:data:`~nameparser.config.prefixes.PREFIXES` set, or was derived as - a prefix earlier in this parse (e.g. ``"von und"``). - """ - if isinstance(piece, list): - for item in piece: - if self.is_prefix(item): - return True - return False - word = lc(piece) - return word in self.C.prefixes or word in self._derived_prefixes - - def is_bound_first_name(self, piece: str) -> bool: - """Lowercased, leading/trailing-periods-stripped version of piece is in :py:attr:`~nameparser.config.Constants.bound_first_names`.""" - return lc(piece) in self.C.bound_first_names - - def is_non_first_name_prefix(self, piece: str) -> bool: - """Lowercased, leading/trailing-periods-stripped version of piece is in - :py:attr:`~nameparser.config.Constants.non_first_name_prefixes`.""" - return lc(piece) in self.C.non_first_name_prefixes - - def _join_bound_first_name(self, pieces: list[str], reserve_last: bool) -> list[str]: - """Join a first-name prefix to its following piece. - - Finds the first non-title piece; if it is in ``bound_first_names``, - merges it with the next piece — unless ``reserve_last`` is True and no - further piece would remain for the last name. - """ - fi = next((i for i, p in enumerate(pieces) if not self.is_title(p)), None) - if fi is None: - return pieces - if not self.is_bound_first_name(pieces[fi]): - return pieces - next_i = fi + 1 - if next_i >= len(pieces): - return pieces - if reserve_last: - # Count non-suffix pieces from next_i onward; need ≥2 so the join - # target and at least one last-name piece both exist. - non_suffix_remaining = sum( - 1 for p in pieces[next_i:] if not self.is_suffix(p) - ) - if non_suffix_remaining <= 1: - return pieces - pieces[fi] = pieces[fi] + " " + pieces[next_i] - del pieces[next_i] - return pieces - - def is_roman_numeral(self, value: str) -> bool: - """ - Matches the ``roman_numeral`` regular expression in - :py:data:`~nameparser.config.regexes.REGEXES`. - """ - return bool(self.C.regexes.roman_numeral.match(value)) - - def is_suffix(self, piece: str | list[str]) -> bool: - """ - Is in the suffixes set — or was derived as a period-joined suffix - earlier in this parse (e.g. ``"JD.CPA"``) — and not - :py:func:`is_an_initial()`. - - Some suffixes may be acronyms (M.B.A) while some are not (Jr.), - so we remove the periods from `piece` when testing against - `C.suffix_acronyms`. - """ - # suffixes may have periods inside them like "M.D." - if isinstance(piece, list): - for item in piece: - if self.is_suffix(item): - return True - return False - else: - word = lc(piece) - return ((word.replace('.', '') in self.C.suffix_acronyms) - or (word in self.C.suffix_not_acronyms) - or (word in self._derived_suffixes)) \ - and not self.is_an_initial(piece) - - def are_suffixes(self, pieces: Iterable[str]) -> bool: - """Return True if all pieces are suffixes. - - Vacuously True for an empty iterable — the piece loops in - :py:func:`parse_full_name` rely on this to route the final piece - to the last-name branch. - """ - for piece in pieces: - if not self.is_suffix(piece): - return False - return True - - def is_suffix_lenient(self, piece: str) -> bool: - """Like is_suffix(), but suffix_not_acronyms members are accepted - unconditionally, bypassing is_suffix()'s is_an_initial() veto. - - This covers all suffix_not_acronyms members (i, ii, iii, iv, v, jr, - sr, etc.), case-insensitively, including single-letter entries that - is_suffix() would otherwise reject. Only safe for pieces in - unambiguous positions, e.g. after a comma ("John Ingram, V"). - """ - return lc(piece) in self.C.suffix_not_acronyms or self.is_suffix(piece) - - def expand_suffix_delimiter(self, part: str) -> list[str]: - """Split a single post-comma part on :py:attr:`suffix_delimiter`, - if configured. Used only at suffix-consumption sites, where a part - has already been identified as a suffix group, so splitting it - further can't misparse an unrelated name segment. Returns ``[part]`` - unchanged if no delimiter is configured. - """ - if not self.suffix_delimiter: - return [part] - return [p for p in (p.strip() for p in part.split(self.suffix_delimiter)) if p] - - def are_suffixes_after_comma(self, pieces: Iterable[str]) -> bool: - """Return True if all pieces are suffixes by the lenient - :py:func:`is_suffix_lenient` test. Used when detecting suffix-comma - format (e.g. "John Ingram, V") where the post-comma position is - unambiguous. - """ - return all(self.is_suffix_lenient(piece) for piece in pieces) - - def is_rootname(self, piece: str) -> bool: - """ - Is not a known title, suffix or prefix. Just first, middle, last names. - """ - word = lc(piece) - return word not in self.C.suffixes_prefixes_titles \ - and word not in self._derived_titles \ - and word not in self._derived_suffixes \ - and word not in self._derived_prefixes \ - and not self.is_an_initial(piece) - - def is_an_initial(self, value: str) -> bool: - """ - Words with a single period at the end, or a single uppercase letter. - - Matches the ``initial`` regular expression in - :py:data:`~nameparser.config.regexes.REGEXES`. - """ - return bool(self.C.regexes.initial.match(value)) - - def is_east_slavic_patronymic(self, piece: str) -> bool: - """ - Return True if ``piece`` ends with a recognised East-Slavic patronymic - suffix, checked against both Latin-script and Cyrillic patterns in - ``self.C.regexes``. Latin suffixes: ``-ovich``, ``-ovna``, ``-evich``, - ``-evna``, ``-ichna``, and the irregular forms ``-ilyich``, ``-kuzmich``, - ``-lukich``, ``-fomich``, ``-fokich``. Cyrillic equivalents are matched - by a separate pattern. - """ - return bool( - self.C.regexes.east_slavic_patronymic.search(piece) - or self.C.regexes.east_slavic_patronymic_cyrillic.search(piece) - ) - - def is_turkic_patronymic_marker(self, piece: str) -> bool: - """ - Return True if ``piece`` is exactly a recognised Turkic patronymic - marker word (e.g. ``oglu``, ``qizi``, ``uly``), checked against both - Latin-script and Cyrillic patterns in ``self.C.regexes``. Unlike - East-Slavic patronymics, these are standalone marker words, not - suffixes, so the match is whole-word rather than a suffix search. - """ - return bool( - self.C.regexes.turkic_patronymic_marker.match(piece) - or self.C.regexes.turkic_patronymic_marker_cyrillic.match(piece) - ) - - # full_name parser - - @property - def full_name(self) -> str: - """The string output of the HumanName instance.""" - return str(self) - - @full_name.setter - def full_name(self, value: str | bytes) -> None: - self._apply_full_name(value, stacklevel=3) - - def _apply_full_name(self, value: str | bytes, *, stacklevel: int) -> None: - # Shared by the setter and the constructor so each can call it - # directly with a stacklevel that attributes the warning to *its own* - # caller -- the constructor going through the setter would otherwise - # add a frame and misattribute the warning to this module. - self.original = value - - if isinstance(value, bytes): - # deprecated 1.3.0, raises TypeError in 2.0 (#245) - warnings.warn( - "Passing bytes to HumanName is deprecated and will raise " - "TypeError in 2.0; decode it first, e.g. " - "value.decode('utf-8'). See " - "https://github.com/derek73/python-nameparser/issues/245", - DeprecationWarning, - stacklevel=stacklevel, - ) - self._full_name = value.decode(self.encoding) - else: - self._full_name = value - - self.parse_full_name() - - def collapse_whitespace(self, string: str) -> str: - # collapse multiple spaces into single space - string = self.C.regexes.spaces.sub(" ", string.strip()) - if string and self.C.regexes.commas.fullmatch(string[-1]): - string = string[:-1] - return string - - def pre_process(self) -> None: - """ - - This method happens at the beginning of the :py:func:`parse_full_name` - before any other processing of the string aside from unicode - normalization, so it's a good place to do any custom handling in a - subclass. Runs :py:func:`squash_bidi`, :py:func:`parse_nicknames` and - :py:func:`squash_emoji`. - - """ - self.squash_bidi() - self.fix_phd() - self.parse_nicknames() - self.squash_emoji() - - def handle_east_slavic_patronymic_name_order(self) -> None: - """ - When patronymic_name_order is enabled, detect Russian formal order - (Surname GivenName Patronymic) and rotate to Western order. - Fires only for no-comma, single-token first/middle/last where the last - token is a patronymic and the middle token is not. Title, suffix, and - nickname parts do not affect this guard — reordering proceeds regardless - of whether they are present. - """ - if ( - not self._had_comma - and len(self.first_list) == 1 - and len(self.middle_list) == 1 - and len(self.last_list) == 1 - and self.is_east_slavic_patronymic(self.last_list[0]) - and not self.is_east_slavic_patronymic(self.middle_list[0]) - ): - self.first_list, self.middle_list, self.last_list = ( - self.middle_list, - self.last_list, - self.first_list, - ) - - def handle_turkic_patronymic_name_order(self) -> None: - """ - When patronymic_name_order is enabled, detect the reversed Turkic - formal order (Surname GivenName PatronymicRoot Marker) and rotate to - Western order. Fires only for the strict 4-token, no-comma shape: - single-token first/last and exactly two middle tokens, where the last - token is a recognised Turkic patronymic marker. - """ - if ( - not self._had_comma - and len(self.first_list) == 1 - and len(self.middle_list) == 2 - and len(self.last_list) == 1 - and self.is_turkic_patronymic_marker(self.last_list[0]) - ): - self.first_list, self.middle_list, self.last_list = ( - [self.middle_list[0]], - [self.middle_list[1], self.last_list[0]], - self.first_list, - ) - - def handle_non_first_name_prefix(self) -> None: - """ - A leading prefix that is never a first name means the whole name is a - surname -- fold first (and any middle) into last. Keys on the parsed - first name, so a non-leading particle ("Jean de Mesnil") is untouched - and title/suffix are preserved. The middle_list/last_list guard leaves a - degenerate bare "de" as first="de" rather than inventing a surname. - """ - if (len(self.first_list) == 1 - and self.is_non_first_name_prefix(self.first_list[0]) - and (self.middle_list or self.last_list)): - self.last_list = self.first_list + self.middle_list + self.last_list - self.first_list = [] - self.middle_list = [] - - def handle_middle_name_as_last(self) -> None: - """ - When middle_name_as_last is enabled, fold middle_list into last_list - (prepended, preserving order) and clear middle_list. No-op when - middle_list is already empty. - """ - self.last_list = self.middle_list + self.last_list - self.middle_list = [] - - def post_process(self) -> None: - """ - This happens at the end of the :py:func:`parse_full_name` after - all other processing has taken place. Runs :py:func:`handle_firstnames` - and :py:func:`handle_capitalization`. - """ - self.handle_firstnames() - self.handle_non_first_name_prefix() - if self.C.patronymic_name_order: - self.handle_east_slavic_patronymic_name_order() - self.handle_turkic_patronymic_name_order() - if self.C.middle_name_as_last: - self.handle_middle_name_as_last() - self.handle_capitalization() - - def fix_phd(self) -> None: - _re = self.C.regexes.phd - - if match := _re.search(self._full_name): - self.suffix_list.extend(match.groups()) - self._full_name = _re.sub("", self._full_name) - - def parse_nicknames(self) -> None: - """ - Delimited content in the name is routed to either the nickname or - maiden bucket, based on which of - :py:attr:`~nameparser.config.Constants.nickname_delimiters` / - :py:attr:`~nameparser.config.Constants.maiden_delimiters` the matching - delimiter belongs to -- unless that content is suffix-shaped -- an - unambiguous suffix_not_acronyms/suffix_acronyms member, or content - ending in a period -- in which case it's left in place (undelimited) - for normal downstream suffix/title/word parsing instead. This happens - before any other processing of the name. - - Single quotes cannot span white space characters and must border - white space to allow for quotes in names like O'Connor and Kawai'ae'a. - Double quotes and parenthesis can span white space. - - By default, ``nickname_delimiters`` holds the three built-in - delimiters (``quoted_word``, ``double_quotes`` and ``parenthesis``, - resolved live from :py:attr:`~nameparser.config.Constants.regexes` so - overriding e.g. ``CONSTANTS.regexes.parenthesis`` keeps affecting - nickname parsing) and ``maiden_delimiters`` is empty. Move a key - between the two dicts, e.g. - ``maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')``, - to route it to ``maiden`` instead, or add a new compiled pattern under - any key to recognize an additional delimiter -- see the "Adding - Custom Nickname Delimiters" and "Routing to Maiden Name" sections of - the customization docs. - """ - - def handle_match(target_list: list[str]) -> Callable[['re.Match[str]'], str]: - def _handle(m: 're.Match[str]') -> str: - # Fall back to the whole match when the regex has no capturing - # group (e.g. a custom override regex without one, like - # EMPTY_REGEX) -- mirrors the old code's use of findall(), which - # returns the whole match for group-less patterns. - content = m.group(1) if m.lastindex else m.group(0) - stripped = lc(content) - # Inlined rather than calling self.is_suffix(content): is_suffix() - # also rejects single-letter initials via is_an_initial(), which - # isn't relevant here, and the suffix_acronyms_ambiguous exclusion - # needs to be interleaved into the acronym branch specifically. - # Acronym suffixes may have periods between every letter (e.g. - # "M.D", "Ph.D") that aren't necessarily trailing, so -- exactly - # like is_suffix() -- strip all periods before checking - # suffix_acronyms/suffix_acronyms_ambiguous membership. Bare - # `stripped` (lc() only strips leading/trailing periods) is still - # used for suffix_not_acronyms, matching is_suffix()'s asymmetry. - acronym_stripped = stripped.replace('.', '') - is_unambiguous_suffix = ( - stripped in self.C.suffix_not_acronyms - or (acronym_stripped in self.C.suffix_acronyms - and acronym_stripped not in self.C.suffix_acronyms_ambiguous) - ) - if is_unambiguous_suffix or content.endswith('.'): - # Leave the bare content -- no delimiters -- so downstream - # word-splitting/suffix-matching sees it exactly as if it had - # never been wrapped in parens/quotes. is_suffix()/lc() only - # strip periods, never parens/quotes, so returning m.group(0) - # here (e.g. literal "(Ret)") would never match - # suffix_not_acronyms ("ret"). - return content - target_list.append(content) - return '' - return _handle - - # Same handle_match for every delimiter: suffix-shaped content is rare - # in quotes but not impossible, and the logic is delimiter-agnostic, - # so there's no reason to special-case parenthesis here. A string - # delimiter value names a Constants.regexes entry, resolved via - # getattr() so overriding e.g. self.C.regexes.parenthesis keeps - # working; anything else is already a compiled pattern, added - # directly by a caller. Unlike a caller directly querying - # self.C.regexes (where RegexTupleManager's EMPTY_REGEX default for - # an unknown attribute is harmless -- the caller sees the pattern and - # can react to it), a bad string here is an internal cross-reference - # the delimiter dict itself is responsible for keeping valid. - # EMPTY_REGEX matches the empty string at every position, so - # silently falling back to it would not just skip the delimiter -- - # handle_match() would fire on every zero-width match and append '' - # into the bucket's list repeatedly, producing a truthy - # whitespace-only nickname/maiden while leaving the real delimited - # content (e.g. literal parentheses) unstripped. Fail loudly instead. - for bucket, delimiters in ( - ('nickname', self.C.nickname_delimiters), - ('maiden', self.C.maiden_delimiters), - ): - target_list = getattr(self, bucket + '_list') - _handle_match = handle_match(target_list) - for raw_pattern in delimiters.values(): - if isinstance(raw_pattern, re.Pattern): - _re = raw_pattern - elif raw_pattern in self.C.regexes: - _re = getattr(self.C.regexes, raw_pattern) - else: - raise ValueError( - f"{bucket}_delimiters references unknown regexes key {raw_pattern!r}. " - f"Known regexes keys: {sorted(self.C.regexes)}" - ) - self._full_name = _re.sub(_handle_match, self._full_name) - - def squash_emoji(self) -> None: - """ - Remove emoji from the input string. - """ - re_emoji = self.C.regexes.emoji - if re_emoji and re_emoji.search(self._full_name): - self._full_name = re_emoji.sub('', self._full_name) - - def squash_bidi(self) -> None: - """ - Remove invisible bidirectional control characters from the input - string. They carry no name content but stick to the parts they - surround, so parsed attributes stop comparing equal to the clean name. - """ - re_bidi = self.C.regexes.bidi - if re_bidi and re_bidi.search(self._full_name): - self._full_name = re_bidi.sub('', self._full_name) - - def handle_firstnames(self) -> None: - """ - If there are only two parts and one is a title, assume it's a last name - instead of a first name. e.g. Mr. Johnson. Unless it's a special title - like "Sir", then when it's followed by a single name that name is always - a first name. - """ - if self.title \ - and len(self) == 2 \ - and lc(self.title) not in self.C.first_name_titles: - self.last, self.first = self.first, self.last - - def parse_full_name(self) -> None: - """ - - The main parse method for the parser. This method is run upon - assignment to the :py:attr:`full_name` attribute or instantiation. - - Basic flow is to hand off to :py:func:`pre_process` to handle - nicknames. It then splits on commas and chooses a code path depending - on the number of commas. - - :py:func:`parse_pieces` then splits those parts on spaces and - :py:func:`join_on_conjunctions` joins any pieces next to conjunctions. - """ - - self.title_list = [] - self.first_list = [] - self.middle_list = [] - self.last_list = [] - self.suffix_list = [] - self.nickname_list = [] - self.maiden_list = [] - - # each parse derives these from scratch; entries from a previous - # full_name must not influence this one - self._derived_titles = set() - self._derived_suffixes = set() - self._derived_conjunctions = set() - self._derived_prefixes = set() - - self.pre_process() - - self._full_name = self.collapse_whitespace(self._full_name) - - # break up full_name by commas. A missing "commas" key in a custom - # regexes dict falls back to RegexTupleManager's EMPTY_REGEX, whose - # .split() matches between every character rather than not - # splitting at all -- guard against that so a custom regexes dict - # that omits "commas" disables the comma split instead of shattering - # the name into single characters. - commas = self.C.regexes.commas - parts = [x.strip() for x in (commas.split(self._full_name) if commas.pattern else [self._full_name])] - self._had_comma = len(parts) > 1 - - log.debug("full_name: %s", self._full_name) - log.debug("parts: %s", parts) - - if len(parts) == 1: - - # no commas, title first middle middle middle last suffix - # part[0] - - pieces = self.parse_pieces(parts) - pieces = self._join_bound_first_name(pieces, reserve_last=True) - p_len = len(pieces) - for i, piece in enumerate(pieces): - try: - nxt = pieces[i + 1] - except IndexError: - nxt = None - - # title must have a next piece, unless it's just a title - if not self.first \ - and (nxt or p_len == 1) \ - and self.is_leading_title(piece): - self.title_list.append(piece) - continue - if not self.first: - if p_len == 1 and self.nickname: - self.last_list.append(piece) - continue - self.first_list.append(piece) - continue - if self.are_suffixes(pieces[i+1:]) or \ - ( - # if the next piece is the last piece and a roman - # numeral but this piece is not an initial - nxt is not None and \ - self.is_roman_numeral(nxt) and i == p_len - 2 - and not self.is_an_initial(piece) - ): - # any piece reaching this check as the final piece lands - # here: are_suffixes() is vacuously True for the empty - # tail, making this the last-name branch as well as the - # suffix branch - self.last_list.append(piece) - self.suffix_list += pieces[i+1:] - break - - self.middle_list.append(piece) - else: - # if all the end parts are suffixes and there is more than one piece - # in the first part. (Suffixes will never appear after last names - # only, and allows potential first names to be in suffixes, e.g. - # "Johnson, Bart" - - post_comma_pieces = self.parse_pieces(parts[1].split(' '), 1) - - # Detection must see the delimiter-expanded words too, or a - # delimiter-joined suffix group like "RN - CRNA" would never be - # recognized as suffix-comma format in the first place. - suffix_delimiter_pieces = [word for part in self.expand_suffix_delimiter(parts[1]) - for word in part.split(' ')] - - if self.are_suffixes_after_comma(suffix_delimiter_pieces) \ - and len(parts[0].split(' ')) > 1: - - # suffix comma: - # title first middle last [suffix], suffix [suffix] [, suffix] - # parts[0], parts[1:...] - - for part in parts[1:]: - # skip empty segments from doubled commas, mirroring the - # parts[2:] guard in the lastname-comma path below - if part: - self.suffix_list += self.expand_suffix_delimiter(part) - pieces = self.parse_pieces(parts[0].split(' ')) - pieces = self._join_bound_first_name(pieces, reserve_last=True) - log.debug("pieces: %s", str(pieces)) - for i, piece in enumerate(pieces): - try: - nxt = pieces[i + 1] - except IndexError: - nxt = None - - if not self.first \ - and (nxt or len(pieces) == 1) \ - and self.is_leading_title(piece): - self.title_list.append(piece) - continue - if not self.first: - self.first_list.append(piece) - continue - if self.are_suffixes(pieces[i+1:]): - # any piece reaching this check as the final piece - # lands here: are_suffixes() is vacuously True for the - # empty tail, making this the last-name branch as well - # as the suffix branch - self.last_list.append(piece) - self.suffix_list = pieces[i+1:] + self.suffix_list - break - self.middle_list.append(piece) - else: - - # lastname comma: - # last [suffix], title first middles[,] suffix [,suffix] - # parts[0], parts[1], parts[2:...] - - log.debug("post-comma pieces: %s", str(post_comma_pieces)) - post_comma_pieces = self._join_bound_first_name(post_comma_pieces, reserve_last=False) - - # lastname part may have suffixes in it - lastname_pieces = self.parse_pieces(parts[0].split(' '), 1) - for piece in lastname_pieces: - # the first one is always a last name, even if it looks like - # a suffix - if self.is_suffix(piece) and len(self.last_list) > 0: - self.suffix_list.append(piece) - else: - self.last_list.append(piece) - - for i, piece in enumerate(post_comma_pieces): - try: - nxt = post_comma_pieces[i + 1] - except IndexError: - nxt = None - - if not self.first \ - and (nxt or len(post_comma_pieces) == 1) \ - and self.is_leading_title(piece): - self.title_list.append(piece) - continue - if not self.first: - self.first_list.append(piece) - continue - # A trailing token in a two-part lastname-comma name is - # unambiguously positioned, so use the lenient test that - # accepts suffix_not_acronyms members is_suffix() would - # veto as initials. When parts[2] exists the caller - # already declared an explicit suffix via comma (e.g. - # 'Doe, Rev. John V, Jr.'), making the trailing token - # more likely a middle initial. - if self.is_suffix(piece) or \ - (nxt is None and len(parts) == 2 - and self.is_suffix_lenient(piece)): - self.suffix_list.append(piece) - continue - self.middle_list.append(piece) - for part in parts[2:]: - # skip empty segments from doubled commas ("Doe, John,, Jr.") - # without dropping the segments that follow them - if part: - self.suffix_list += self.expand_suffix_delimiter(part) - - self.post_process() - - def parse_pieces(self, parts: Iterable[str], additional_parts_count: int = 0) -> list[str]: - """ - Split parts on spaces and remove commas, join on conjunctions and - lastname prefixes. Tokens that are empty after stripping spaces and - commas are dropped, so the returned pieces never contain empty - strings. If parts have periods in the middle, try splitting - on periods and check if the parts are titles or suffixes. If they are, - register the periods-joined part as a derived title/suffix for this - parse so it will be recognized; the constants are not modified. - - :param list parts: name part strings from the comma split - :param int additional_parts_count: - - if the comma format contains other parts, we need to know - how many there are to decide if things should be considered a - conjunction. - :return: pieces split on spaces and joined on conjunctions - :rtype: list - """ - - output: list[str] = [] - for part in parts: - if not isinstance(part, (str, bytes)): - raise TypeError("Name parts must be strings. " - f" Got {type(part)}") - # drop tokens that strip to nothing (e.g. from a bare "," input or - # an empty comma segment) so no empty piece reaches the parse - # loops and the public *_list attributes - output += [s for s in (x.strip(' ,') for x in part.split(' ')) if s] - - # If part contains periods, check if it's multiple titles or suffixes - # together without spaces. If so, register the periods-joined part as - # a derived title/suffix for this parse so it gets recognized later - for part in output: - # if this part has a period not at the beginning or end - if self.C.regexes.period_not_at_end and self.C.regexes.period_not_at_end.match(part): - # split on periods, any of the split pieces titles or suffixes? - # ("Lt.Gov.") - period_chunks = part.split(".") - titles = list(filter(self.is_title, period_chunks)) - suffixes = list(filter(self.is_suffix, period_chunks)) - - # register the part so it will be found by the is_* checks - if titles: - self._derived_titles.add(lc(part)) - continue - if suffixes: - self._derived_suffixes.add(lc(part)) - continue - - return self.join_on_conjunctions(output, additional_parts_count) - - def join_on_conjunctions(self, pieces: list[str], additional_parts_count: int = 0) -> list[str]: - """ - Join conjunctions to surrounding pieces. Title- and prefix-aware. e.g.: - - ['Mr.', 'and', 'Mrs.', 'John', 'Doe'] ==> - ['Mr. and Mrs.', 'John', 'Doe'] - - ['The', 'Secretary', 'of', 'State', 'Hillary', 'Clinton'] ==> - ['The Secretary of State', 'Hillary', 'Clinton'] - - When joining titles, registers the newly formed piece as a derived - title for the current parse so it will be recognized correctly later - in the same parse. E.g. while parsing the example names above, - 'The Secretary of State' and 'Mr. and Mrs.' are treated as titles. - The configuration in ``self.C`` is never modified. - - :param list pieces: name pieces strings after split on spaces - :param int additional_parts_count: - :return: new list with piece next to conjunctions merged into one piece - with spaces in it. - :rtype: list - - """ - length = len(pieces) + additional_parts_count - # don't join on conjunctions if there's only 2 parts - if length < 3: - return pieces - - rootname_pieces = [p for p in pieces if self.is_rootname(p)] - total_length = len(rootname_pieces) + additional_parts_count - - # find all the conjunctions, join any conjunctions that are next to each - # other, then join those newly joined conjunctions and any single - # conjunctions to the piece before and after it - conj_index = [i for i, piece in enumerate(pieces) - if self.is_conjunction(piece)] - - contiguous_conj_i = group_contiguous_integers(conj_index) - - # process ranges in reverse so deleting one range doesn't shift the - # indices of ranges still to be processed - for cont_i in reversed(contiguous_conj_i): - new_piece = " ".join(pieces[cont_i[0]: cont_i[1]+1]) - pieces[cont_i[0]:cont_i[1]+1] = [new_piece] - # register newly joined conjunctions to be found later this parse - self._derived_conjunctions.add(lc(new_piece)) - - if len(pieces) == 1: - # if there's only one piece left, nothing left to do - return pieces - - # refresh conjunction index locations - conj_index = [i for i, piece in enumerate(pieces) if self.is_conjunction(piece)] - - def register_joined_piece(new_piece: str, neighbor: str) -> None: - if self.is_title(neighbor): - # when joining to a title, make new_piece a title too - self._derived_titles.add(lc(new_piece)) - if self.is_prefix(neighbor): - # when joining to a prefix, make new_piece a prefix too, so - # e.g. "von" + "und" bridges into "von und" and can still - # chain onto a following prefix/lastname (see "von und zu") - self._derived_prefixes.add(lc(new_piece)) - - def shift_conj_index(past: int, by: int) -> None: - # after removing pieces at/after `past`, indices of the - # remaining conjunctions need to shift down by `by` - for j, val in enumerate(conj_index): - if val > past: - conj_index[j] = val - by - - for i in conj_index: - if len(pieces[i]) == 1 and total_length < 4 and pieces[i].isalpha(): - # if there are only 3 total parts (minus known titles, suffixes - # and prefixes) and this conjunction is a single letter, prefer - # treating it as an initial rather than a conjunction. - # http://code.google.com/p/python-nameparser/issues/detail?id=11 - continue - - start = max(0, i - 1) - end = min(len(pieces), i + 2) - new_piece = " ".join(pieces[start:end]) - neighbor = pieces[start] if start < i else pieces[end - 1] - register_joined_piece(new_piece, neighbor) - pieces[start:end] = [new_piece] - shift_conj_index(past=i, by=end - start - 1) - - # join prefixes to following lastnames: ['de la Vega'], ['van Buren'] - i = 0 - while i < len(pieces): - # total_length >= 1 covers essentially all real input, so this - # treats any leading piece as a first name rather than a prefix. - leading_first_name = i == 0 and total_length >= 1 - if not self.is_prefix(pieces[i]) or leading_first_name: - i += 1 - continue - - # absorb any immediately-adjacent prefixes into one contiguous run - # e.g. "von und zu der" ==> chain them all before looking further - j = i + 1 - while j < len(pieces) and self.is_prefix(pieces[j]): - j += 1 - - # then join everything after the run until the next prefix or suffix - while j < len(pieces) and not self.is_prefix(pieces[j]) and not self.is_suffix(pieces[j]): - j += 1 - - pieces[i:j] = [' '.join(pieces[i:j])] - i += 1 - - log.debug("pieces: %s", pieces) - return pieces - - # Capitalization Support - - def cap_word(self, word: str, attribute: HumanNameAttributeT) -> str: - if (self.is_prefix(word) and attribute in ('last', 'middle')) \ - or self.is_conjunction(word): - return word.lower() - exceptions = self.C.capitalization_exceptions - key = lc(word) - for k in (key, key.replace('.', '')): - if k in exceptions: - return exceptions[k] - mac_match = self.C.regexes.mac.match(word) - if mac_match: - def cap_after_mac(m: re.Match) -> str: - return m.group(1).capitalize() + m.group(2).capitalize() - return self.C.regexes.mac.sub(cap_after_mac, word) - else: - return word.capitalize() - - def cap_piece(self, piece: str, attribute: HumanNameAttributeT) -> str: - if not piece: - return "" - - def replacement(m: re.Match) -> str: - return self.cap_word(m.group(0), attribute) - - return self.C.regexes.word.sub(replacement, piece) - - def capitalize(self, force: bool | None = None) -> None: - """ - The HumanName class can try to guess the correct capitalization of name - entered in all upper or lower case. By default, it will not adjust the - case of names entered in mixed case. To run capitalization on all names - pass the parameter `force=True`. - - :param bool force: Forces capitalization of mixed case strings. This - parameter overrides rules set within - :py:class:`~nameparser.config.CONSTANTS`. - - **Usage** - - .. doctest:: capitalize - - >>> name = HumanName('bob v. de la macdole-eisenhower phd') - >>> name.capitalize() - >>> str(name) - 'Bob V. de la MacDole-Eisenhower Ph.D.' - >>> # Don't touch good names - >>> name = HumanName('Shirley Maclaine') - >>> name.capitalize() - >>> str(name) - 'Shirley Maclaine' - >>> name.capitalize(force=True) - >>> str(name) - 'Shirley MacLaine' - - """ - name = str(self) - force = self.C.force_mixed_case_capitalization \ - if force is None else force - - if not force and not (name == name.upper() or name == name.lower()): - return - self.title_list = self.cap_piece(self.title, 'title').split() - self.first_list = self.cap_piece(self.first, 'first').split() - self.middle_list = self.cap_piece(self.middle, 'middle').split() - self.last_list = self.cap_piece(self.last, 'last').split() - # suffix is stored comma-separated ("Ph.D., J.D."), not space-separated - self.suffix_list = [s for s in self.cap_piece(self.suffix, 'suffix').split(', ') if s] - - def handle_capitalization(self) -> None: - """ - Handles capitalization configurations set within - :py:class:`~nameparser.config.CONSTANTS`. - """ - if self.C.capitalize_name: - self.capitalize() +__all__ = ["HumanName"] diff --git a/pyproject.toml b/pyproject.toml index 69bfe719..6a86e4b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "nameparser" description = "A simple Python module for parsing human names into their individual components." readme = "README.rst" -requires-python = ">=3.10" +requires-python = ">=3.11" license = {text = "LGPL"} authors = [{name = "Derek Gulbranson", email = "derek73@gmail.com"}] dynamic = ["version"] @@ -13,7 +13,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -23,9 +22,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Text Processing :: Linguistic", ] -dependencies = [ - "typing_extensions (>=4.5.0); python_version < '3.11'" -] +dependencies = [] [build-system] requires = ["setuptools (>=82.0.1)"] @@ -47,6 +44,7 @@ dev = [ "ruff (>=0.15)", "pytest-timeout>=2.4.0", "pytest-cov>=6", + "hypothesis", ] [tool.mypy] @@ -58,6 +56,26 @@ module = [ ] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = [ + "nameparser._types", + "nameparser._lexicon", + "nameparser._policy", + "nameparser._render", + "nameparser._locale", + "nameparser._pipeline.*", + "nameparser._parser", +] +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_any_generics = true +warn_return_any = true +strict_equality = true + +[[tool.mypy.overrides]] +module = ["tests.v2.*"] +check_untyped_defs = true + [tool.pytest.ini_options] testpaths = ["tests", "nameparser"] addopts = "--doctest-modules" diff --git a/tests/base.py b/tests/base.py index b563fb3f..283e09d1 100644 --- a/tests/base.py +++ b/tests/base.py @@ -16,8 +16,14 @@ class HumanNameTestBase(Generic[T]): """ def m(self, actual: T, expected: T, hn: HumanName) -> None: - """assertEqual with a better message and awareness of hn.C.empty_attribute_default""" - expected_ = expected or hn.C.empty_attribute_default + """assertEqual with a better message. + + ``expected or ''`` used to fall back to ``hn.C.empty_attribute_default``, + which could be '' or None depending on which of the (formerly dual-run) + settings a test ran under. That setting was removed in 2.0 (#255): + empty attributes are always ''. + """ + expected_ = expected or '' try: assert actual == expected_, f"{actual!r} != {expected!r} for {hn.original!r}\n{hn!r}" except UnicodeDecodeError: diff --git a/tests/conftest.py b/tests/conftest.py index 79dbfd92..f6e5e487 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,14 +6,19 @@ from nameparser.config import CONSTANTS + # Scalar (non-collection) config attributes that individual tests mutate on the # global CONSTANTS singleton. Several tests change these without restoring them; # the original suite only survived because unittest happens to run methods in # alphabetical order, so a later test reset the value. pytest runs in definition # order, so we snapshot and restore these around every test to keep tests # isolated regardless of order. +# +# empty_attribute_default is gone from this list: it was removed in 2.0 (#255, +# see nameparser/_config_shim.py's Constants.__setattr__), so there is no +# longer a second parsing path to snapshot/restore or dual-run against. Empty +# attributes are always ''. _SCALAR_CONFIG_ATTRS = ( - "empty_attribute_default", "string_format", "initials_format", "initials_delimiter", @@ -28,6 +33,10 @@ # these in place, so a shallow snapshot of the reference would not protect later # tests. We snapshot independent copies and restore them, making collection # mutations order-independent too. +# +# regexes is gone from this list: it is a read-only _RegexesProxy in 2.0 (set +# once in Constants.__init__), and reassigning it raises TypeError, so it can +# neither be mutated by a test nor restored by this fixture. _COLLECTION_CONFIG_ATTRS = ( "prefixes", "suffix_acronyms", @@ -38,45 +47,35 @@ "bound_first_names", "non_first_name_prefixes", "capitalization_exceptions", - "regexes", "nickname_delimiters", "maiden_delimiters", ) -@pytest.fixture(autouse=True, params=['', None], ids=['default', 'none']) -def empty_attribute_default(request: pytest.FixtureRequest) -> Iterator[str | None]: - """Run every test under both empty_attribute_default settings, isolating global config. +@pytest.fixture(autouse=True) +def _isolate_constants() -> Iterator[None]: + """Snapshot and restore the shared CONSTANTS singleton around every test. - Reproduces the original tests.py __main__ block, which ran the whole suite - twice — once with the default ('') and once with None — as a regression - check that the three parsing code paths agree. The surrounding snapshot of - both the scalar and the collection CONSTANTS attributes restores any global - config a test mutates, so tests do not leak state into one another (the - original relied on unittest's alphabetical method ordering to mask such - leaks). + Formerly also drove a parametrized dual-run over empty_attribute_default + settings (reproducing the original tests.py __main__ block, which ran the + whole suite twice as a regression check that the three parsing code paths + agreed). That setting no longer exists in 2.0 (#255: empty attributes are + always ''), so only the isolation half of the original fixture remains. """ scalar_snapshot = {attr: getattr(CONSTANTS, attr) for attr in _SCALAR_CONFIG_ATTRS} collection_snapshot = { attr: copy.deepcopy(getattr(CONSTANTS, attr)) for attr in _COLLECTION_CONFIG_ATTRS } - # empty_attribute_default assignment is deprecated (#255); this fixture's - # own systematic exercise of both settings across the whole suite is - # infrastructure, not a test of the deprecation itself, so it's silenced - # here (narrowly, around just the assignment) rather than at every one of - # its ~1500 call sites. Must not wrap `yield` -- that would suppress - # DeprecationWarning for the test body itself, hiding real ones. + yield + # Restoring through the shared singleton is itself a mutation and would + # otherwise emit the shared-CONSTANTS DeprecationWarning (#262) on every + # single test's teardown; this fixture is infrastructure; it is not + # exercising that deprecation, so it's silenced narrowly here rather than + # at every test. with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - CONSTANTS.empty_attribute_default = request.param - yield request.param - for attr, value in scalar_snapshot.items(): - if attr == "empty_attribute_default": - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - setattr(CONSTANTS, attr, value) - else: + for attr, value in scalar_snapshot.items(): + setattr(CONSTANTS, attr, value) + for attr, value in collection_snapshot.items(): setattr(CONSTANTS, attr, value) - for attr, value in collection_snapshot.items(): - setattr(CONSTANTS, attr, value) diff --git a/tests/test_bound_first_names.py b/tests/test_bound_first_names.py index cb5aae95..3c1fb475 100644 --- a/tests/test_bound_first_names.py +++ b/tests/test_bound_first_names.py @@ -3,14 +3,9 @@ class BoundFirstNamesTestCase(HumanNameTestBase): - - def test_is_bound_first_name_true(self) -> None: - hn = HumanName("test") - assert hn.is_bound_first_name("Abdul") - - def test_is_bound_first_name_false(self) -> None: - hn = HumanName("test") - assert not hn.is_bound_first_name("Ahmed") + # The v1 is_bound_first_name predicate is gone with the other v1 parsing + # hooks (#280); the vocabulary's behavior is pinned through the parsing + # tests below. # --- no-comma: basic joining --- def test_no_comma_basic_join(self) -> None: diff --git a/tests/test_comma_variants.py b/tests/test_comma_variants.py index ca53581f..17597487 100644 --- a/tests/test_comma_variants.py +++ b/tests/test_comma_variants.py @@ -1,8 +1,4 @@ -import pytest - from nameparser import HumanName -from nameparser.config import Constants -from nameparser.config.regexes import REGEXES from tests.base import HumanNameTestBase @@ -31,23 +27,8 @@ def test_trailing_arabic_comma_stripped(self) -> None: hn = HumanName("سلمان،") self.m(hn.first, "سلمان", hn) - def test_custom_regexes_without_commas_key_does_not_shatter_name(self) -> None: - # A custom regexes dict that omits "commas" entirely must not fall - # back to RegexTupleManager's EMPTY_REGEX default for splitting -- - # re.compile('').split(...) matches between every character, which - # explodes any name into single-char pieces instead of leaving it - # unsplit (the EMPTY_REGEX convention elsewhere in this codebase - # means "feature disabled", not "split on every character"). - # With comma splitting disabled, "Smith, John" is tokenized like any - # other no-comma input (word tokenizing drops the punctuation), - # yielding a plain first/last pair -- not the inverted "Last, First" - # reading, and definitely not single-character pieces. - # Unknown-key access (here, the deliberately-omitted 'commas') is - # deprecated for removal in 2.0 (#256); this fallback pattern will - # AttributeError-crash the parser instead of degrading silently. - custom = {k: v for k, v in REGEXES.items() if k != 'commas'} - c = Constants(regexes=custom) - with pytest.deprecated_call(): - hn = HumanName("Smith, John", constants=c) - self.m(hn.first, "Smith", hn) - self.m(hn.last, "John", hn) + # The v1 test for a custom regexes dict omitting the 'commas' key (the + # EMPTY_REGEX shatter guard) died with the mechanism: Constants(regexes= + # ...) raises TypeError in 2.0 (deliberate divergence, migration spec + # section 3 uniform rule) -- pinned in test_python_api.py's + # test_override_regex_raises. diff --git a/tests/test_config_attribute_docstrings.py b/tests/test_config_attribute_docstrings.py deleted file mode 100644 index 4b1f4c79..00000000 --- a/tests/test_config_attribute_docstrings.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Exercise the doctest examples embedded in Constants attribute docstrings. - -nameparser.config.Constants documents several scalar attributes (e.g. -patronymic_name_order, middle_name_as_last) with a bare string literal placed -right after the class-attribute assignment -- Sphinx's "attribute docstring" -convention, picked up by autodoc's source-level analysis for the built docs. - -That convention is invisible to Python at runtime: only module/class/function/ -method docstrings become a real __doc__, so a bare string following -`attr = value` is evaluated and discarded. doctest.DocTestFinder walks __doc__ -attributes, so pytest's --doctest-modules (see pyproject.toml addopts) never -finds the `.. doctest::` examples inside them -- they can go stale silently. - -This module parses the source with `ast` to recover those literals (the same -information Sphinx's static analysis relies on) and runs any doctest examples -found inside them explicitly, so a stale example fails the suite. -""" -import ast -import doctest -import io -from pathlib import Path - -import pytest - -import nameparser.config as config_module -from nameparser import HumanName -from nameparser.config import CONSTANTS, Constants - -CONFIG_SOURCE_PATH = Path(config_module.__file__) - - -def _constants_attribute_docstrings() -> dict[str, str]: - """Map attribute name -> bare-string-literal docstring, Constants class body only.""" - tree = ast.parse(CONFIG_SOURCE_PATH.read_text(), filename=str(CONFIG_SOURCE_PATH)) - (class_node,) = ( - node for node in ast.walk(tree) - if isinstance(node, ast.ClassDef) and node.name == 'Constants' - ) - docstrings = {} - body = class_node.body - for stmt, following in zip(body, body[1:]): - if not (isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 - and isinstance(stmt.targets[0], ast.Name)): - continue - if isinstance(following, ast.Expr) and isinstance(following.value, ast.Constant) \ - and isinstance(following.value.value, str): - docstrings[stmt.targets[0].id] = following.value.value - return docstrings - - -ATTRIBUTE_DOCSTRINGS = _constants_attribute_docstrings() - -DOCTEST_GLOBS = {'HumanName': HumanName, 'CONSTANTS': CONSTANTS, 'Constants': Constants} - - -def _attrs_with_doctest_examples() -> list[str]: - parser = doctest.DocTestParser() - return sorted( - attr for attr, docstring in ATTRIBUTE_DOCSTRINGS.items() - if parser.get_examples(docstring) - ) - - -@pytest.mark.parametrize("attr", _attrs_with_doctest_examples()) -def test_constants_attribute_docstring_examples(attr: str) -> None: - docstring = ATTRIBUTE_DOCSTRINGS[attr] - test = doctest.DocTestParser().get_doctest( - docstring, DOCTEST_GLOBS, attr, str(CONFIG_SOURCE_PATH), 0, - ) - output = io.StringIO() - failures, _ = doctest.DocTestRunner().run(test, out=output.write) - assert failures == 0, output.getvalue() - - -def test_found_expected_attributes_with_doctest_examples() -> None: - """Guard the discovery mechanism itself: if this drops to zero, the AST - walk above stopped matching Constants's attribute-docstring pattern and - every parametrized case above is silently skipped rather than run.""" - assert _attrs_with_doctest_examples() diff --git a/tests/test_constants.py b/tests/test_constants.py index 54c26500..a8ebe82d 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -1,15 +1,13 @@ import copy import pickle import re -import timeit import warnings from typing import Any import pytest from nameparser import HumanName -from nameparser.config import CONSTANTS, Constants, RegexTupleManager, SetManager, TupleManager -from nameparser.config.regexes import EMPTY_REGEX +from nameparser.config import CONSTANTS, Constants, SetManager, TupleManager from nameparser.config.titles import TITLES from tests.base import HumanNameTestBase @@ -45,7 +43,10 @@ def test_constants_invalid_type_raises_typeerror(self) -> None: HumanName("John Doe", constants="not a Constants") # type: ignore[arg-type] def test_constants_class_instead_of_instance_raises_with_hint(self) -> None: - with pytest.raises(TypeError, match=r"did you mean Constants\(\)"): + # 2.0's message names the received class rather than v1's "did you + # mean Constants()" hint; the class-not-instance mistake still fails + # loudly at construction + with pytest.raises(TypeError, match=r"constants must be a Constants instance"): HumanName("John Doe", constants=Constants) # type: ignore[arg-type] def test_assigning_invalid_constants_after_construction_raises(self) -> None: @@ -57,16 +58,18 @@ def test_assigning_invalid_constants_after_construction_raises(self) -> None: hn.C = "garbage" # type: ignore[assignment] def test_assigning_constants_class_after_construction_raises_with_hint(self) -> None: + # same message note as the constructor variant above hn = HumanName("John Doe") - with pytest.raises(TypeError, match=r"did you mean Constants\(\)"): + with pytest.raises(TypeError, match=r"constants must be a Constants instance"): hn.C = Constants # type: ignore[assignment] - def test_assigning_none_to_constants_after_construction_builds_new_instance(self) -> None: + def test_assigning_none_to_constants_raises(self) -> None: + # constants=None was removed in 2.0 (#261, warned since 1.3.1): the v1 + # silently-build-a-fresh-Constants fallback is gone from the C setter + # too; pass Constants() or CONSTANTS.copy() explicitly hn = HumanName("John Doe") - with pytest.deprecated_call(): - hn.C = None - self.assertIsNot(hn.C, CONSTANTS) - self.assertTrue(isinstance(hn.C, Constants)) + with pytest.raises(TypeError, match="261"): + hn.C = None # type: ignore[assignment] def test_constants_bare_string_kwarg_raises_typeerror(self) -> None: # a bare string is an iterable of its characters, so set('dr') would @@ -104,8 +107,11 @@ def test_set_manager_operators_accept_lists(self) -> None: # behave like an add()-built one, normalization included c = Constants() with pytest.raises(TypeError, match=r"wrap it in a list"): - c.titles |= 'esq' - c.titles |= ['Esq.'] + c.titles |= 'esq' # type: ignore[assignment] + # |= produces a plain set that Constants.__setattr__ re-wraps in a + # SetManager (2.0's auto-wrap; see the plain-iterable assignment + # test below) -- mypy sees only the set-for-SetManager assignment + c.titles |= ['Esq.'] # type: ignore[assignment] self.assertIn('esq', c.titles) hn = HumanName("Esq Jane Smith", constants=c) self.m(hn.title, "Esq", hn) @@ -115,17 +121,20 @@ def test_set_manager_operators_normalize_like_add(self) -> None: # same normalization of operator operands, (titles | ['Esq.']) keeps # a raw 'Esq.', which the parser's lc()-based lookups can never match # — silently broken config, same failure family as the bare-string - # shredding (#238) + # shredding (#238). + # Compared via set(...) rather than v1's .elements: the raw-set + # accessor was internal machinery and is gone in 2.0 (#243 family); + # 2.0 operators also return a plain set rather than a SetManager. sm = SetManager(['dr', 'mr']) - self.assertEqual((sm | ['Esq.']).elements, {'dr', 'mr', 'esq'}) - self.assertEqual((['Esq.', 'Dr.'] | sm).elements, {'dr', 'mr', 'esq'}) - self.assertEqual((sm & ['Dr.']).elements, {'dr'}) - self.assertEqual((['Dr.'] & sm).elements, {'dr'}) - self.assertEqual((sm - ['Dr.']).elements, {'mr'}) - self.assertEqual((['Dr.', 'Esq.'] - sm).elements, {'esq'}) - self.assertEqual((sm ^ ['Dr.', 'Esq.']).elements, {'mr', 'esq'}) + self.assertEqual(set(sm | ['Esq.']), {'dr', 'mr', 'esq'}) + self.assertEqual(set(['Esq.', 'Dr.'] | sm), {'dr', 'mr', 'esq'}) + self.assertEqual(set(sm & ['Dr.']), {'dr'}) + self.assertEqual(set(['Dr.'] & sm), {'dr'}) + self.assertEqual(set(sm - ['Dr.']), {'mr'}) + self.assertEqual(set(['Dr.', 'Esq.'] - sm), {'esq'}) + self.assertEqual(set(sm ^ ['Dr.', 'Esq.']), {'mr', 'esq'}) # pins __rxor__ separately in case it ever stops aliasing __xor__ - self.assertEqual((['Dr.', 'Esq.'] ^ sm).elements, {'mr', 'esq'}) + self.assertEqual(set(['Dr.', 'Esq.'] ^ sm), {'mr', 'esq'}) def test_set_manager_contains_normalizes_like_add(self) -> None: # add()/remove()/the constructor/the operators all normalize (lowercase, @@ -150,17 +159,17 @@ def test_set_manager_rsub_is_order_sensitive(self) -> None: # in __rsub__ would silently flip the result and nothing else # in this file would catch it sm = SetManager(['dr', 'mr']) - self.assertEqual((['Dr.', 'Esq.'] - sm).elements, {'esq'}) - self.assertEqual((sm - ['Dr.', 'Esq.']).elements, {'mr'}) + self.assertEqual(set(['Dr.', 'Esq.'] - sm), {'esq'}) + self.assertEqual(set(sm - ['Dr.', 'Esq.']), {'mr'}) def test_set_manager_constructor_normalizes_like_add(self) -> None: # without constructor normalization the operators misfire against # the exact spelling visibly stored in the set: & returns empty # and - silently no-ops sm = SetManager(['Dr.', 'MR']) - self.assertEqual(sm.elements, {'dr', 'mr'}) - self.assertEqual((sm & ['Dr.']).elements, {'dr'}) - self.assertEqual((sm - ['Dr.']).elements, {'mr'}) + self.assertEqual(set(sm), {'dr', 'mr'}) + self.assertEqual(set(sm & ['Dr.']), {'dr'}) + self.assertEqual(set(sm - ['Dr.']), {'mr'}) def test_constants_kwarg_elements_are_normalized(self) -> None: # Constants(titles=[...]) was the last silently-dead config path: @@ -193,9 +202,9 @@ def test_set_manager_non_str_elements_raise_typeerror(self) -> None: # silently transmutes None into '' — raise a curated error instead with pytest.raises(TypeError, match=r"decode it first"): SetManager([b'dr']) # type: ignore[list-item] - with pytest.raises(TypeError, match=r"expected str elements"): + with pytest.raises(TypeError, match=r"expected a str"): SetManager([None]) # type: ignore[list-item] - with pytest.raises(TypeError, match=r"expected str elements"): + with pytest.raises(TypeError, match=r"expected a str"): SetManager(['dr']) | [1] # type: ignore[list-item] def test_tuplemanager_bare_string_raises_typeerror(self) -> None: @@ -255,9 +264,14 @@ def test_instances_can_have_own_constants(self) -> None: self.assertEqual(hn2.has_own_config, False) def test_can_change_global_constants(self) -> None: + # This test exercises shared-CONSTANTS mutation specifically, which + # 2.0 deprecates (removal 3.0; the message points at Lexicon/Policy + # and private Constants); deprecated_call() pins the warning while + # asserting the mutation is still honored. hn = HumanName("") hn2 = HumanName("") - hn.C.titles.remove('hon') + with pytest.deprecated_call(match="Lexicon"): + hn.C.titles.remove('hon') self.assertEqual('hon' in hn.C.titles, False) self.assertEqual('hon' in hn2.C.titles, False) self.assertEqual(hn.has_own_config, False) @@ -265,16 +279,14 @@ def test_can_change_global_constants(self) -> None: # No manual cleanup needed: the autouse fixture in conftest.py snapshots # and restores the global CONSTANTS collections around every test. - def test_can_add_global_nickname_delimiter(self) -> None: - # https://github.com/derek73/python-nameparser/issues/112 + def test_custom_nickname_delimiter_raises(self) -> None: + # Custom (non-sentinel) delimiter additions raise in 2.0 (deliberate + # divergence, migration spec section 3 -- same uniform rule as + # regexes); Policy(nickname_delimiters=...) is the replacement. The + # #112 use case this test used to pin moved to the new API. hn = HumanName("") - hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') - hn2 = HumanName("Benjamin {Ben} Franklin") - self.assertEqual(hn2.has_own_config, False) - self.m(hn2.nickname, "Ben", hn2) - # No manual cleanup needed: the autouse fixture in conftest.py snapshots - # and restores the global CONSTANTS collections (including - # nickname_delimiters) around every test. + with pytest.raises(TypeError, match="Policy"): + hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') def test_remove_multiple_arguments(self) -> None: hn = HumanName("Ms Hon Solo", constants=Constants()) @@ -301,106 +313,33 @@ def test_clear_removes_all_entries(self) -> None: self.m(hn.middle, "Hon", hn) self.m(hn.last, "Solo", hn) - def test_empty_attribute_default_assignment_emits_deprecation_warning(self) -> None: - # assigning empty_attribute_default is deprecated for removal in 2.0 - # (#255); empty attributes will always return '' once removed + def test_empty_attribute_default_removed(self) -> None: + # empty_attribute_default was removed in 2.0 (#255, warned since + # 1.3.0): empty attributes are always ''. Assignment raises naming + # the issue; the attribute no longer exists to read either. c = Constants() - with pytest.deprecated_call(match="255"): + with pytest.raises(AttributeError, match="255"): c.empty_attribute_default = None # type: ignore[assignment] - self.assertIsNone(c.empty_attribute_default) - - def test_empty_attribute_default_rejects_non_str_non_none(self) -> None: - # A cheap early check on the invariant 2.0 will fully enforce (only - # '' will be legal): non-str/non-None values fail loudly here - # instead of surfacing later as a confusing failure deep in some - # unrelated HumanName string property. - c = Constants() - with pytest.raises(TypeError, match="str or None"): - c.empty_attribute_default = 42 # type: ignore[assignment] - self.assertEqual(c.empty_attribute_default, '') - - def test_empty_attribute_default_read_does_not_warn(self) -> None: - c = Constants() - with warnings.catch_warnings(): - warnings.simplefilter("error") - self.assertEqual(c.empty_attribute_default, '') - - def test_empty_attribute_default(self) -> None: - from nameparser.config import CONSTANTS - # empty_attribute_default has no explicit annotation (mypy infers str - # from the '' default), but None is documented/supported here -- see - # the doctest on the attribute's docstring in config/__init__.py. - # Not widened to str | None like string_format/suffix_delimiter - # because it cascades into every public str-typed name accessor - # (title, first, middle, last, suffix, nickname, maiden, surnames, - # given_names, last_base, last_prefixes, initials()). - with pytest.deprecated_call(): - CONSTANTS.empty_attribute_default = None # type: ignore[assignment] + self.assertFalse(hasattr(c, 'empty_attribute_default')) hn = HumanName("") - self.m(hn.title, None, hn) - self.m(hn.first, None, hn) - self.m(hn.middle, None, hn) - self.m(hn.last, None, hn) - self.m(hn.suffix, None, hn) - self.m(hn.nickname, None, hn) - - def test_empty_attribute_on_instance(self) -> None: - hn = HumanName("", Constants()) - with pytest.deprecated_call(): - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above - self.m(hn.title, None, hn) - self.m(hn.first, None, hn) - self.m(hn.middle, None, hn) - self.m(hn.last, None, hn) - self.m(hn.suffix, None, hn) - self.m(hn.nickname, None, hn) - - def test_none_empty_attribute_string_formatting(self) -> None: - hn = HumanName("", Constants()) - with pytest.deprecated_call(): - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above - self.assertEqual('', str(hn), hn) + self.assertEqual(hn.first, '') + self.assertEqual(hn.last, '') - def test_add_constant_with_explicit_encoding(self) -> None: - # bytes input is deprecated (#245), still supported until 2.0 + def test_add_with_encoding_removed(self) -> None: + # add_with_encoding() was removed in 2.0 (#245/#263, warned in 1.4); + # decode and use add() instead c = Constants() - with pytest.deprecated_call(): - c.titles.add_with_encoding(b'b\351ck', encoding='latin_1') + with pytest.raises(AttributeError, match="add_with_encoding"): + c.titles.add_with_encoding(b'b\351ck', encoding='latin_1') # type: ignore[attr-defined] + c.titles.add(b'b\351ck'.decode('latin_1')) self.assertIn('béck', c.titles) - def test_set_manager_add_bytes_emits_deprecation_warning(self) -> None: - # bytes elements will be removed in 2.0 (#245); the caller should decode + def test_set_manager_add_bytes_raises_with_decode_hint(self) -> None: + # bytes elements were removed in 2.0 (#245, warned since 1.3.0) sm = SetManager(['dr']) - with pytest.deprecated_call(match="decode"): - sm.add(b'esq') # type: ignore[arg-type] # deliberately deprecated input - self.assertIn('esq', sm) - - def test_add_with_encoding_str_emits_deprecation_warning(self) -> None: - # add_with_encoding() itself is deprecated in 1.4 for removal in 2.0 - # (#263/#245); the str path is otherwise silent, so this method's - # own removal is unwarned without this - sm = SetManager(['dr']) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - sm.add_with_encoding('esq') - deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] - # Exactly one -- the str path must not also trip the bytes-decode - # warning (that one's for the *other* argument type). - self.assertEqual(len(deprecations), 1) - self.assertIn('add()', str(deprecations[0].message)) - self.assertIn('esq', sm) - - def test_add_with_encoding_bytes_emits_two_distinct_warnings(self) -> None: - # the bytes-decode warning (#245, shipped 1.3.0) and the - # add_with_encoding()-removal warning (#263) are independent - sm = SetManager(['dr']) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - sm.add_with_encoding(b'esq') - messages = [str(w.message) for w in caught if issubclass(w.category, DeprecationWarning)] - self.assertEqual(len(messages), 2) - self.assertTrue(any('decode' in m for m in messages)) - self.assertTrue(any('use add() instead' in m for m in messages)) + with pytest.raises(TypeError, match="decode"): + sm.add(b'esq') # type: ignore[arg-type] + self.assertNotIn('esq', sm) def test_set_manager_add_str_does_not_warn(self) -> None: sm = SetManager(['dr']) @@ -409,13 +348,14 @@ def test_set_manager_add_str_does_not_warn(self) -> None: sm.add('esq') self.assertIn('esq', sm) - def test_set_manager_call_emits_deprecation_warning(self) -> None: - # __call__ hands out the raw underlying set, bypassing normalization - # and cache invalidation; will be removed in 2.0 (#243) + def test_set_manager_call_removed(self) -> None: + # __call__ handed out the raw underlying set, bypassing normalization + # and change tracking; removed in 2.0 (#243, warned since 1.3.0) -- + # iterate the manager or copy with set(manager) instead sm = SetManager(['dr']) - with pytest.deprecated_call(match="raw underlying set"): - elements = sm() - self.assertEqual(elements, {'dr'}) + with pytest.raises(TypeError, match="not callable"): + sm() # type: ignore[operator] + self.assertEqual(set(sm), {'dr'}) def test_set_manager_discard_ignores_missing_without_warning(self) -> None: sm = SetManager(['dr', 'mr']) @@ -425,17 +365,11 @@ def test_set_manager_discard_ignores_missing_without_warning(self) -> None: self.assertIs(result, sm) self.assertEqual(set(sm), {'mr'}) - def test_set_manager_discard_invalidates_cached_union(self) -> None: - c = Constants() - self.assertIn('hon', c.suffixes_prefixes_titles) # prime the cache - c.titles.discard('hon') - self.assertNotIn('hon', c.suffixes_prefixes_titles) - - def test_set_manager_remove_missing_member_emits_deprecation_warning(self) -> None: - # ignore-missing remove() becomes KeyError in 2.0 (#243); discard() - # is the intentional ignore-missing spelling + def test_set_manager_remove_missing_member_raises(self) -> None: + # ignore-missing remove() became KeyError in 2.0 (#243, warned since + # 1.3.0); discard() is the intentional ignore-missing spelling sm = SetManager(['dr']) - with pytest.deprecated_call(match="discard"): + with pytest.raises(KeyError): sm.remove('nope') self.assertEqual(set(sm), {'dr'}) # removing a present member stays silent @@ -445,14 +379,14 @@ def test_set_manager_remove_missing_member_emits_deprecation_warning(self) -> No self.assertEqual(len(sm), 0) def test_set_manager_remove_mixed_present_and_missing_in_one_call(self) -> None: - # a single call mixing a present and a missing member must still warn - # (for the missing one) and still invalidate the cache (for the - # present one) -- not short-circuit either behavior + # a single call mixing a present and a missing member raises for the + # missing one (#243) but still applies the present removal, so + # config state and change tracking don't silently diverge from what + # was removed before the KeyError c = Constants() - self.assertIn('hon', c.suffixes_prefixes_titles) # prime the cache - with pytest.deprecated_call(match="discard"): + with pytest.raises(KeyError): c.titles.remove('hon', 'nope') - self.assertNotIn('hon', c.suffixes_prefixes_titles) + self.assertNotIn('hon', c.titles) def test_set_manager_discard_mixed_present_and_missing_in_one_call(self) -> None: sm = SetManager(['dr', 'mr']) @@ -473,7 +407,9 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None: c.titles.add('customtitle') c.prefixes.add('customprefix') c.titles.remove('hon') - c.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') + # (custom nickname delimiters raise in 2.0 -- see + # test_custom_nickname_delimiter_raises -- so the delimiter leg of + # this round-trip moved to the sentinel default below) # Safe: round-tripping a Constants the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(c)) @@ -484,93 +420,46 @@ def test_pickle_roundtrip_preserves_customizations(self) -> None: # The contributing collections must match the original exactly. self.assertEqual(set(restored.titles), set(c.titles)) self.assertEqual(set(restored.prefixes), set(c.prefixes)) - # The collections must also keep their manager type, not just contents. + # The collections must also keep their manager type, not just + # contents (nickname_delimiters is the 2.0 delimiter manager, a + # TupleManager subclass, so isinstance rather than exact type). self.assertEqual(type(restored.titles), SetManager) self.assertEqual(type(restored.prefixes), SetManager) - self.assertIn('curly_braces', restored.nickname_delimiters) - self.assertEqual(type(restored.nickname_delimiters), TupleManager) + self.assertIn('parenthesis', restored.nickname_delimiters) + self.assertTrue(isinstance(restored.nickname_delimiters, TupleManager)) def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None: - """An instance-level scalar override must survive a pickle round-trip.""" + """An instance-level scalar override must survive a pickle round-trip. + + Exercised via string_format: the v1 vehicle for this test, + empty_attribute_default, was removed in 2.0 (#255). + """ c = Constants() - with pytest.deprecated_call(): - c.empty_attribute_default = None # type: ignore[assignment] # see test_empty_attribute_default above + c.string_format = "{last}" # Safe: round-tripping a Constants the test just built, not untrusted data. - # Restoring a pickled state is not itself a #255-deprecated assignment. with warnings.catch_warnings(): warnings.simplefilter("error") restored = pickle.loads(pickle.dumps(c)) - self.assertEqual(restored.empty_attribute_default, None) - - def test_unpickle_legacy_state_with_property_key(self) -> None: - """Pickles written by older versions must still load. + self.assertEqual(restored.string_format, "{last}") - The previous __getstate__ built its state from a dir() sweep, which - always included the computed `suffixes_prefixes_titles` property (no - customization required). That property has no setter, so __setstate__ - must skip such keys instead of raising AttributeError. + def test_unpickle_legacy_state_raises(self) -> None: + """Pre-1.3.0 pickles now raise (#279, warned since 1.3.0). - Covers the temporary migration shim in __setstate__; remove this test - when that shim is dropped (a release or two after 1.3.0). + Those blobs are recognizable by the computed + ``suffixes_prefixes_titles`` property their dir()-sweep + ``__getstate__`` captured; the 1.4 DeprecationWarning promised + ValueError in 2.0, pointing at re-pickling under 1.3/1.4. """ - c = Constants() - c.titles.add('legacytitle') - # Reproduce the legacy dir()-sweep state dict, which carries the - # read-only `suffixes_prefixes_titles` property alongside the real config. - legacy_state = { - name: getattr(c, name) for name in dir(c) if not name.startswith('_') - } - self.assertIn('suffixes_prefixes_titles', legacy_state) - - restored = Constants.__new__(Constants) - with pytest.deprecated_call(): # legacy-format load deprecated (#279) - restored.__setstate__(legacy_state) - - # The real customization is recovered and the property key is ignored. - self.assertIn('legacytitle', restored.titles) - - def test_unpickle_legacy_state_emits_deprecation_warning_once(self) -> None: - # legacy-format unpickling is deprecated for removal in 2.0 (#279): - # the migration shim will stop skipping the computed-property key and - # raise ValueError instead; warn once per __setstate__ call (not once - # per skipped key) telling users to re-pickle - c = Constants() - legacy_state = { - name: getattr(c, name) for name in dir(c) if not name.startswith('_') + legacy_state: dict[str, object] = { + 'prefixes': {'van'}, + 'titles': {'dr', 'legacytitle'}, + 'suffixes_prefixes_titles': {'van', 'dr'}, } - self.assertIn('suffixes_prefixes_titles', legacy_state) - restored = Constants.__new__(Constants) - with pytest.deprecated_call(match="re-pickle") as record: + with pytest.raises(ValueError, match="279"): restored.__setstate__(legacy_state) - deprecations = [w for w in record.list if issubclass(w.category, DeprecationWarning)] - self.assertEqual(len(deprecations), 1) - - def test_unpickle_legacy_state_with_two_stale_property_keys_warns_once(self) -> None: - # Pins "once per __setstate__ call, not once per skipped key": with - # only one computed property (suffixes_prefixes_titles) on the real - # Constants class, the test above can't distinguish the two - # semantics. A second read-only property on a throwaway subclass - # forces two keys through the skip branch in one __setstate__ call. - class ConstantsWithExtraProperty(Constants): - @property - def another_computed_property(self) -> str: - return 'computed' - - c = ConstantsWithExtraProperty() - legacy_state = { - name: getattr(c, name) for name in dir(c) if not name.startswith('_') - } - self.assertIn('suffixes_prefixes_titles', legacy_state) - self.assertIn('another_computed_property', legacy_state) - - restored = ConstantsWithExtraProperty.__new__(ConstantsWithExtraProperty) - with pytest.deprecated_call(match="re-pickle") as record: - restored.__setstate__(legacy_state) - deprecations = [w for w in record.list if issubclass(w.category, DeprecationWarning)] - self.assertEqual(len(deprecations), 1) def test_setstate_without_legacy_keys_does_not_warn(self) -> None: c = Constants() @@ -584,60 +473,59 @@ def test_setstate_without_legacy_keys_does_not_warn(self) -> None: restored.__setstate__(state) self.assertIn('legacytitle', restored.titles) - def test_pickle_roundtrip_preserves_regex_manager_subclass(self) -> None: - """regexes must round-trip as a RegexTupleManager, not a plain TupleManager. + def test_pickle_roundtrip_keeps_regexes_readable(self) -> None: + """The regexes surface must survive a Constants pickle round-trip. - TupleManager.__reduce__ previously hardcoded TupleManager, so the - RegexTupleManager subclass was downgraded on unpickling. The difference - is observable: RegexTupleManager returns the EMPTY_REGEX default for an - unknown key, while a plain TupleManager returns None. + In 2.0 ``regexes`` is a read-only proxy over the built-in patterns + (not pickled state), so the v1 concern this test carried -- the + RegexTupleManager subclass being downgraded by ``__reduce__`` -- no + longer exists; what remains contractual is that reads keep working + on the restored instance and unknown keys fail loudly (#256). """ c = Constants() # Safe: round-tripping a Constants the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(c)) - self.assertEqual(type(restored.regexes), RegexTupleManager) - with pytest.deprecated_call(): # unknown-key access deprecated (#256) - self.assertEqual(restored.regexes.does_not_exist, EMPTY_REGEX) + self.assertEqual(type(restored.regexes), type(c.regexes)) + self.assertIsNotNone(restored.regexes.mac) + with pytest.raises(AttributeError): # unknown-key access raises (#256) + restored.regexes.does_not_exist def test_regexes_deepcopy_roundtrip(self) -> None: - """copy.deepcopy of a RegexTupleManager must round-trip. + """copy.deepcopy of the regexes proxy must round-trip. - __getattr__ answered every unknown name with the EMPTY_REGEX default, - including the __deepcopy__ probe copy.deepcopy issues. copy then - mistook that re.Pattern for a deep-copy hook and tried to call it. + The v1 bug this pinned: __getattr__ answered every unknown name -- + including copy.deepcopy's __deepcopy__ probe -- with the EMPTY_REGEX + default, so copy mistook a re.Pattern for a deep-copy hook. The 2.0 + proxy must keep ignoring protocol probes. """ c = Constants() dup = copy.deepcopy(c.regexes) - self.assertEqual(type(dup), RegexTupleManager) - self.assertEqual(dict(dup), dict(c.regexes)) - # The EMPTY_REGEX default still applies to genuinely unknown keys, - # but accessing one is now deprecated (#256). - with pytest.deprecated_call(): - self.assertEqual(dup.does_not_exist, EMPTY_REGEX) + self.assertEqual(type(dup), type(c.regexes)) + self.assertEqual(set(dup.keys()), set(c.regexes.keys())) + # Unknown keys raise in 2.0 (#256) instead of returning EMPTY_REGEX. + with pytest.raises(AttributeError): + dup.does_not_exist def test_nickname_delimiters_deepcopy_roundtrip(self) -> None: """copy.deepcopy of nickname_delimiters must round-trip. - Mirrors test_regexes_deepcopy_roundtrip: nickname_delimiters is a - plain TupleManager (not RegexTupleManager), but shares the same - __getattr__/__reduce__ machinery. + Mirrors test_regexes_deepcopy_roundtrip on the delimiter manager (a + TupleManager subclass in 2.0). Custom entries raise in 2.0, so the + round-trip is exercised on the three built-in sentinels. """ c = Constants() - c.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') dup = copy.deepcopy(c.nickname_delimiters) - self.assertEqual(type(dup), TupleManager) + self.assertTrue(isinstance(dup, TupleManager)) self.assertEqual(dict(dup), dict(c.nickname_delimiters)) - # Plain TupleManager has no EMPTY_REGEX fallback: unknown keys are - # None, but accessing one is now deprecated (#256). - with pytest.deprecated_call(): - self.assertIsNone(dup.does_not_exist) - self.assertIsNotNone(dup.curly_braces) + with pytest.raises(AttributeError): # unknown-key access raises (#256) + dup.does_not_exist + self.assertIsNotNone(dup.parenthesis) def test_maiden_delimiters_deepcopy_roundtrip(self) -> None: """copy.deepcopy of maiden_delimiters (empty by default) must round-trip.""" @@ -645,10 +533,10 @@ def test_maiden_delimiters_deepcopy_roundtrip(self) -> None: dup = copy.deepcopy(c.maiden_delimiters) - self.assertEqual(type(dup), TupleManager) + self.assertTrue(isinstance(dup, TupleManager)) self.assertEqual(dict(dup), {}) - with pytest.deprecated_call(): # unknown-key access deprecated (#256) - self.assertIsNone(dup.does_not_exist) + with pytest.raises(AttributeError): # unknown-key access raises (#256) + dup.does_not_exist def test_nickname_delimiters_default_builtins_resolve_live(self) -> None: # The three built-ins are stored as the *name* of a regexes entry @@ -657,11 +545,30 @@ def test_nickname_delimiters_default_builtins_resolve_live(self) -> None: # see test_overriding_builtin_regex_still_affects_nickname_parsing in # test_nicknames.py. c = Constants() - self.assertEqual(dict(c.nickname_delimiters), { - 'quoted_word': 'quoted_word', - 'double_quotes': 'double_quotes', - 'parenthesis': 'parenthesis', - }) + entries = dict(c.nickname_delimiters) + # v1 trio still present, still stored name-as-value + for name in ('quoted_word', 'double_quotes', 'parenthesis'): + self.assertEqual(entries[name], name) + # A pre-#273 pickle restores as exactly its own three keys -- + # __setstate__ REPLACES the bucket, never merges defaults in -- + # so old configs don't silently gain the typographic pairs. + legacy = Constants() + state = legacy.__getstate__() + state['nickname_delimiters'] = { + 'quoted_word': 'quoted_word', 'double_quotes': 'double_quotes', + 'parenthesis': 'parenthesis'} + restored = Constants.__new__(Constants) + restored.__setstate__(state) + self.assertEqual(set(restored.nickname_delimiters), + {'quoted_word', 'double_quotes', 'parenthesis'}) + from nameparser import HumanName + assert HumanName('John «Jack» Kennedy', constants=restored).nickname == '' + assert HumanName('John (Jack) Kennedy', constants=restored).nickname == 'Jack' + # 2.0 adds the #273 typographic sentinels alongside them, same + # name-as-value scheme (full list pinned in tests/v2/ + # test_config_shim.py against _SENTINEL_PAIRS) + assert all(value == name for name, value in entries.items()) + assert 'smart_double_quotes' in entries self.assertEqual(dict(c.maiden_delimiters), {}) def test_extra_nickname_delimiters_removed(self) -> None: @@ -682,7 +589,11 @@ def test_tuplemanager_setattr_delattr_ignore_dunder_names(self) -> None: parse_nicknames() iterates over. This bit nickname_delimiters' construction (#22) before the guard was added. """ - tm = TupleManager[re.Pattern[str] | str]({'a': re.compile('x')}) + # The 2.0 TupleManager is a plain dict subclass, not Generic like + # v1's -- but dict's inherited __class_getitem__ still makes the + # subscription work at runtime, which is exactly the GenericAlias + # __orig_class__ probe this test exists to exercise. + tm = TupleManager[re.Pattern[str] | str]({'a': re.compile('x')}) # type: ignore[misc] self.assertNotIn('__orig_class__', tm) self.assertEqual(dict(tm), {'a': re.compile('x')}) # Dunder assignment/deletion still work as normal object attributes, @@ -704,10 +615,10 @@ def test_regextuplemanager_ignores_dunder_lookups(self) -> None: sentinel = object() self.assertEqual(getattr(c.regexes, '__deepcopy__', sentinel), sentinel) - # A normal (non-dunder) unknown key still yields the EMPTY_REGEX - # default, but accessing one is now deprecated (#256). - with pytest.deprecated_call(): - self.assertEqual(c.regexes.unknown_key, EMPTY_REGEX) + # A normal (non-dunder) unknown key raises in 2.0 (#256, warned + # since 1.4) instead of degrading to the EMPTY_REGEX default. + with pytest.raises(AttributeError): + c.regexes.unknown_key def test_tuplemanager_ignores_dunder_lookups(self) -> None: """Base TupleManager must report unknown dunder names as absent too. @@ -723,32 +634,34 @@ def test_tuplemanager_ignores_dunder_lookups(self) -> None: self.assertEqual(type(tm), TupleManager) self.assertFalse(hasattr(tm, '__deepcopy__')) self.assertEqual(getattr(tm, '__wrapped__', sentinel), sentinel) - # A normal (non-dunder) unknown key still returns the None default, - # but accessing one is now deprecated (#256). - with pytest.deprecated_call(): - self.assertEqual(tm.unknown_key, None) + # A normal (non-dunder) unknown key raises in 2.0 (#256, warned + # since 1.4) instead of returning the None default. + with pytest.raises(AttributeError): + tm.unknown_key - def test_sunder_probe_does_not_emit_deprecation_warning(self) -> None: + def test_sunder_probe_reports_absent_without_deprecation_warning(self) -> None: # Single-underscore introspection probes (IPython/Jupyter's # _repr_html_, _ipython_canary_method_should_not_exist_, etc.) are - # never config keys, just like dunders -- warning on them would - # misleadingly flag a typo merely for e.g. displaying CONSTANTS.regexes - # in a notebook. No real config key starts with '_'. + # never config keys, just like dunders. In 2.0 they raise a plain + # AttributeError -- the protocol-correct "absent" answer, so + # hasattr-then-call probes work -- with no typo-flavored noise + # (v1.4 returned the manager default silently instead). c = Constants() with warnings.catch_warnings(): warnings.simplefilter("error") - self.assertEqual(c.regexes._repr_html_, EMPTY_REGEX) - self.assertIsNone(c.capitalization_exceptions._ipython_canary_method_should_not_exist_) - - def test_tuplemanager_unknown_key_emits_deprecation_warning(self) -> None: - # unknown-key attribute access is deprecated for removal in 2.0 - # (#256); will become AttributeError naming the known keys + self.assertFalse(hasattr(c.regexes, '_repr_html_')) + self.assertFalse(hasattr(c.capitalization_exceptions, + '_ipython_canary_method_should_not_exist_')) + + def test_tuplemanager_unknown_key_raises_naming_known_keys(self) -> None: + # unknown-key attribute access raises in 2.0 (#256, warned since + # 1.4): AttributeError naming the known keys, as the 1.4 warning + # promised c = Constants() tm = c.capitalization_exceptions - with pytest.deprecated_call(match="phd_typo") as record: - result = tm.phd_typo - self.assertIsNone(result) - message = str(record.list[0].message) + with pytest.raises(AttributeError, match="phd_typo") as excinfo: + tm.phd_typo + message = str(excinfo.value) for key in tm.keys(): self.assertIn(key, message) @@ -760,13 +673,13 @@ def test_tuplemanager_known_key_does_not_warn(self) -> None: first_key = next(iter(c.capitalization_exceptions)) getattr(c.capitalization_exceptions, first_key) - def test_regextuplemanager_unknown_key_emits_deprecation_warning(self) -> None: + def test_regexes_unknown_key_raises(self) -> None: + # the read-only regexes proxy raises on unknown keys too (#256); + # unlike the tuple managers its message names only the missing key, + # not the known-keys listing c = Constants() - with pytest.deprecated_call(match="parenthesys") as record: - result = c.regexes.parenthesys - self.assertEqual(result, EMPTY_REGEX) - message = str(record.list[0].message) - self.assertIn('mac', message) # a known regexes key + with pytest.raises(AttributeError, match="parenthesys"): + c.regexes.parenthesys def test_regextuplemanager_known_key_does_not_warn(self) -> None: c = Constants() @@ -784,124 +697,49 @@ def test_dunder_probe_does_not_emit_deprecation_warning(self) -> None: self.assertEqual(getattr(c.capitalization_exceptions, '__deepcopy__', sentinel), sentinel) - def test_suffixes_prefixes_titles_reflects_add_title(self) -> None: - """suffixes_prefixes_titles must include titles added after construction.""" - c = Constants() - _ = c.suffixes_prefixes_titles # prime the cache so invalidation is exercised - c.titles.add('emerita') - self.assertIn('emerita', c.suffixes_prefixes_titles) + # The v1 suffixes_prefixes_titles cached-union property and its + # invalidation machinery (including the is_rootname predicate that read + # it) are gone in 2.0 -- change tracking is the shim's generation + # counter, covered in tests/v2/test_config_shim.py. The ~13 tests that + # exercised cache priming/invalidation were deleted with it; the two + # kept below re-pin their surviving contracts on public parsing surface. - def test_suffixes_prefixes_titles_reflects_add_prefix(self) -> None: - """suffixes_prefixes_titles must include prefixes added after construction.""" - c = Constants() - _ = c.suffixes_prefixes_titles # prime the cache so invalidation is exercised - c.prefixes.add('xpfx') - self.assertIn('xpfx', c.suffixes_prefixes_titles) + def test_pickle_roundtrip_rewires_change_tracking(self) -> None: + """Mutations on a deserialized Constants must still reach the parser. - def test_suffixes_prefixes_titles_reflects_remove_title(self) -> None: - """suffixes_prefixes_titles must not include a word that was only in titles and is then removed.""" - c = Constants() - c.titles.add('emerita') - self.assertIn('emerita', c.suffixes_prefixes_titles) - c.titles.remove('emerita') - self.assertNotIn('emerita', c.suffixes_prefixes_titles) - - def test_suffixes_prefixes_titles_reflects_remove_prefix(self) -> None: - """suffixes_prefixes_titles must not include a word that was only in prefixes and is then removed.""" - c = Constants() - c.prefixes.add('xpfx') - self.assertIn('xpfx', c.suffixes_prefixes_titles) - c.prefixes.remove('xpfx') - self.assertNotIn('xpfx', c.suffixes_prefixes_titles) - - def test_suffixes_prefixes_titles_reflects_add_suffix_acronym(self) -> None: - """suffixes_prefixes_titles must include suffix acronyms added after construction.""" - c = Constants() - _ = c.suffixes_prefixes_titles # prime the cache so invalidation is exercised - c.suffix_acronyms.add('xsfx') - self.assertIn('xsfx', c.suffixes_prefixes_titles) - - def test_suffixes_prefixes_titles_reflects_add_suffix_not_acronym(self) -> None: - """suffixes_prefixes_titles must include non-acronym suffixes added after construction.""" - c = Constants() - _ = c.suffixes_prefixes_titles # prime the cache so invalidation is exercised - c.suffix_not_acronyms.add('xsfx') - self.assertIn('xsfx', c.suffixes_prefixes_titles) - - def test_pickle_roundtrip_rewires_invalidation_callbacks(self) -> None: - """Mutations on a deserialized Constants must still invalidate the cache.""" + The v1 version primed and re-read suffixes_prefixes_titles; that + cache is gone, so this pins the same contract -- post-unpickle + mutations are honored -- through an actual parse. + """ c = Constants() # Safe: round-tripping a Constants the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(c)) - _ = restored.suffixes_prefixes_titles # prime the cache + hn = HumanName("Posttitle Jane Smith", constants=restored) + self.m(hn.title, "", hn) restored.titles.add('posttitle') - self.assertIn('posttitle', restored.suffixes_prefixes_titles) - - def test_is_rootname_consistent_with_is_title(self) -> None: - """is_rootname must return False for words recognised by is_title.""" - hn = HumanName("", constants=Constants()) - _ = hn.C.suffixes_prefixes_titles # prime the cache so a stale entry would be observable - hn.C.titles.add('emerita') - self.assertFalse(hn.is_rootname('emerita')) - - def test_is_rootname_consistent_with_is_prefix(self) -> None: - """is_rootname must return False for words recognised by is_prefix.""" - hn = HumanName("", constants=Constants()) - _ = hn.C.suffixes_prefixes_titles # prime the cache so a stale entry would be observable - hn.C.prefixes.add('xpfx') - self.assertFalse(hn.is_rootname('xpfx')) - - def test_suffixes_prefixes_titles_reflects_remove_suffix_acronym(self) -> None: - """suffixes_prefixes_titles must reflect a suffix acronym removed after the cache is primed.""" - c = Constants() - c.suffix_acronyms.add('xsfx') - self.assertIn('xsfx', c.suffixes_prefixes_titles) # primes the cache - c.suffix_acronyms.remove('xsfx') - self.assertNotIn('xsfx', c.suffixes_prefixes_titles) - - def test_suffixes_prefixes_titles_reflects_remove_suffix_not_acronym(self) -> None: - """suffixes_prefixes_titles must reflect a non-acronym suffix removed after the cache is primed.""" - c = Constants() - c.suffix_not_acronyms.add('xsfx') - self.assertIn('xsfx', c.suffixes_prefixes_titles) # primes the cache - c.suffix_not_acronyms.remove('xsfx') - self.assertNotIn('xsfx', c.suffixes_prefixes_titles) - - def test_suffixes_prefixes_titles_reflects_add_with_encoding(self) -> None: - """add_with_encoding must invalidate the cache like add()/remove() do.""" - c = Constants() - _ = c.suffixes_prefixes_titles # prime the cache - with pytest.deprecated_call(): # bytes input deprecated (#245) - c.titles.add_with_encoding(b'b\351ck', encoding='latin_1') - self.assertIn('béck', c.suffixes_prefixes_titles) + hn.parse_full_name() + self.m(hn.title, "Posttitle", hn) - def test_suffixes_prefixes_titles_reflects_replaced_manager(self) -> None: - """Replacing a whole SetManager must invalidate the cache and wire the new manager. + def test_replaced_manager_is_wired_for_change_tracking(self) -> None: + """Wholesale manager replacement must wire the new manager's mutations. Covers the config-teardown path where a fresh SetManager is assigned - directly (e.g. ``setattr(CONSTANTS, 'titles', SetManager(...))``). + directly (v1 pinned this via the suffixes_prefixes_titles cache; the + 2.0 contract is that both the replacement and the new manager's own + later mutations are honored by the next parse). """ c = Constants() - _ = c.suffixes_prefixes_titles # prime the cache + hn = HumanName("Brandnewtitle Jane Smith", constants=c) + self.m(hn.title, "", hn) c.titles = SetManager(['brandnewtitle']) - # The replacement is reflected immediately... - self.assertIn('brandnewtitle', c.suffixes_prefixes_titles) - # ...and the new manager's own mutations invalidate the cache too, - # proving the on_change callback was re-wired to the replacement. - _ = c.suffixes_prefixes_titles + hn.parse_full_name() + # The replacement is reflected... + self.m(hn.title, "Brandnewtitle", hn) + # ...and the new manager's own mutations are tracked too, proving + # the change callback was re-wired to the replacement. c.titles.add('secondtitle') - self.assertIn('secondtitle', c.suffixes_prefixes_titles) - - def test_replaced_manager_no_longer_invalidates_cache(self) -> None: - """A SetManager detached by reassignment must not invalidate the new cache.""" - c = Constants() - replaced = c.titles - c.titles = SetManager(['brandnewtitle']) - primed = c.suffixes_prefixes_titles - # Mutating the orphaned manager must leave the live cache untouched. - replaced.add('ghost') - self.assertIs(c.suffixes_prefixes_titles, primed) - self.assertNotIn('ghost', c.suffixes_prefixes_titles) + hn.full_name = "Secondtitle Jane Smith" + self.m(hn.title, "Secondtitle", hn) def test_tuplemanager_delattr_removes_dict_entry(self) -> None: """Deleting a non-dunder attribute must remove the dict entry. @@ -911,58 +749,39 @@ def test_tuplemanager_delattr_removes_dict_entry(self) -> None: ``del tm['key']`` are the same operation. """ c = Constants() - c.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') - self.assertIn('curly_braces', c.nickname_delimiters) - del c.nickname_delimiters.curly_braces # type: ignore[attr-defined] - self.assertNotIn('curly_braces', c.nickname_delimiters) - - def test_assigning_non_setmanager_to_cached_union_member_raises(self) -> None: - """A cached-union attribute rejects a plain iterable, demanding a SetManager. - - The four ``_CachedUnionMember`` attributes wire an on-change callback into - the assigned manager; a bare list would silently break cache invalidation, - so __set__ fails loud with a message telling the caller to wrap it. - """ - c = Constants() - with pytest.raises(TypeError, match='SetManager'): - c.titles = ['mr', 'ms'] # type: ignore[assignment] - - def test_assigning_non_setmanager_to_plain_set_attr_raises(self) -> None: - """The five non-cached-union SetManager attributes reject bare assignment too. - - Only the four ``_CachedUnionMember`` attributes were guarded; these five - were plain instance attributes, so ``c.conjunctions = 'and'`` silently - replaced the SetManager with a str, turning later ``in`` membership - checks into substring tests (#241). + self.assertIn('ii', c.capitalization_exceptions) + del c.capitalization_exceptions.ii # type: ignore[attr-defined] + self.assertNotIn('ii', c.capitalization_exceptions) + + def test_assigning_iterable_to_set_attr_wraps_and_normalizes(self) -> None: + """Assigning a plain iterable to a set field wraps it in a SetManager. + + 2.0 divergence from v1's guard: v1 demanded a pre-built SetManager + (raising TypeError otherwise) because a bare collection would have + broken its cache-invalidation wiring; the shim wraps the value itself + (with full element validation and normalization), which protects the + same invariant -- change tracking stays wired -- without the ceremony. + Bare strings still raise (below), so #241's silent substring-test + corruption stays impossible. """ c = Constants() + c.titles = ['Mr.', 'ms'] # type: ignore[assignment] + self.assertEqual(type(c.titles), SetManager) + self.assertEqual(set(c.titles), {'mr', 'ms'}) for name in ('first_name_titles', 'conjunctions', 'bound_first_names', 'non_first_name_prefixes', 'suffix_acronyms_ambiguous'): - with pytest.raises(TypeError, match='SetManager'): - setattr(c, name, ['x']) + setattr(c, name, ['X.']) + self.assertEqual(type(getattr(c, name)), SetManager) + self.assertIn('x', getattr(c, name)) def test_bare_string_assignment_to_conjunctions_raises(self) -> None: - # the original #241 repro: 'and' assigned as a bare str silently - # degrades `piece.lower() in self.C.conjunctions` into a substring test + # the original #241 repro: 'and' assigned as a bare str would + # silently degrade `piece.lower() in self.C.conjunctions` into a + # substring test (or, wrapped naively, shred into {'a','n','d'}) c = Constants() - with pytest.raises(TypeError, match='SetManager'): + with pytest.raises(TypeError, match='wrap it in a list'): c.conjunctions = 'and' # type: ignore[assignment] - def test_setstate_raises_on_missing_descriptor_field(self) -> None: - """Unpickling a state blob missing a cached-union collection must fail loudly. - - __setstate__ verifies every ``_CachedUnionMember``-backed attribute was - restored; a missing one would otherwise surface much later as an - AttributeError on the private mangled name (e.g. ``_titles``), which is - far harder to diagnose than a named ValueError here. - """ - c = Constants() - state = dict(c.__getstate__()) - del state['titles'] - restored = Constants.__new__(Constants) - with pytest.raises(ValueError, match='titles'): - restored.__setstate__(state) - class ParsingDoesNotMutateConfigTests(HumanNameTestBase): """Parsing a name must never write back into the Constants it reads. @@ -1014,7 +833,6 @@ def _assert_config_unchanged(self, constants: Constants, before: dict, parsed: s self.assertEqual(diffs, [], f"parsing {parsed!r} changed the config: {diffs}") def _assert_parse_leaves_config_unchanged(self, name: str) -> HumanName: - from nameparser.config import CONSTANTS before = self._config_snapshot(CONSTANTS) hn = HumanName(name) self._assert_config_unchanged(CONSTANTS, before, name) @@ -1065,55 +883,34 @@ def test_derivations_reset_between_parses_of_same_instance(self) -> None: self.m(hn.last, "Roe", hn) -class SuffixesPrefixesTitlesPerformanceTests(HumanNameTestBase): - """Guard against accidental cache removal on suffixes_prefixes_titles. - - This library is commonly used to parse large batches of names, so - suffixes_prefixes_titles must remain cached. Without the cache, each call - rebuilds the union from ~700 strings; with it, repeated access is - orders of magnitude faster. Rather than compare against a fixed - wall-clock budget (which flakes on slower/noisier CI runners), this - measures the uncached build cost on the same machine and asserts cached - access is much faster than that baseline. - """ - - def test_repeated_access_is_cached(self) -> None: - c = Constants() - first = c.suffixes_prefixes_titles - second = c.suffixes_prefixes_titles - assert first is second, "suffixes_prefixes_titles should return the same cached object on repeated access" - - # Baseline: cost of an uncached build, measured via fresh instances - # (each instance's first access rebuilds the union) on this machine. - m = 50 - uncached_per_call = timeit.timeit(lambda: Constants().suffixes_prefixes_titles, number=m) / m - - n = 10_000 - cached_per_call = timeit.timeit(lambda: c.suffixes_prefixes_titles, number=n) / n - - # If caching is broken, cached_per_call would be roughly the same as - # uncached_per_call. With caching intact it should be at least an - # order of magnitude faster. - limit = uncached_per_call / 10 - assert cached_per_call < limit, ( - f"suffixes_prefixes_titles appears uncached: cached access averaged " - f"{cached_per_call * 1e6:.1f} us/call vs an uncached build cost of " - f"{uncached_per_call * 1e6:.1f} us/call. Was _pst caching removed?" - ) - - class ConstantsReprTests(HumanNameTestBase): + # The name lists live here rather than reading v1's + # Constants._repr_collection_attrs/_repr_scalar_attrs class attributes, + # which were internals and are gone in 2.0 (the shim keeps them as + # module-level tuples). empty_attribute_default left the scalar list + # with #255. + collection_attrs = ( + 'prefixes', 'suffix_acronyms', 'suffix_not_acronyms', 'titles', + 'first_name_titles', 'conjunctions', 'bound_first_names', + 'non_first_name_prefixes', 'suffix_acronyms_ambiguous', + ) + scalar_attrs = ( + 'string_format', 'initials_format', 'initials_delimiter', + 'initials_separator', 'suffix_delimiter', 'capitalize_name', + 'force_mixed_case_capitalization', 'patronymic_name_order', + 'middle_name_as_last', + ) def test_repr_reports_actual_collection_sizes(self) -> None: c = Constants() repr_str = repr(c) - for name in Constants._repr_collection_attrs: + for name in self.collection_attrs: self.assertIn(f"{name}: {len(getattr(c, name))}", repr_str) def test_repr_omits_scalars_at_default_value(self) -> None: c = Constants() repr_str = repr(c) - for name in Constants._repr_scalar_attrs: + for name in self.scalar_attrs: self.assertNotIn(name, repr_str) def test_repr_shows_scalar_override_via_constructor(self) -> None: @@ -1175,19 +972,17 @@ class CustomConstants(Constants): dup = c.copy() self.assertTrue(isinstance(dup, CustomConstants)) - def test_copy_preserves_empty_attribute_default_without_warning(self) -> None: - # copy() round-trips through __getstate__/__setstate__ like pickle - # does; restoring saved state isn't a user assignment, so it must not - # emit #255's deprecation warning (the __setstate__ bypass exists - # specifically for this call path, not just pickle.loads). + def test_copy_preserves_scalar_override_without_warning(self) -> None: + # copy() restores saved state rather than replaying user + # assignments, so it must carry scalar overrides across silently. + # (The v1 vehicle for this test, empty_attribute_default, was + # removed in 2.0 -- #255.) c = Constants() - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - c.empty_attribute_default = None # type: ignore[assignment] + c.string_format = "{last}" with warnings.catch_warnings(): warnings.simplefilter("error") dup = c.copy() - self.assertIsNone(dup.empty_attribute_default) + self.assertEqual(dup.string_format, "{last}") def test_copy_snapshots_current_customizations(self) -> None: # Unlike Constants(), which always starts from library defaults, @@ -1209,28 +1004,31 @@ def test_fresh_constants_does_not_include_source_customizations(self) -> None: self.assertNotIn('zephyrmark', fresh.titles) -class ConstantsNoneDeprecationTests(HumanNameTestBase): - """constants=None is deprecated in favor of Constants() or CONSTANTS.copy() (#260).""" +class ConstantsNoneRemovalTests(HumanNameTestBase): + """constants=None was removed in 2.0 (#261, warned since 1.3.1). - def test_explicit_none_warns_on_construction(self) -> None: - with pytest.deprecated_call(match="Constants()"): - HumanName("John Doe", constants=None) + The v1 fallback silently built a fresh Constants(), discarding any + customizations the caller may have expected to carry over; 2.0 raises + TypeError at every entry point instead. + """ - def test_explicit_none_warns_on_positional_argument(self) -> None: - with pytest.deprecated_call(match="Constants()"): - HumanName("John Doe", None) + def test_explicit_none_raises_on_construction(self) -> None: + with pytest.raises(TypeError, match="261"): + HumanName("John Doe", constants=None) # type: ignore[arg-type] - def test_explicit_none_warning_names_both_replacements(self) -> None: - with pytest.warns(DeprecationWarning) as record: - HumanName("John Doe", constants=None) - message = str(record[0].message) - self.assertIn("Constants()", message) - self.assertIn("CONSTANTS.copy()", message) + def test_explicit_none_raises_on_positional_argument(self) -> None: + with pytest.raises(TypeError, match="261"): + HumanName("John Doe", None) # type: ignore[arg-type] - def test_explicit_none_warns_on_c_setter(self) -> None: + def test_explicit_none_raises_on_c_setter(self) -> None: hn = HumanName("John Doe") - with pytest.deprecated_call(match="Constants()"): - hn.C = None + with pytest.raises(TypeError, match="261"): + hn.C = None # type: ignore[assignment] + + def test_none_error_names_the_replacement(self) -> None: + with pytest.raises(TypeError) as excinfo: + HumanName("John Doe", constants=None) # type: ignore[arg-type] + self.assertIn("Constants", str(excinfo.value)) def test_omitted_constants_argument_does_not_warn(self) -> None: with warnings.catch_warnings(): @@ -1241,9 +1039,3 @@ def test_explicit_own_constants_instance_does_not_warn(self) -> None: with warnings.catch_warnings(): warnings.simplefilter("error") HumanName("John Doe", constants=Constants()) - - def test_explicit_none_still_produces_a_working_private_config(self) -> None: - # Behavior is unchanged, only newly warned about. - with pytest.deprecated_call(): - hn = HumanName("John Doe", constants=None) - self.assertTrue(hn.has_own_config) diff --git a/tests/test_east_slavic_patronymic_order.py b/tests/test_east_slavic_patronymic_order.py index 314592a5..fb1eb11c 100644 --- a/tests/test_east_slavic_patronymic_order.py +++ b/tests/test_east_slavic_patronymic_order.py @@ -1,34 +1,39 @@ +import re +from typing import cast + from nameparser import HumanName from nameparser.config import Constants from tests.base import FlaggedConstantsTestBase, HumanNameTestBase +# The 2.0 regexes proxy types its attributes as ``object`` (reads are +# informational); the pattern-inspection tests below cast once here. +_LATIN = cast('re.Pattern[str]', Constants().regexes.east_slavic_patronymic) +_CYRILLIC = cast('re.Pattern[str]', + Constants().regexes.east_slavic_patronymic_cyrillic) + def test_latin_patronymic_matches() -> None: # One common suffix and one irregular — the integration tests cover the rest. - C = Constants() - assert C.regexes.east_slavic_patronymic.search("Ivanovich") - assert C.regexes.east_slavic_patronymic.search("Ilyich") + assert _LATIN.search("Ivanovich") + assert _LATIN.search("Ilyich") def test_latin_patronymic_rejects_non_patronymic() -> None: # EMPTY_REGEX (the default for missing keys) matches everything, # so this test is red until the real pattern is in place. - C = Constants() - assert not C.regexes.east_slavic_patronymic.search("Smith") + assert not _LATIN.search("Smith") def test_latin_patronymic_end_anchored() -> None: # A surname ending in a patronymic suffix matches; the end-anchor does not # prevent this. The parser guard tests verify reordering is suppressed. - C = Constants() - assert C.regexes.east_slavic_patronymic.search("Abramovich") + assert _LATIN.search("Abramovich") def test_cyrillic_patronymic_matches() -> None: # One common suffix and one irregular. - C = Constants() - assert C.regexes.east_slavic_patronymic_cyrillic.search("Иванович") - assert C.regexes.east_slavic_patronymic_cyrillic.search("ильич") + assert _CYRILLIC.search("Иванович") + assert _CYRILLIC.search("ильич") def test_cyrillic_patronymic_matches_capitalized_irregular_forms() -> None: @@ -36,17 +41,15 @@ def test_cyrillic_patronymic_matches_capitalized_irregular_forms() -> None: # capitalized first letter falls within the matched suffix itself, unlike # the common suffixes (-ович, -евна, ...) where only the surname root is # capitalized. Case-insensitivity is required for these to match. - C = Constants() - assert C.regexes.east_slavic_patronymic_cyrillic.search("Ильич") - assert C.regexes.east_slavic_patronymic_cyrillic.search("Кузьмич") - assert C.regexes.east_slavic_patronymic_cyrillic.search("Лукич") - assert C.regexes.east_slavic_patronymic_cyrillic.search("Фомич") - assert C.regexes.east_slavic_patronymic_cyrillic.search("Фокич") + assert _CYRILLIC.search("Ильич") + assert _CYRILLIC.search("Кузьмич") + assert _CYRILLIC.search("Лукич") + assert _CYRILLIC.search("Фомич") + assert _CYRILLIC.search("Фокич") def test_cyrillic_patronymic_rejects_non_patronymic() -> None: - C = Constants() - assert not C.regexes.east_slavic_patronymic_cyrillic.search("Иванов") + assert not _CYRILLIC.search("Иванов") class PatronymicNameOrderReorderTests(FlaggedConstantsTestBase): diff --git a/tests/test_first_name.py b/tests/test_first_name.py index 0058acc0..38ffa4bd 100644 --- a/tests/test_first_name.py +++ b/tests/test_first_name.py @@ -15,11 +15,11 @@ def test_assume_title_and_one_other_name_is_last_name(self) -> None: self.m(hn.title, "Rev", hn) self.m(hn.last, "Andrews", hn) - # TODO: Seems "Andrews, M.D.", Andrews should be treated as a last name - # but other suffixes like "George Jr." should be first names. Might be - # related to https://github.com/derek73/python-nameparser/issues/2 - @pytest.mark.xfail def test_assume_suffix_title_and_one_other_name_is_last_name(self) -> None: + # xfail in v1 (which parsed first='M.D.'); 2.0's family-comma + # suffix peel routes the post-comma suffix piece to suffix and + # keeps the pre-comma piece in last, so the long-desired + # expectation now holds. 2.0: fix(comma-family/suffix-routing). hn = HumanName("Andrews, M.D.") self.m(hn.suffix, "M.D.", hn) self.m(hn.last, "Andrews", hn) diff --git a/tests/test_initials.py b/tests/test_initials.py index c50879ff..c6ab90a5 100644 --- a/tests/test_initials.py +++ b/tests/test_initials.py @@ -1,5 +1,3 @@ -import pytest - from nameparser import HumanName from nameparser.config import Constants @@ -23,31 +21,12 @@ def test_initials_simple_name(self) -> None: hn = HumanName("John Doe", initials_format="{middle}") self.m(hn.initials(), "", hn) - def test_initials_empty_part_with_none_default_not_literal_none(self) -> None: - # Regression: when empty_attribute_default is None, an empty name part - # used to be interpolated by str.format as the literal "None" (e.g. - # "John Doe" -> "J. None D."). Empty parts must render as ''. - hn = HumanName("John Doe", constants=Constants()) - # empty_attribute_default has no explicit annotation (mypy infers str - # from the '' default), but None is documented/supported here -- see - # the doctest on the attribute's docstring in config/__init__.py. Not - # widened to str | None like string_format/suffix_delimiter because - # it cascades into every public str-typed name accessor (title, - # first, middle, last, suffix, nickname, maiden, surnames, - # given_names, last_base, last_prefixes, initials()). - with pytest.deprecated_call(): - hn.C.empty_attribute_default = None # type: ignore[assignment] - self.assertEqual(hn.initials(), "J. D.") - self.assertTrue("None" not in hn.initials()) - - def test_initials_all_empty_returns_empty_attribute_default(self) -> None: - # Regression: a fully-empty result must fall back to - # empty_attribute_default (here None), matching the first/last accessors, - # rather than rendering the literal "None None None". + def test_initials_all_empty_renders_empty_string(self) -> None: + # The v1 None display mode (and its literal-"None" interpolation + # regressions) died with #255: a fully-empty result is now always + # '', matching the first/last accessors. hn = HumanName("", constants=Constants()) - with pytest.deprecated_call(): - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test above - self.assertEqual(hn.initials(), None) + self.assertEqual(hn.initials(), "") def test_initials_middle_name_all_prefixes(self) -> None: # "Vega, Juan de la" parses with middle name "de la", which contains @@ -73,13 +52,18 @@ def test_initials_format(self) -> None: hn = HumanName("Doe, John A. Kenneth, Jr.", initials_format="{first}, {last}") self.m(hn.initials(), "J., D.", hn) + # The *_constants tests below ran on the shared CONSTANTS singleton in + # v1; 2.0 deprecates shared mutation, so they use a private Constants + # passed as constants= (the migration-guide idiom) -- the config + # attribute under test is the same either way. + def test_initials_format_constants(self) -> None: - from nameparser.config import CONSTANTS - CONSTANTS.initials_format = "{first} {last}" - hn = HumanName("Doe, John A. Kenneth, Jr.") + c = Constants() + c.initials_format = "{first} {last}" + hn = HumanName("Doe, John A. Kenneth, Jr.", constants=c) self.m(hn.initials(), "J. D.", hn) - CONSTANTS.initials_format = "{first} {last}" - hn = HumanName("Doe, John A. Kenneth, Jr.") + c.initials_format = "{first} {last}" + hn = HumanName("Doe, John A. Kenneth, Jr.", constants=c) self.m(hn.initials(), "J. D.", hn) def test_initials_delimiter(self) -> None: @@ -87,9 +71,9 @@ def test_initials_delimiter(self) -> None: self.m(hn.initials(), "J; A; K; D;", hn) def test_initials_delimiter_constants(self) -> None: - from nameparser.config import CONSTANTS - CONSTANTS.initials_delimiter = ";" - hn = HumanName("Doe, John A. Kenneth, Jr.") + c = Constants() + c.initials_delimiter = ";" + hn = HumanName("Doe, John A. Kenneth, Jr.", constants=c) self.m(hn.initials(), "J; A; K; D;", hn) def test_initials_list(self) -> None: @@ -147,13 +131,16 @@ def test_str_default_behavior_unchanged(self) -> None: self.assertEqual(str(hn), "John Doe") def test_initials_with_doubled_space_in_list_element(self) -> None: - # direct *_list assignment bypasses parse_pieces whitespace - # normalization, so initials must tolerate unnormalized elements - # instead of raising IndexError (#232) + # v1's #232 hole -- direct *_list assignment injecting unnormalized + # elements that made initials raise IndexError -- is unreachable in + # 2.0: *_list attributes are read-only snapshots (spec section 2 + # exc. 1) and the field setter normalizes whitespace, splitting a + # doubled-space element into clean members. hn = HumanName(first="John") - hn.middle_list = ["Q R"] - self.assertEqual(hn.initials_list(), ["J", "Q R"]) - self.assertEqual(hn.initials(), "J. Q R.") + hn.middle = ["Q R"] + self.assertEqual(hn.middle_list, ["Q", "R"]) + self.assertEqual(hn.initials_list(), ["J", "Q", "R"]) + self.assertEqual(hn.initials(), "J. Q. R.") def test_constructor_first(self) -> None: hn = HumanName(first="TheName") @@ -210,16 +197,16 @@ def test_initials_separator_empty_multi_part_middle(self) -> None: self.m(hn.initials(), "JAKD", hn) def test_initials_separator_constants_multi_part_middle(self) -> None: - from nameparser.config import CONSTANTS - CONSTANTS.initials_delimiter = "" - CONSTANTS.initials_separator = "" - CONSTANTS.initials_format = "{first}{middle}{last}" - hn = HumanName("Doe, John A. Kenneth") + c = Constants() + c.initials_delimiter = "" + c.initials_separator = "" + c.initials_format = "{first}{middle}{last}" + hn = HumanName("Doe, John A. Kenneth", constants=c) self.m(hn.initials(), "JAKD", hn) def test_initials_separator_default_on_constants(self) -> None: - # Runs after test_initials_separator_constants_multi_part_middle so that, - # in file/definition order, it verifies the autouse fixture restored - # CONSTANTS.initials_separator rather than leaking the "" set above. + # The shared singleton's default must be untouched by the private- + # Constants tests above (and, in file order, by anything the autouse + # fixture restores). from nameparser.config import CONSTANTS self.assertEqual(CONSTANTS.initials_separator, " ") diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index 423f5b13..2034a04d 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -18,48 +18,28 @@ def test_nickname_in_parenthesis(self) -> None: self.m(hn.nickname, "Ben", hn) # https://github.com/derek73/python-nameparser/issues/112 - def test_add_custom_nickname_delimiter(self) -> None: + def test_add_custom_nickname_delimiter_raises(self) -> None: + # Custom (non-sentinel) delimiter additions raise in 2.0 (deliberate + # divergence, migration spec section 3 -- same uniform rule as + # regexes); Policy(nickname_delimiters=...) is the replacement. The + # v1 tests for removing custom delimiters, registering several at + # once, and the ambiguous-acronym carve-out through a custom + # delimiter died with the mechanism. hn = HumanName("Benjamin {Ben} Franklin", constants=Constants()) # curly braces aren't a recognized delimiter by default self.m(hn.nickname, "", hn) - hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') - hn.parse_full_name() - self.m(hn.first, "Benjamin", hn) - self.m(hn.last, "Franklin", hn) - self.m(hn.nickname, "Ben", hn) - - def test_remove_custom_nickname_delimiter(self) -> None: - hn = HumanName("Benjamin {Ben} Franklin", constants=Constants()) - hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') - hn.parse_full_name() - self.m(hn.nickname, "Ben", hn) - del hn.C.nickname_delimiters['curly_braces'] - hn.parse_full_name() - self.m(hn.nickname, "", hn) - - def test_multiple_custom_nickname_delimiters_together(self) -> None: - # Two extras registered at once must both be recognized in a single - # parse, independent of insertion order. - hn = HumanName("Benjamin {Ben} Franklin", constants=Constants()) - hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') - hn.C.nickname_delimiters['angle_brackets'] = re.compile(r'<(.*?)>') - hn.parse_full_name() - self.m(hn.first, "Benjamin", hn) - self.m(hn.last, "Franklin", hn) - self.m(hn.nickname, "Ben Benny", hn) - - def test_overriding_builtin_regex_still_affects_nickname_parsing(self) -> None: - # The pre-existing customization path (overriding self.C.regexes - # directly) must keep working: nickname_delimiters' three built-in - # entries resolve self.C.regexes. live at parse time rather than - # storing a snapshotted pattern. + with pytest.raises(TypeError, match="Policy"): + hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') + + def test_overriding_builtin_regex_raises(self) -> None: + # v1's other customization path -- replacing a regexes entry so the + # built-in delimiter sentinels resolve to it live -- is the same + # uniform 2.0 removal: any regexes assignment raises, pointing at + # Policy. hn = HumanName("Benjamin [Ben] Franklin", constants=Constants()) self.m(hn.nickname, "", hn) - hn.C.regexes['parenthesis'] = re.compile(r'\[(.*?)\]') - hn.parse_full_name() - self.m(hn.first, "Benjamin", hn) - self.m(hn.last, "Franklin", hn) - self.m(hn.nickname, "Ben", hn) + with pytest.raises(TypeError, match="Policy"): + hn.C.regexes['parenthesis'] = re.compile(r'\[(.*?)\]') def test_two_word_nickname_in_parenthesis(self) -> None: hn = HumanName("Benjamin (Big Ben) Franklin") @@ -189,18 +169,6 @@ def test_ambiguous_suffix_acronym_in_parenthesis_stays_nickname(self) -> None: self.m(hn.nickname, "JD", hn) self.m(hn.suffix, "", hn) - def test_ambiguous_suffix_acronym_in_custom_delimiter_stays_nickname(self) -> None: - # Same suffix-vs-nickname disambiguation as above, but through a - # custom delimiter added via nickname_delimiters -- confirms - # handle_match() is applied uniformly regardless of which delimiter - # matched, not just the three built-ins. - hn = HumanName("JEFFREY {JD} BRICKEN", constants=Constants()) - hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') - hn.parse_full_name() - self.m(hn.nickname, "JD", hn) - self.m(hn.suffix, "", hn) - - class MaidenNameTestCase(HumanNameTestBase): def test_maiden_assignment_and_property(self) -> None: hn = HumanName("Jenny Baker") @@ -212,8 +180,9 @@ def test_maiden_defaults_empty(self) -> None: self.m(hn.maiden, "", hn) def test_maiden_key_always_in_as_dict(self) -> None: + # empty attributes are always '' in 2.0 (#255) hn = HumanName("Bob Dole") - self.assertEqual(hn.as_dict()['maiden'], hn.C.empty_attribute_default) + self.assertEqual(hn.as_dict()['maiden'], '') self.assertNotIn('maiden', hn.as_dict(False)) def test_maiden_appears_in_as_dict_when_populated(self) -> None: @@ -284,16 +253,15 @@ def test_maiden_appears_in_as_dict_via_routing(self) -> None: self.assertEqual(hn.as_dict()['maiden'], "Johnson") def test_unresolvable_string_sentinel_raises(self) -> None: - # A string value in nickname_delimiters/maiden_delimiters that - # doesn't name a real regexes key used to silently fall back to - # EMPTY_REGEX, which matches at every position and corrupts parsing - # (appends '' into the bucket repeatedly, and leaves the intended - # delimiter's content unstripped elsewhere in the name). It must - # raise instead. + # A string value that doesn't name a real delimiter used to silently + # fall back to EMPTY_REGEX in v1 (matching at every position and + # corrupting parsing) until 1.3 made the parse raise ValueError. 2.0 + # enforces the invariant one step earlier: the delimiter managers + # accept only the named sentinels, so the typo fails loudly at + # assignment instead of at the next parse. C = Constants() - C.nickname_delimiters['typo'] = 'parenthesus' - with pytest.raises(ValueError): - HumanName("Jenny (Johnson) Baker", constants=C) + with pytest.raises(TypeError, match="sentinel"): + C.nickname_delimiters['typo'] = 'parenthesus' def test_routing_same_delimiter_to_both_buckets_nickname_wins(self) -> None: # Misuse case: assigning the same key into both dicts instead of diff --git a/tests/test_output_format.py b/tests/test_output_format.py index d7c4cfcd..08e37a27 100644 --- a/tests/test_output_format.py +++ b/tests/test_output_format.py @@ -13,30 +13,35 @@ def test_formatting_init_argument(self) -> None: string_format="TEST1") self.assertEqual(str(hn), "TEST1") + # The four *_constants_attribute tests below ran on the shared CONSTANTS + # singleton in v1; 2.0 deprecates shared mutation, so they use a private + # Constants passed as constants= (the migration-guide idiom) -- the + # config attribute under test is the same either way. + def test_formatting_constants_attribute(self) -> None: - from nameparser.config import CONSTANTS - CONSTANTS.string_format = "TEST2" - hn = HumanName("Rev John A. Kenneth Doe III (Kenny)") + c = Constants() + c.string_format = "TEST2" + hn = HumanName("Rev John A. Kenneth Doe III (Kenny)", constants=c) self.assertEqual(str(hn), "TEST2") def test_capitalize_name_constants_attribute(self) -> None: - from nameparser.config import CONSTANTS - CONSTANTS.capitalize_name = True - hn = HumanName("bob v. de la macdole-eisenhower phd") + c = Constants() + c.capitalize_name = True + hn = HumanName("bob v. de la macdole-eisenhower phd", constants=c) self.assertEqual(str(hn), "Bob V. de la MacDole-Eisenhower Ph.D.") def test_force_mixed_case_capitalization_constants_attribute(self) -> None: - from nameparser.config import CONSTANTS - CONSTANTS.force_mixed_case_capitalization = True - hn = HumanName('Shirley Maclaine') + c = Constants() + c.force_mixed_case_capitalization = True + hn = HumanName('Shirley Maclaine', constants=c) hn.capitalize() self.assertEqual(str(hn), "Shirley MacLaine") def test_capitalize_name_and_force_mixed_case_capitalization_constants_attributes(self) -> None: - from nameparser.config import CONSTANTS - CONSTANTS.capitalize_name = True - CONSTANTS.force_mixed_case_capitalization = True - hn = HumanName('Shirley Maclaine') + c = Constants() + c.capitalize_name = True + c.force_mixed_case_capitalization = True + hn = HumanName('Shirley Maclaine', constants=c) self.assertEqual(str(hn), "Shirley MacLaine") def test_quote_nickname_formating(self) -> None: @@ -102,22 +107,13 @@ def test_formating_of_nicknames_in_middle(self) -> None: hn.nickname = '' self.assertEqual(str(hn), "Rev John A. Kenneth Doe III") - def test_name_containing_none_substring_with_none_empty_attribute_default(self) -> None: - # Regression for #254: with empty_attribute_default = None, __str__ - # scrubbed the literal string 'None' from the formatted output, - # corrupting real name text containing that substring. - hn = HumanName("Nonez Smith", Constants()) - with pytest.deprecated_call(): - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_constants.test_empty_attribute_default - self.assertEqual(str(hn), "Nonez Smith") - - def test_name_none_as_literal_name_with_none_empty_attribute_default(self) -> None: - # Companion to the #254 regression: a name piece that is exactly - # 'None' must survive formatting in None-mode. - hn = HumanName("None Smith", Constants()) - with pytest.deprecated_call(): - hn.C.empty_attribute_default = None # type: ignore[assignment] # see test_constants.test_empty_attribute_default - self.assertEqual(str(hn), "None Smith") + def test_name_containing_none_substring_survives_formatting(self) -> None: + # Residue of the #254 regression: v1's None-mode __str__ once + # scrubbed the literal string 'None' from formatted output. The + # None mode is gone in 2.0 (#255), but real name text spelled + # 'None'/'Nonez' must still never be scrubbed by formatting. + self.assertEqual(str(HumanName("Nonez Smith")), "Nonez Smith") + self.assertEqual(str(HumanName("None Smith")), "None Smith") def test_empty_field_drops_surrounding_whitespace(self) -> None: # issue #139: adjacent whitespace/punctuation should be dropped when a field is empty @@ -147,15 +143,14 @@ def test_keep_non_emojis(self) -> None: self.m(hn.last, "Smith", hn) self.assertEqual(str(hn), "∫≜⩕ Smith") - def test_keep_emojis(self) -> None: - from nameparser.config import Constants + def test_keep_emojis_opt_out_moved_to_policy(self) -> None: + # v1's regexes.emoji = False opt-out is not supported in 2.0 + # (deliberate divergence, migration spec section 3 uniform rule): + # the assignment raises, and the error names the replacement, + # Policy(strip_emoji=False). constants = Constants() - constants.regexes.emoji = False # type: ignore[assignment] - hn = HumanName("∫≜⩕ Smith😊", constants) - self.m(hn.first, "∫≜⩕", hn) - self.m(hn.last, "Smith😊", hn) - self.assertEqual(str(hn), "∫≜⩕ Smith😊") - # test cleanup + with pytest.raises(TypeError, match="strip_emoji"): + constants.regexes.emoji = False # type: ignore[assignment] def test_remove_bidi_control_chars(self) -> None: # LRM/RLM and friends ride along with copy-pasted names and stick to @@ -173,10 +168,11 @@ def test_bidi_stripped_name_compares_equal(self) -> None: hn = HumanName("\u200fمحمد بن سلمان\u200f") self.assertEqual(hn.first, "محمد") - def test_keep_bidi_control_chars(self) -> None: - from nameparser.config import Constants + def test_keep_bidi_opt_out_moved_to_policy(self) -> None: + # v1's regexes.bidi = False opt-out is not supported in 2.0 + # (deliberate divergence, migration spec section 3 uniform rule): + # the assignment raises, and the error names the replacement, + # Policy(strip_bidi=False). constants = Constants() - constants.regexes.bidi = False # type: ignore[assignment] - hn = HumanName("\u200fJohn\u200f Smith", constants) - self.m(hn.first, "\u200fJohn\u200f", hn) - self.m(hn.last, "Smith", hn) + with pytest.raises(TypeError, match="strip_bidi"): + constants.regexes.bidi = False # type: ignore[assignment] diff --git a/tests/test_parser_util.py b/tests/test_parser_util.py deleted file mode 100644 index a2b74f5d..00000000 --- a/tests/test_parser_util.py +++ /dev/null @@ -1,39 +0,0 @@ -from nameparser.parser import group_contiguous_integers - - -def test_empty_list_returns_no_ranges() -> None: - assert group_contiguous_integers([]) == [] - - -def test_all_isolated_values_returns_no_ranges() -> None: - # no two values are adjacent, so nothing counts as a "run" - assert group_contiguous_integers([1, 3, 5]) == [] - - -def test_single_value_returns_no_ranges() -> None: - # a run of length 1 isn't a contiguous "run" by this function's definition - assert group_contiguous_integers([5]) == [] - - -def test_pair_of_adjacent_values_is_smallest_valid_run() -> None: - assert group_contiguous_integers([4, 5]) == [(4, 5)] - - -def test_single_contiguous_run() -> None: - assert group_contiguous_integers([1, 2, 3]) == [(1, 3)] - - -def test_multiple_separate_contiguous_runs() -> None: - assert group_contiguous_integers([1, 2, 5, 6, 7, 9]) == [(1, 2), (5, 7)] - - -def test_isolated_values_between_runs_are_excluded() -> None: - assert group_contiguous_integers([1, 2, 4, 6, 7]) == [(1, 2), (6, 7)] - - -def test_unsorted_input_is_not_treated_as_contiguous() -> None: - # the grouping key (enumerate index - value) only repeats for ascending, - # strictly increasing runs (as produced by an `enumerate`-based index - # scan, which is how join_on_conjunctions uses it); a descending - # sequence changes the key at every step, so no run is ever found - assert group_contiguous_integers([3, 2, 1]) == [] diff --git a/tests/test_prefixes.py b/tests/test_prefixes.py index e21e3af7..26b2d75d 100644 --- a/tests/test_prefixes.py +++ b/tests/test_prefixes.py @@ -291,9 +291,11 @@ def test_non_first_name_prefix_with_custom_title(self) -> None: # 'Gunny' is NOT a default title -> genuinely exercises the custom-title # path. Title is consumed first (first_list == ['']), so the fold does # not fire and the surname is already correct; asserts we don't corrupt - # the title case. - CONSTANTS.titles.add('gunny') - hn = HumanName("Gunny de Mesnil") + # the title case. Runs on a private Constants (2.0 deprecates shared- + # CONSTANTS mutation; the custom-title path under test is identical). + c = Constants() + c.titles.add('gunny') + hn = HumanName("Gunny de Mesnil", constants=c) self.m(hn.title, "Gunny", hn) self.m(hn.first, "", hn) self.m(hn.last, "de Mesnil", hn) diff --git a/tests/test_python_api.py b/tests/test_python_api.py index f0be687e..fb763d9b 100644 --- a/tests/test_python_api.py +++ b/tests/test_python_api.py @@ -28,21 +28,23 @@ def test_string_output(self) -> None: self.m(str(hn), "Jüan de la Véña", hn) def test_escaped_utf8_bytes(self) -> None: - # bytes input is deprecated (#245), still supported until 2.0 - with pytest.deprecated_call(): - hn = HumanName(b'B\xc3\xb6ck, Gerald') + # bytes input was removed in 2.0 (#245, warned since 1.3.0): + # decode first + with pytest.raises(TypeError, match="decode"): + HumanName(b'B\xc3\xb6ck, Gerald') # type: ignore[arg-type] + hn = HumanName(b'B\xc3\xb6ck, Gerald'.decode('utf-8')) self.m(hn.first, "Gerald", hn) self.m(hn.last, "Böck", hn) - def test_bytes_full_name_emits_deprecation_warning(self) -> None: - # bytes input will be removed in 2.0 (#245); the caller should decode - with pytest.deprecated_call(match="decode"): - hn = HumanName(b'John Smith') - self.m(hn.first, "John", hn) - hn2 = HumanName("Jane Doe") - with pytest.deprecated_call(match="decode"): - hn2.full_name = b'John Smith' - self.m(hn2.first, "John", hn2) + def test_bytes_full_name_raises_with_decode_hint(self) -> None: + # bytes input was removed in 2.0 (#245); both entry points raise + with pytest.raises(TypeError, match="decode"): + HumanName(b'John Smith') # type: ignore[arg-type] + hn = HumanName("Jane Doe") + with pytest.raises(TypeError, match="decode"): + hn.full_name = b'John Smith' # type: ignore[assignment] + # the failed assignment must not have corrupted the parse + self.m(hn.first, "Jane", hn) def test_str_full_name_does_not_warn(self) -> None: with warnings.catch_warnings(): @@ -157,27 +159,6 @@ def test_name_instance_deepcopy_isolates_instance_config(self) -> None: self.assertIn('chancellor', dup.C.titles) self.assertNotIn('marker', hn.C.titles) - def test_unpickle_legacy_state_without_derived_sets(self) -> None: - """Pickles from before the per-parse derived sets existed must still work. - - Their state lacks the ``_derived_*`` attributes, which the ``is_*`` - predicates (and through them ``capitalize()``) read directly, so - ``__setstate__`` must backfill them rather than crash with - AttributeError on first use. - """ - hn = HumanName("dr. juan de la vega jr.") - legacy_state = { - k: v for k, v in hn.__getstate__().items() - if not k.startswith('_derived_') - } - - restored = HumanName.__new__(HumanName) - restored.__setstate__(legacy_state) - - self.assertTrue(restored.is_title('dr.')) - restored.capitalize() # reads _derived_prefixes via cap_word/is_prefix - self.assertEqual(str(restored), "Dr. Juan de la Vega Jr.") - def test_pickle_default_name_preserves_singleton_identity(self) -> None: """A default HumanName must re-attach to CONSTANTS after a pickle round-trip. @@ -235,22 +216,24 @@ def test_deepcopy_default_name_preserves_singleton_identity(self) -> None: self.assertEqual(dup.last, hn.last) def test_comparison(self) -> None: - # deprecated behavior (#223/#224), still supported until 2.0: - # deprecated_call() asserts the warning fires while silencing it + # == and hash reverted to object identity in 2.0 (#223, warned since + # 1.3.0): distinct instances never compare equal, however they parse, + # and strings never match. matches()/comparison_key() are the + # replacements (covered below). hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC") - with pytest.deprecated_call(): - self.assertTrue(hn1 == hn2) - self.assertIsNot(hn1, hn2) - self.assertTrue(hn1 == "Dr. John P. Doe-Ray CLU, CFP, LUTC") + self.assertTrue(hn1 == hn1) + self.assertFalse(hn1 == hn2) + self.assertIsNot(hn1, hn2) + self.assertFalse(hn1 == "Dr. John P. Doe-Ray CLU, CFP, LUTC") + self.assertTrue(hn1.matches(hn2)) # the 2.0 spelling of the old == hn1 = HumanName("Doe, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC") - with pytest.deprecated_call(): - self.assertTrue(not hn1 == hn2) - self.assertTrue(not hn1 == 0) - self.assertTrue(not hn1 == "test") - self.assertTrue(not hn1 == ["test"]) - self.assertTrue(not hn1 == {"test": hn2}) + self.assertTrue(not hn1 == hn2) + self.assertTrue(not hn1 == 0) + self.assertTrue(not hn1 == "test") + self.assertTrue(not hn1 == ["test"]) + self.assertTrue(not hn1 == {"test": hn2}) def test_assignment_to_full_name(self) -> None: hn = HumanName("John A. Kenneth Doe, Jr.") @@ -263,11 +246,6 @@ def test_assignment_to_full_name(self) -> None: self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) - def test_get_full_name_attribute_references_internal_lists(self) -> None: - hn = HumanName("John Williams") - hn.first_list = ["Larry"] - self.m(hn.full_name, "Larry Williams", hn) - def test_assignment_to_attribute(self) -> None: hn = HumanName("John A. Kenneth Doe, Jr.") hn.last = "de la Vega" @@ -299,37 +277,38 @@ def test_assign_list_to_attribute(self) -> None: self.m(hn.suffix, "test", hn) def test_comparison_case_insensitive(self) -> None: - # deprecated behavior (#223/#224), still supported until 2.0 + # 2.0 identity semantics (#223): equivalently-parsed instances no + # longer compare ==; matches() carries the case-insensitive + # component comparison instead hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("dr. john p. doe-Ray, CLU, CFP, LUTC") - with pytest.deprecated_call(): - self.assertTrue(hn1 == hn2) - self.assertIsNot(hn1, hn2) - self.assertTrue(hn1 == "Dr. John P. Doe-ray clu, CFP, LUTC") - - def test_hash_matches_case_insensitive_equality(self) -> None: - # deprecated behavior (#223/#224), still supported until 2.0. - # __eq__ compares lowercased strings, so __hash__ must too: - # equal objects are required to have equal hashes. + self.assertFalse(hn1 == hn2) + self.assertIsNot(hn1, hn2) + self.assertFalse(hn1 == "Dr. John P. Doe-ray clu, CFP, LUTC") + self.assertTrue(hn1.matches(hn2)) + self.assertTrue(hn1.matches("Dr. John P. Doe-ray clu, CFP, LUTC")) + + def test_hash_is_identity_based(self) -> None: + # 2.0 identity semantics (#223): hash reverted to the object default, + # consistent with identity ==; equal-parsing instances stay distinct + # in sets/dicts. comparison_key() is the dedup replacement. hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("dr. john p. doe-Ray, CLU, CFP, LUTC") - with pytest.deprecated_call(): - self.assertEqual(hn1, hn2) - self.assertEqual(hash(hn1), hash(hn2)) - self.assertEqual(len({hn1, hn2}), 1) - # __eq__ also accepts plain strings, so hashing str(self).lower() - # specifically (not e.g. an attribute tuple) is what lets strings - # and HumanName instances interoperate in sets and dicts - hn = HumanName("John Smith") - self.assertEqual(hash(hn), hash("john smith")) - self.assertIn("john smith", {hn}) + self.assertEqual(hash(hn1), object.__hash__(hn1)) + self.assertEqual(len({hn1, hn2}), 2) + # strings no longer interoperate with HumanName in sets/dicts + hn = HumanName("John Smith") + self.assertNotEqual(hash(hn), hash("john smith")) + self.assertNotIn("john smith", {hn}) + self.assertEqual(hn1.comparison_key(), hn2.comparison_key()) def test_not_equal_operator(self) -> None: - # deprecated behavior (#223/#224), still supported until 2.0; - # != routes through __eq__, so it warns too - with pytest.deprecated_call(): - self.assertTrue(HumanName("John Smith") != HumanName("Jane Smith")) - self.assertFalse(HumanName("John Smith") != HumanName("john smith")) + # != routes through the 2.0 identity __eq__ (#223): any two distinct + # instances are unequal, however similar their parse + self.assertTrue(HumanName("John Smith") != HumanName("Jane Smith")) + self.assertTrue(HumanName("John Smith") != HumanName("john smith")) + hn = HumanName("John Smith") + self.assertFalse(hn != hn) def test_comparison_key_components(self) -> None: hn = HumanName("Dr. Juan Q. Xavier de la Vega III") @@ -416,19 +395,15 @@ def test_matches_rejects_other_types(self) -> None: with pytest.raises(TypeError, match="str or HumanName"): hn.matches(bad) # type: ignore[arg-type] - def test_eq_emits_deprecation_warning(self) -> None: - # behavior is unchanged until 2.0 (#223); 1.3.0 only warns + def test_eq_and_hash_are_silent_in_2_0(self) -> None: + # the 1.3.0/1.4 DeprecationWarnings on __eq__/__hash__ ended with the + # 2.0 switch to identity semantics (#223): both are now warning-free hn1 = HumanName("John Smith") hn2 = HumanName("john smith") - with pytest.deprecated_call(match="matches"): - result = hn1 == hn2 - self.assertTrue(result) - - def test_hash_emits_deprecation_warning(self) -> None: - hn = HumanName("John Smith") - with pytest.deprecated_call(match="comparison_key"): - result = hash(hn) - self.assertEqual(result, hash("john smith")) + with warnings.catch_warnings(): + warnings.simplefilter("error") + self.assertFalse(hn1 == hn2) + hash(hn1) def test_new_comparison_api_does_not_warn(self) -> None: # the replacements must be adoptable before 2.0 without tripping @@ -458,21 +433,16 @@ def test_repr_blank_name(self) -> None: self.assertIn("first: ''", repr(hn)) self.assertIn(hn.__class__.__name__, repr(hn)) - def test_slice(self) -> None: + def test_slice_removed(self) -> None: + # slice access was removed in 2.0 (#258, warned since 1.4); iteration + # and string-key access are the remaining spellings hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") self.m(list(hn), ['Dr.', 'John', 'P.', 'Doe-Ray', 'CLU, CFP, LUTC'], hn) - with pytest.deprecated_call(match="Slic"): - sliced = hn[1:] - self.m(sliced, ['John', 'P.', 'Doe-Ray', 'CLU, CFP, LUTC', hn.C.empty_attribute_default, hn.C.empty_attribute_default], hn) - with pytest.deprecated_call(match="Slic"): - self.m(hn[1:-3], ['John', 'P.', 'Doe-Ray'], hn) - - def test_slice_getitem_deprecation_names_issue(self) -> None: - # slice access is deprecated for removal in 2.0 (#258); string-key - # access is unaffected and stays silent - hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") - with pytest.deprecated_call(match="258"): - hn[1:] + with pytest.raises(TypeError, match="258"): + hn[1:] # type: ignore[index] + with pytest.raises(TypeError, match="258"): + hn[1:-3] # type: ignore[index] + # string-key access is unaffected and stays silent with warnings.catch_warnings(): warnings.simplefilter("error") hn['first'] @@ -485,30 +455,16 @@ def test_getitem(self) -> None: self.m(hn['middle'], "A. Kenneth", hn) self.m(hn['suffix'], "Jr.", hn) - def test_setitem(self) -> None: - hn = HumanName("Dr. John A. Kenneth Doe, Jr.") - with pytest.deprecated_call(match="258"): - hn['title'] = 'test' - self.m(hn['title'], "test", hn) - with pytest.deprecated_call(match="258"): - hn['last'] = ['test', 'test2'] - self.m(hn['last'], "test test2", hn) - with pytest.raises(TypeError), pytest.deprecated_call(): - hn["suffix"] = [['test']] # type: ignore[list-item] - with pytest.raises(TypeError), pytest.deprecated_call(): - hn["suffix"] = {"test": "test"} # type: ignore[assignment] - - def test_setitem_invalid_key_raises_keyerror(self) -> None: - hn = HumanName("Dr. John A. Kenneth Doe, Jr.") - with pytest.raises(KeyError), pytest.deprecated_call(): - hn["bogus"] = "value" - - def test_setitem_emits_deprecation_warning_naming_attribute_assignment(self) -> None: - # __setitem__ duplicates plain attribute assignment and is deprecated - # for removal in 2.0 (#258); use hn.first = ... instead + def test_setitem_removed(self) -> None: + # __setitem__ was removed in 2.0 (#258, warned since 1.4): item + # assignment raises TypeError for every key, valid or not; plain + # attribute assignment is the replacement hn = HumanName("Dr. John A. Kenneth Doe, Jr.") - with pytest.deprecated_call(match="attribute assignment"): - hn['first'] = 'Jane' + with pytest.raises(TypeError, match="item assignment"): + hn['title'] = 'test' # type: ignore[index] + with pytest.raises(TypeError, match="item assignment"): + hn['bogus'] = 'value' # type: ignore[index] + hn.first = 'Jane' # the replacement spelling self.m(hn.first, "Jane", hn) def test_conjunction_names(self) -> None: @@ -583,55 +539,23 @@ def test_given_names_attribute_first_only(self) -> None: self.m(hn.given_names, "John", hn) def test_given_names_attribute_empty(self) -> None: + # empty attributes are always '' in 2.0 (#255) hn = HumanName("Dr. Williams") - self.m(hn.given_names, hn.C.empty_attribute_default, hn) - - def test_is_prefix_with_list(self) -> None: - hn = HumanName() - items = ['firstname', 'lastname', 'del'] - self.assertTrue(hn.is_prefix(items)) - self.assertTrue(hn.is_prefix(items[1:])) - - def test_is_prefix_with_list_no_match(self) -> None: - hn = HumanName() - self.assertFalse(hn.is_prefix(['firstname', 'lastname'])) - - def test_is_conjunction_with_list(self) -> None: - hn = HumanName() - items = ['firstname', 'lastname', 'and'] - self.assertTrue(hn.is_conjunction(items)) - self.assertTrue(hn.is_conjunction(items[1:])) - - def test_is_conjunction_with_list_no_match(self) -> None: - hn = HumanName() - self.assertFalse(hn.is_conjunction(['firstname', 'lastname'])) - - def test_is_suffix_with_list(self) -> None: - hn = HumanName() - items = ['firstname', 'lastname', 'jr'] - self.assertTrue(hn.is_suffix(items)) - self.assertTrue(hn.is_suffix(items[1:])) - - def test_is_suffix_with_list_no_match(self) -> None: - hn = HumanName() - self.assertFalse(hn.is_suffix(['firstname', 'lastname'])) + self.m(hn.given_names, "", hn) def test_override_constants(self) -> None: C = Constants() hn = HumanName(constants=C) self.assertIs(hn.C, C) - def test_override_regex(self) -> None: - # A regexes dict this sparse omits keys the parser reads - # unconditionally (e.g. squash_bidi's 'bidi'); each miss falls back - # to EMPTY_REGEX but now also warns -- unknown-key access is - # deprecated for removal in 2.0 (#256), which would make this build - # AttributeError-crash the parser instead of degrading silently. + def test_override_regex_raises(self) -> None: + # Custom regexes are not supported in 2.0 (deliberate divergence, + # migration spec §3 uniform rule): the constructor kwarg raises the + # same TypeError as attribute assignment, pointing at named Policy + # flags. Reads of the built-in patterns stay available. var = TupleManager([("spaces", re.compile(r"\s+")),]) - C = Constants(regexes=var) - with pytest.deprecated_call(): - hn = HumanName(constants=C) - self.assertTrue(hn.C.regexes == var) + with pytest.raises(TypeError, match="Policy"): + Constants(regexes=var) # type: ignore[call-arg] def test_override_titles(self) -> None: var = ["abc","def"] diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index 04d91f56..35661547 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -238,9 +238,13 @@ def test_suffix_delimiter_no_effect_without_comma(self) -> None: self.m(hn.suffix, "MD, PhD", hn) def test_suffix_delimiter_constants_level(self) -> None: - from nameparser.config import CONSTANTS - CONSTANTS.suffix_delimiter = " - " - hn = HumanName("Steven Hardman, RN - CRNA") + # ran on the shared CONSTANTS in v1; 2.0 deprecates shared mutation, + # so the config-level (non-kwarg) path is exercised on a private + # Constants passed as constants= + from nameparser.config import Constants + c = Constants() + c.suffix_delimiter = " - " + hn = HumanName("Steven Hardman, RN - CRNA", constants=c) self.m(hn.first, "Steven", hn) self.m(hn.last, "Hardman", hn) self.m(hn.suffix, "RN, CRNA", hn) @@ -254,12 +258,15 @@ def test_suffix_delimiter_none_by_default_known_limitation(self) -> None: self.m(hn.suffix, "CRNA", hn) def test_suffix_delimiter_trailing_delimiter_ignored(self) -> None: - # Trailing delimiter produces an empty token that must be filtered out. - # Using a non-whitespace-terminated delimiter so stripping doesn't consume it. + # Trailing delimiter must not defeat suffix detection. Using a + # non-whitespace-terminated delimiter so stripping doesn't consume it. hn = HumanName("John Doe, MD-PhD-", suffix_delimiter="-") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) - self.m(hn.suffix, "MD, PhD", hn) + # 2.0: fix(suffix-delimiter-rendering) -- v1 split the token and + # rendered 'MD, PhD'; v2 keeps a no-space delimiter-core token whole + # (anti-#100), same rule as the pinned 'RN/CRNA' case row. + self.m(hn.suffix, "MD-PhD-", hn) def test_suffix_delimiter_comma_space_is_noop(self) -> None: hn = HumanName("John Doe, MD, PhD", suffix_delimiter=", ") diff --git a/tests/test_titles.py b/tests/test_titles.py index 56c066c4..d01ae0bd 100644 --- a/tests/test_titles.py +++ b/tests/test_titles.py @@ -56,7 +56,11 @@ def test_title_is_title(self) -> None: hn = HumanName("Coach") self.m(hn.title, "Coach", hn) - # TODO: fix handling of U.S. + # TODO: fix handling of U.S. -- 2.0 matches v1 here: lc()-style + # normalization keeps 'u.s' out of the titles vocabulary, so the + # chain never starts (an interim 2.0 build that stripped interior + # periods made this pass, but that normalization wrongly turned + # 'J.R.' into the title 'jr'; v1 parity won) @pytest.mark.xfail def test_chained_title_first_name_title_is_initials(self) -> None: hn = HumanName("U.S. District Judge Marc Thomas Treadwell") diff --git a/tests/test_turkic_patronymic_order.py b/tests/test_turkic_patronymic_order.py index 9b026600..ad275fcb 100644 --- a/tests/test_turkic_patronymic_order.py +++ b/tests/test_turkic_patronymic_order.py @@ -1,18 +1,28 @@ +import re +from typing import cast + from nameparser import HumanName from nameparser.config import Constants from tests.base import FlaggedConstantsTestBase, HumanNameTestBase +# The 2.0 regexes proxy types its attributes as ``object`` (reads are +# informational); the pattern-inspection tests below cast once here. +_R = Constants().regexes +_TURKIC = cast('re.Pattern[str]', _R.turkic_patronymic_marker) +_TURKIC_CYR = cast('re.Pattern[str]', _R.turkic_patronymic_marker_cyrillic) +_EAST_SLAVIC = cast('re.Pattern[str]', _R.east_slavic_patronymic) +_EAST_SLAVIC_CYR = cast('re.Pattern[str]', _R.east_slavic_patronymic_cyrillic) + def test_marker_is_whole_word_not_substring() -> None: # is_turkic_patronymic_marker() deliberately uses whole-word .match(), # not suffix .search() (unlike is_east_slavic_patronymic()) — pin this # so a future .match()->.search() slip doesn't silently start matching # surnames/given-names that merely contain a marker as a substring. - C = Constants() - assert not C.regexes.turkic_patronymic_marker.match("ogluu") - assert not C.regexes.turkic_patronymic_marker.match("Bogluchik") - assert not C.regexes.turkic_patronymic_marker_cyrillic.match("оглуш") - assert not C.regexes.turkic_patronymic_marker_cyrillic.match("Оглуев") + assert not _TURKIC.match("ogluu") + assert not _TURKIC.match("Bogluchik") + assert not _TURKIC_CYR.match("оглуш") + assert not _TURKIC_CYR.match("Оглуев") class TurkicPatronymicNameOrderReorderTests(FlaggedConstantsTestBase): @@ -264,7 +274,6 @@ def test_turkic_shape_unaffected_by_east_slavic_handler(self) -> None: assert n.last == "Aliyev" def test_no_regex_collision_latin(self) -> None: - C = Constants() east_slavic_examples = [ "Ivanovich", "Ivanovna", "Sergeevich", "Sergeevna", "Nikitichna", "Ilyich", "Kuzmich", "Lukich", "Fomich", "Fokich", @@ -276,14 +285,13 @@ def test_no_regex_collision_latin(self) -> None: for word in east_slavic_examples: # Sanity check: each word actually matches its own family's # regex, so the non-collision assertion below is non-vacuous. - assert C.regexes.east_slavic_patronymic.search(word), word - assert not C.regexes.turkic_patronymic_marker.match(word), word + assert _EAST_SLAVIC.search(word), word + assert not _TURKIC.match(word), word for word in turkic_examples: - assert C.regexes.turkic_patronymic_marker.match(word), word - assert not C.regexes.east_slavic_patronymic.search(word), word + assert _TURKIC.match(word), word + assert not _EAST_SLAVIC.search(word), word def test_no_regex_collision_cyrillic(self) -> None: - C = Constants() east_slavic_examples = [ "Иванович", "Ивановна", "Сергеевич", "Сергеевна", "Никитична", "Ильич", "Кузьмич", "Лукич", "Фомич", "Фокич", @@ -295,8 +303,8 @@ def test_no_regex_collision_cyrillic(self) -> None: for word in east_slavic_examples: # Sanity check: each word actually matches its own family's # regex, so the non-collision assertion below is non-vacuous. - assert C.regexes.east_slavic_patronymic_cyrillic.search(word), word - assert not C.regexes.turkic_patronymic_marker_cyrillic.match(word), word + assert _EAST_SLAVIC_CYR.search(word), word + assert not _TURKIC_CYR.match(word), word for word in turkic_examples: - assert C.regexes.turkic_patronymic_marker_cyrillic.match(word), word - assert not C.regexes.east_slavic_patronymic_cyrillic.search(word), word + assert _TURKIC_CYR.match(word), word + assert not _EAST_SLAVIC_CYR.search(word), word diff --git a/tests/test_variations.py b/tests/test_variations.py index 249afd84..6e7c4154 100644 --- a/tests/test_variations.py +++ b/tests/test_variations.py @@ -1,5 +1,3 @@ -import warnings - from nameparser import HumanName from tests.base import HumanNameTestBase @@ -205,11 +203,9 @@ def test_variations_of_TEST_NAMES(self) -> None: if len(hn.suffix_list) > 1: d = SimpleNamespace(**hn.as_dict()) hn = HumanName(f"{d.title} {d.first} {d.middle} {d.last} {d.suffix}".split(',')[0]) - with warnings.catch_warnings(): - # format strings below require empty string; #255 deprecation - # noise isn't the point of this broad parsing-variation test - warnings.simplefilter("ignore", DeprecationWarning) - hn.C.empty_attribute_default = '' + # v1 forced empty_attribute_default='' here so the format + # strings below got '' for empty parts; 2.0 removed the setting + # (#255) and empty attributes are always '' -- nothing to force. d = SimpleNamespace(**hn.as_dict()) nocomma = HumanName(f"{d.title} {d.first} {d.middle} {d.last} {d.suffix}") lastnamecomma = HumanName(f"{d.last}, {d.title} {d.first} {d.middle} {d.suffix}") @@ -220,7 +216,9 @@ def test_variations_of_TEST_NAMES(self) -> None: lastnamecomma = HumanName(f"{d.last}, {d.title} {d.first} {d.middle} {d.suffix} ({d.nickname})") if d.suffix: suffixcomma = HumanName(f"{d.title} {d.first} {d.middle} {d.last}, {d.suffix} ({d.nickname})") - for attr in hn._members: + # v1 iterated the private hn._members; 2.0's public equivalent + # is the as_dict() key set (same seven fields) + for attr in hn.as_dict(): self.m(getattr(hn, attr), getattr(nocomma, attr), hn) self.m(getattr(hn, attr), getattr(lastnamecomma, attr), hn) if hn.suffix: diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/v2/cases.py b/tests/v2/cases.py new file mode 100644 index 00000000..004638aa --- /dev/null +++ b/tests/v2/cases.py @@ -0,0 +1,418 @@ +"""THE shared behavior case table (core spec §7.2). + +Format is fixed here, in the first pipeline PR, and never per-PR: +one Case per input, expected values for exactly the non-empty fields, +optional Policy/Locale context, and a mandatory classification -- +"parity" (matches v1.4.0, pinned live 2026-07-12) or "fix(#N)" / +"fix()" (an intentional 2.0 behavior change, annotated with its +issue or a design-decision slug). No silent expectation edits: +changing a row means changing its classification. + +The v1 suite's full corpus is extracted into this table by the +migration plan (facade runner consumes the same rows); this file seeds +it with the pinned battery. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from nameparser import Policy +from nameparser._policy import PatronymicRule + + +@dataclass(frozen=True) +class Case: + id: str + text: str + expect: dict[str, str] # field -> value; absent fields == "" + policy: Policy | None = None + locale: str | None = None # locale CODE (keeps this table + # import-light); mutually exclusive + # with policy + classification: str = "parity" + ambiguities: tuple[str, ...] = () # expected AmbiguityKind values + notes: str = "" + + def __post_init__(self) -> None: + if self.policy is not None and self.locale is not None: + raise ValueError( + f"{self.id}: policy and locale are mutually exclusive") + + +_ES = Policy(patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})) +_TK = Policy(patronymic_rules=frozenset({PatronymicRule.TURKIC})) +_SD = Policy(extra_suffix_delimiters=frozenset({" - "})) + +CASES: tuple[Case, ...] = ( + Case("plain", "John Smith", {"given": "John", "family": "Smith"}), + Case("family_comma", "Smith, John", + {"given": "John", "family": "Smith"}), + Case("suffix_comma", "John Smith, PhD", + {"given": "John", "family": "Smith", "suffix": "PhD"}), + Case("bound_given_pairwise_only", "Salem, Abdul Rahman Ahmed", + {"given": "Abdul Rahman", "middle": "Ahmed", "family": "Salem"}, + notes="the bound-given join is PAIRWISE (one merge, v1 " + "parity): the third piece stays a middle name"), + Case("family_comma_three_part_trailing_strict", "Smith, John V, Jr.", + {"given": "John", "middle": "V", "family": "Smith", + "suffix": "Jr."}, + notes="the lenient trailing test applies only to TWO-part " + "names; a third comma part makes the trailing token a " + "middle initial (v1 parity, pinned live 2026-07-17)"), + Case("triple_trailing_commas", "Doe,,,", + {"family": "Doe"}, + notes="one trailing comma is cosmetic; the rest are " + "structural empties (pinned live 2026-07-17)"), + Case("paren_suffix_word_escapes_nickname", "John Smith (Esq)", + {"given": "John", "family": "Smith", "suffix": "Esq"}, + notes="the suffix_words branch of the delimited-content " + "escape (v1 parity, pinned live 2026-07-17)"), + Case("bound_given_whole_segment", "salem, abdul salam", + {"given": "abdul salam", "family": "salem"}, + notes="v1 joins bound given names freely in the post-comma " + "segment (reserve_last=False, parser.py:1366) -- even " + "when the join consumes the whole segment"), + Case("middle_as_family_fold_order", "Hassan, Mohamad Ahmad Ali", + {"given": "Mohamad", "family": "Ahmad Ali Hassan"}, + policy=Policy(middle_as_family=True), + notes="v1 PREPENDED middle_list to last_list; folded tokens " + "carry vocab:folded-middle and the family view orders " + "them first (spans cannot reorder)"), + Case("ambiguous_surname_acronyms", "Jack Ma", + {"given": "Jack", "family": "Ma"}, + notes="'ma'/'do' joined suffix_acronyms_ambiguous: common " + "surnames need periods to read as credentials (v1 " + "parity restored; data change, flag for release log)"), + Case("ambiguous_surname_acronym_with_suffix", "Jack Ma Jr", + {"given": "Jack", "family": "Ma", "suffix": "Jr"}), + Case("initial_shaped_not_conjunction", "john e. smith", + {"given": "john", "middle": "e.", "family": "smith"}, + notes="v1 is_conjunction excludes initials at classify too"), + Case("family_comma_lenient_trailing", "Smith, John V", + {"given": "John", "family": "Smith", "suffix": "V"}, + notes="v1 #144: the trailing piece of a two-part comma name " + "takes the lenient suffix test"), + Case("family_comma_first_piece_is_given", "Steven Hardman, RN - CRNA", + {"given": "RN", "middle": "-", "family": "Steven Hardman", + "suffix": "CRNA"}, + notes="v1 walk order: the first post-comma piece is the given " + "before any suffix check (the delimiter is UNSET here " + "-- v1's documented limitation, kept)"), + Case("family_comma_lone_suffix_piece", "Andrews, M.D.", + {"family": "Andrews", "suffix": "M.D."}, + classification="fix(comma-family)", + notes="v1 made the lone post-comma strict-suffix piece the " + "given; 2.0 routes it to suffix (same family as the " + "'Smith, Dr.' row)"), + Case("period_joined_ambiguous_chunk", "John Doe, Msc.Ed.", + {"given": "John", "family": "Doe", "suffix": "Msc.Ed."}, + notes="chunk-level suffix membership is v1's is_suffix: bare " + "ambiguous acronyms count within period-joined tokens"), + Case("suffix_comma_split_phd", "John Smith, Ph. D.", + {"given": "John", "family": "Smith", "suffix": "Ph. D."}, + notes="the adjacent Ph./D. pair counts as one suffix unit in " + "the suffix-comma detection (v1 fix_phd parity)"), + Case("tail_segment_entry_space_joined", "John Smith, V MD", + {"given": "John", "family": "Smith", "suffix": "V MD"}, + notes="v1 renders each tail comma segment as ONE suffix " + "entry; words within an entry space-join via the " + "'joined' tag"), + Case("maiden_delimiters_win_when_shared", + 'Baker (Johnson), Jenny', + {"given": "Jenny", "family": "Baker", "maiden": "Johnson"}, + policy=Policy(maiden_delimiters=frozenset({("(", ")")})), + notes="listing a pair in maiden_delimiters drops it from the " + "effective nickname set (maiden wins, 2026-07-19) -- the " + "one-liner replaces the bucket-move idiom; the v1 facade " + "keeps v1's nickname-wins precedence via the shim's " + "pre-subtraction (pinned in test_config_shim)"), + # #269 follow-up: Arabic-script bound given names, mirroring the + # Latin transliterations' behavior (probed live 2026-07-19: bound + # join fires only with 3+ tokens, eats the NEXT token into given; + # two-token kunya stays split -- 'أبو مازن' pinned in test_locales; + # non-leading أبو still prefix-chains onto family). + Case("arabic_bound_given_abd", 'عبد الرحمن محمد', + {"given": "عبد الرحمن", "family": "محمد"}, + classification="feat(#269)", + notes="script twin of 'Abdul Rahman Mohammed'; عبد was the " + "missing bound entry -- 1.x split it into given عبد + " + "middle الرحمن"), + Case("arabic_bound_given_kunya", 'أبو بكر أحمد', + {"given": "أبو بكر", "family": "أحمد"}, + classification="feat(#269)"), + Case("arabic_bound_given_kunya_hamzaless", 'ابو بكر احمد', + {"given": "ابو بكر", "family": "احمد"}, + classification="feat(#269)", + notes="both kunya spellings ship, like the أبو/ابو prefix pair"), + Case("arabic_bound_given_umm", 'أم كلثوم إبراهيم', + {"given": "أم كلثوم", "family": "إبراهيم"}, + classification="feat(#269)"), + # #273: typographic nickname delimiters ship as defaults -- one row + # per new pair; expectations verified live 2026-07-19 before the + # pairs were added to DEFAULT_NICKNAME_DELIMITERS. + Case("smart_double_quotes_nickname", 'John “Jack” Kennedy', + {"given": "John", "family": "Kennedy", "nickname": "Jack"}, + classification="feat(#273)"), + Case("low_high_quotes_nickname", 'Hans „Hansi“ Müller', + {"given": "Hans", "family": "Müller", "nickname": "Hansi"}, + classification="feat(#273)", + notes="the closing '“' doubles as the English pair's opener; " + "no spurious unbalanced-delimiter ambiguity (pinned in " + "test_extract)"), + Case("guillemets_nickname_inner_spaces", 'Jean « Petit » Dupont', + {"given": "Jean", "family": "Dupont", "nickname": "Petit"}, + classification="feat(#273)", + notes="French spacing: inner padding is trimmed from the " + "extracted nickname"), + Case("reversed_guillemets_nickname", 'Hans »Hansi« Müller', + {"given": "Hans", "family": "Müller", "nickname": "Hansi"}, + classification="feat(#273)"), + Case("swedish_right_quotes_nickname", 'Anna ”Ann” Larsson', + {"given": "Anna", "family": "Larsson", "nickname": "Ann"}, + classification="feat(#273)"), + Case("cjk_corner_bracket_nickname", '山田「タロ」太郎', + {"given": "山田", "family": "太郎", "nickname": "タロ"}, + classification="feat(#273)", + notes="extraction also splits the unspaced remainder -- the " + "masked region acts as a token boundary"), + Case("cjk_white_corner_bracket_nickname", '田中『ハナ』花子', + {"given": "田中", "family": "花子", "nickname": "ハナ"}, + classification="feat(#273)"), + Case("fullwidth_paren_nickname", 'John (Jack) Kennedy', + {"given": "John", "family": "Kennedy", "nickname": "Jack"}, + classification="feat(#273)"), + Case("curly_apostrophe_stays_literal", 'Sean O’Connor', + {"given": "Sean", "family": "O’Connor"}, + notes="U+2019 is the typographic apostrophe; curly single " + "quotes are deliberately NOT delimiters (#273 excludes " + "them)"), + Case("family_segment_trailing_suffix", "Smith Jr., John", + {"given": "John", "family": "Smith", "suffix": "Jr."}, + notes="v1: the family part may have suffixes in it " + "(parser.py:1368); the first piece is always the family " + "(pinned live 2026-07-17)"), + Case("family_segment_multiple_suffixes", "Smith Jr. MD, John", + {"given": "John", "family": "Smith", "suffix": "Jr., MD"}), + Case("family_segment_particle_chain_suffix", "de la Vega III, Juan", + {"given": "Juan", "family": "de la Vega", "suffix": "III"}), + Case("interior_periods_block_vocab", "Smith, J.R.", + {"given": "J.R.", "family": "Smith"}, + notes="v1's lc() keeps interior periods: 'J.R.' is not the " + "title 'jr' (pinned live 2026-07-17)"), + Case("dotted_acronym_suffix", "John Smith M.D.", + {"given": "John", "family": "Smith", "suffix": "M.D."}, + notes="suffix-ACRONYM membership alone strips periods (v1 " + "is_suffix parity)"), + Case("nickname_rule_counts_whole_segment", "Xyz. (Bud) Smith", + {"title": "Xyz.", "given": "Smith", "nickname": "Bud"}, + notes="v1's lone-piece nickname rule counts the segment " + "BEFORE title peeling (parser.py:1285, pinned live " + "2026-07-17)"), + Case("suffix_comma_decided_by_first_segment", + "Dr. John P. Doe-Ray, CLU, CFP, LUTC", + {"title": "Dr.", "given": "John", "middle": "P.", + "family": "Doe-Ray", "suffix": "CLU, CFP, LUTC"}, + ambiguities=("comma-structure",), + notes="only parts[1] decides the suffix-comma structure " + "(v1 parser.py:1318); 'lutc' is not in the vocabulary " + "but rides along (v1 parity, pinned live 2026-07-16)"), + Case("suffix_comma_nonsuffix_tail_flagged", "John Smith, MD, Xyzzy", + {"given": "John", "family": "Smith", "suffix": "MD, Xyzzy"}, + ambiguities=("comma-structure",), + notes="the unrecognized tail is consumed best-effort with the " + "flag (v1 consumed it silently)"), + Case("period_joined_titles", "Lt.Gov. John Doe", + {"title": "Lt.Gov.", "given": "John", "family": "Doe"}, + notes="v1 derived-title rule: ANY period chunk being a title " + "makes the token a title (pinned live 2026-07-16)"), + Case("period_joined_suffixes", "John Doe JD.CPA", + {"given": "John", "family": "Doe", "suffix": "JD.CPA"}), + Case("period_joined_any_rule", "Mr.Smith", + {"title": "Mr.Smith"}, + notes="the ANY rule is deliberate v1 parity: one title chunk " + "claims the whole token"), + Case("doubled_comma_suffix", "Doe,, Jr.", + {"family": "Doe", "suffix": "Jr."}, + notes="the EMPTY given segment keeps its position: 'Jr.' is a " + "tail suffix, not a lone post-comma title (v1 parity, " + "pinned live 2026-07-16)"), + Case("doubled_comma_given_kept", "Doe, John,, Jr.", + {"given": "John", "family": "Doe", "suffix": "Jr."}), + Case("single_trailing_comma_cosmetic", "John,", + {"given": "John"}, + notes="v1 collapse_whitespace strips exactly ONE trailing " + "comma before parsing"), + Case("double_trailing_comma_structural", "Doe,,", + {"family": "Doe"}, + notes="one trailing comma is cosmetic, the second is " + "structural: an empty given segment makes this a " + "family-comma parse (v1 parity, pinned live 2026-07-16)"), + Case("doubled_comma_blocks_suffix_comma", "John Smith,, MD", + {"family": "John Smith", "suffix": "MD"}, + notes="an empty segment 1 fails the suffix-comma detection " + "(v1 parity)"), + Case("suffix_delimiter_tail_segment", "Doe, John, RN - CRNA", + {"given": "John", "family": "Doe", "suffix": "RN, CRNA"}, + policy=_SD, + notes="v1 suffix_delimiter parity (#191): the delimiter token " + "is dropped from consumed tail segments (pinned live " + "2026-07-16)"), + Case("suffix_delimiter_detection", "Doe, John RN - CRNA", + {"given": "John", "middle": "-", "family": "Doe", + "suffix": "RN, CRNA"}, + policy=_SD, + notes="the delimiter fires only at suffix sites; the stray " + "token keeps its per-piece walk role (v1 parity, pinned " + "live 2026-07-16)"), + Case("suffix_delimiter_suffix_comma", "John Smith, RN - CRNA", + {"given": "John", "family": "Smith", "suffix": "RN, CRNA"}, + policy=_SD, + notes="delimiter transparency in the SUFFIX_COMMA " + "determination: the post-comma segment counts as " + "all-suffix (v1 parity, pinned live 2026-07-16)"), + Case("suffix_delimiter_no_space_core", "John Smith, RN/CRNA", + {"given": "John", "family": "Smith", "suffix": "RN/CRNA"}, + policy=Policy(extra_suffix_delimiters=frozenset({"/"})), + classification="fix(suffix-delimiter-rendering)", + notes="v1 split 'RN/CRNA' and rendered 'RN, CRNA'; v2 keeps " + "the token whole (anti-#100) with Role.SUFFIX -- role " + "assignment matches, rendering differs (migration plan " + "deviation 5, release-log classified)"), + Case("suffix_delimiter_name_segment_untouched", "Doe, Mary - Kate, RN", + {"given": "Mary", "middle": "- Kate", "family": "Doe", + "suffix": "RN"}, + policy=_SD, + notes="a delimiter token in a NAME segment is kept (v1 parity, " + "pinned live 2026-07-16)"), + Case("comma_extras_become_suffixes", "Smith, John, Extra, Jr.", + {"given": "John", "family": "Smith", "suffix": "Extra, Jr."}, + ambiguities=("comma-structure",), + notes="post-comma segments land in suffix even when not " + "suffix-shaped; the ambiguity flags the guess (v1 " + "parity, pinned live 2026-07-13)"), + Case("delavega", "Dr. Juan de la Vega III", + {"title": "Dr.", "given": "Juan", "family": "de la Vega", + "suffix": "III"}), + Case("prefix_chain_to_end", "Juan de la Vega Martinez", + {"given": "Juan", "family": "de la Vega Martinez"}), + Case("van_johnson", "Van Johnson", + {"given": "Van", "family": "Johnson"}, + ambiguities=("particle-or-given",), + notes="v2 surfaces #121's irreducible ambiguity"), + Case("family_comma_particles", "de la Vega, Juan", + {"given": "Juan", "family": "de la Vega"}), + Case("paren_suffix_escapes_nickname", "Andrew Perkins (MBA)", + {"given": "Andrew", "family": "Perkins", "suffix": "MBA"}, + notes="v1 parse_nicknames: suffix-shaped delimited content is " + "left in place for normal parsing (pinned live " + "2026-07-17)"), + Case("paren_period_escapes_nickname", "Andrew Perkins (Ret.)", + {"given": "Andrew", "family": "Perkins", "suffix": "Ret."}), + Case("nickname_quotes", 'John "Jack" Kennedy', + {"given": "John", "family": "Kennedy", "nickname": "Jack"}), + Case("nickname_parens", "John (Jack) Kennedy", + {"given": "John", "family": "Kennedy", "nickname": "Jack"}), + Case("sir_bob", "Sir Bob Andrew Dole", + {"title": "Sir", "given": "Bob", "middle": "Andrew", + "family": "Dole"}), + Case("long_title", "President of the United States Barack Obama", + {"title": "President of the United States", + "given": "Barack", "family": "Obama"}), + Case("secretary", "The Secretary of State Hillary Clinton", + {"title": "The Secretary of State", "given": "Hillary", + "family": "Clinton"}), + Case("comma_middle_initial", "Doe, John A.", + {"given": "John", "middle": "A.", "family": "Doe"}), + Case("single", "John", {"given": "John"}), + Case("title_only", "Dr.", {"title": "Dr."}), + Case("double_comma_suffix", "Smith, John, Jr.", + {"given": "John", "family": "Smith", "suffix": "Jr."}), + Case("bound_given_two", "abdul rahman", + {"given": "abdul", "family": "rahman"}), + Case("bound_given_three", "abdul rahman al-said", + {"given": "abdul rahman", "family": "al-said"}), + Case("mr_and_mrs", "Mr. and Mrs. John Smith", + {"title": "Mr. and Mrs.", "given": "John", "family": "Smith"}), + Case("roman_suffix", "John Smith V", + {"given": "John", "family": "Smith", "suffix": "V"}), + Case("initial_not_suffix", "John V. Smith", + {"given": "John", "middle": "V.", "family": "Smith"}), + Case("lenient_after_comma", "John Ingram, V", + {"given": "John", "family": "Ingram", "suffix": "V"}), + Case("comma_then_title", "Smith, Dr. John", + {"title": "Dr.", "given": "John", "family": "Smith"}), + Case("nickname_single_name", "John (Jack)", + {"family": "John", "nickname": "Jack"}), + Case("nickname_only", "(Jack)", {"nickname": "Jack"}), + Case("suffix_run", "John Jack Kennedy PhD MD", + {"given": "John", "middle": "Jack", "family": "Kennedy", + "suffix": "PhD, MD"}), + Case("maiden_marker", "Jane Smith née Jones", + {"given": "Jane", "family": "Smith", "maiden": "Jones"}, + classification="fix(#274)", + notes="v1 mangles to middle='Smith née'"), + Case("east_slavic", "Сидоров Иван Петрович", + {"given": "Иван", "middle": "Петрович", "family": "Сидоров"}, + policy=_ES), + Case("turkic", "Mammadova Aygun Ali kizi", + {"given": "Aygun", "middle": "Ali kizi", "family": "Mammadova"}, + policy=_TK), + Case("ru_pack_formal_rotation", "Сидоров Иван Петрович", + {"given": "Иван", "middle": "Петрович", "family": "Сидоров"}, + locale="ru", + notes="the RU pack end-to-end: same expectation as the " + "east_slavic synthetic row, through parser_for"), + Case("ru_pack_transliterated", "Petrov Ivan Sergeyevich", + {"given": "Ivan", "middle": "Sergeyevich", "family": "Petrov"}, + locale="ru"), + Case("ru_pack_comma_untouched", "Петров, Иван", + {"given": "Иван", "family": "Петров"}, + locale="ru", + notes="a comma is an explicit signal that suppresses the " + "rotation (spec §1)"), + Case("tr_az_pack_marker", "Mammadova Aygun Ali kizi", + {"given": "Aygun", "middle": "Ali kizi", "family": "Mammadova"}, + locale="tr_az", + notes="the TR_AZ pack end-to-end: same expectation as the " + "turkic synthetic row (pinned live during Plan 3), " + "through parser_for"), + Case("empty", "", {}), + Case("whitespace", " ", {}), + Case("bare_ambiguous_acronym", "John Ed", + {"given": "John", "family": "Ed"}, + notes="'ed' is an ambiguous acronym; bare form is a name (C1)"), + Case("comma_ambiguous_acronym", "Smith, Ed", + {"given": "Ed", "family": "Smith"}), + Case("ambiguous_acronym_with_suffix", "John Ed III", + {"given": "John", "family": "Ed", "suffix": "III"}), + Case("phd_split", "John Ph. D.", + {"given": "John", "suffix": "Ph. D."}, + notes="v1 fix_phd; healed via the stable 'joined' tag"), + Case("phd_split_mid_name", "Dr. John Ph. D. Smith", + {"title": "Dr.", "given": "John", "family": "Smith", + "suffix": "Ph. D."}), + Case("leading_never_given_particle", "de la Vega", + {"family": "de la Vega"}, + notes="v1 handle_non_first_name_prefix: never-given leading " + "particle folds the whole name into family"), + Case("unbalanced_quote", 'Jon "Nick Smith', + {"given": "Jon", "middle": '"Nick', "family": "Smith"}, + ambiguities=("unbalanced-delimiter",), + notes="quote char stays literal (spec §5a)"), + Case("suffix_stays_suffix", "Johnson PhD", + {"given": "Johnson", "suffix": "PhD"}, + classification="fix(suffix-routing)", + notes="v1 routes a lone trailing suffix to family " + "(first=Johnson last=PhD); v2 keeps recognized " + "suffixes in suffix"), + Case("suffix_stays_suffix_title", "Mr. Johnson PhD", + {"title": "Mr.", "given": "Johnson", "suffix": "PhD"}, + classification="fix(suffix-routing)", + notes="v1 routes a lone trailing suffix to family " + "(title=Mr. first=Johnson last=PhD); v2 keeps " + "recognized suffixes in suffix"), + Case("family_comma_lone_title", "Smith, Dr.", + {"title": "Dr.", "family": "Smith"}, + classification="fix(comma-family)", + notes="pre-comma is definitionally family; v1 put it in first"), +) diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py new file mode 100644 index 00000000..9bb5e185 --- /dev/null +++ b/tests/v2/conftest.py @@ -0,0 +1,16 @@ +"""Neutralize the v1 suite's autouse dual-run fixture for tests/v2. + +tests/conftest.py runs every test twice (empty_attribute_default '' and +None) and deep-copy-snapshots the shared CONSTANTS around each test. +v2 code never reads shared CONSTANTS, so both behaviors are pure +overhead here. Overriding the fixture by name in this conftest replaces +the parametrized parent version for this directory. +""" +from collections.abc import Iterator + +import pytest + + +@pytest.fixture(autouse=True) +def empty_attribute_default() -> Iterator[None]: + yield diff --git a/tests/v2/data/constants_v14.pickle b/tests/v2/data/constants_v14.pickle new file mode 100644 index 00000000..0e944465 Binary files /dev/null and b/tests/v2/data/constants_v14.pickle differ diff --git a/tests/v2/data/humanname_v14.pickle b/tests/v2/data/humanname_v14.pickle new file mode 100644 index 00000000..718585a3 Binary files /dev/null and b/tests/v2/data/humanname_v14.pickle differ diff --git a/tests/v2/pipeline/__init__.py b/tests/v2/pipeline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/v2/pipeline/test_assemble.py b/tests/v2/pipeline/test_assemble.py new file mode 100644 index 00000000..ba667825 --- /dev/null +++ b/tests/v2/pipeline/test_assemble.py @@ -0,0 +1,76 @@ +from nameparser._lexicon import Lexicon +from nameparser._pipeline import run +from nameparser._pipeline._assemble import assemble +from nameparser._pipeline._state import ParseState +from nameparser._policy import Policy +from nameparser._types import AmbiguityKind, ParsedName + +_LEX = Lexicon( + titles=frozenset({"dr"}), + particles=frozenset({"de", "la", "van"}), + particles_ambiguous=frozenset({"van"}), + suffix_words=frozenset({"iii"}), + maiden_markers=frozenset({"née"}), +) + + +def _parse(text: str) -> ParsedName: + return assemble(run(ParseState(original=text, lexicon=_LEX, + policy=Policy()))) + + +def test_assemble_produces_validated_parsedname() -> None: + pn = _parse("Dr. Juan de la Vega III") + assert pn.title == "Dr." + assert pn.given == "Juan" + assert pn.family == "de la Vega" + assert pn.family_base == "Vega" # particle tags carried over + assert pn.family_particles == "de la" + assert pn.suffix == "III" + assert pn.original == "Dr. Juan de la Vega III" + for t in pn.tokens: + assert t.span is not None + assert t.text == pn.original[t.span.start:t.span.end] + + +def test_assemble_materializes_ambiguities_on_final_tokens() -> None: + pn = _parse("Van Johnson") + assert len(pn.ambiguities) == 1 + amb = pn.ambiguities[0] + assert amb.kind is AmbiguityKind.PARTICLE_OR_GIVEN + assert amb.tokens[0] is pn.tokens[0] + + +def test_assemble_drops_structural_marker_tokens() -> None: + pn = _parse("Jane Smith née Jones") + assert [t.text for t in pn.tokens] == ["Jane", "Smith", "Jones"] + assert pn.maiden == "Jones" + + +def test_empty_parse_is_falsy() -> None: + assert not _parse("") + assert not _parse(" ") + + +def test_ambiguity_with_all_indices_dropped_is_omitted() -> None: + # an ambiguity whose referent tokens were ALL dropped describes + # nothing; emitting it hollow would mislead consumers. Born-empty + # ambiguities (unbalanced delimiters) are kept -- they are + # token-independent by design. + from nameparser._pipeline._state import PendingAmbiguity + from nameparser._types import AmbiguityKind as AK + import dataclasses + state = run(ParseState(original="Jane Smith née Jones", lexicon=_LEX, + policy=Policy())) + née_idx = next(i for i, t in enumerate(state.tokens) + if t.text == "née") + poisoned = dataclasses.replace( + state, ambiguities=state.ambiguities + ( + PendingAmbiguity(AK.ORDER, "refers only to the marker", + (née_idx,)), + PendingAmbiguity(AK.UNBALANCED_DELIMITER, "born empty", ()), + )) + pn = assemble(poisoned) + kinds = [a.kind for a in pn.ambiguities] + assert AK.ORDER not in kinds # fully dangled: omitted + assert AK.UNBALANCED_DELIMITER in kinds # born empty: kept diff --git a/tests/v2/pipeline/test_assign.py b/tests/v2/pipeline/test_assign.py new file mode 100644 index 00000000..88bb4352 --- /dev/null +++ b/tests/v2/pipeline/test_assign.py @@ -0,0 +1,158 @@ +# tests/v2/pipeline/test_assign.py +from nameparser._lexicon import Lexicon +from nameparser._pipeline._assign import assign +from nameparser._pipeline._classify import classify +from nameparser._pipeline._extract import extract_delimited +from nameparser._pipeline._group import group +from nameparser._pipeline._segment import segment +from nameparser._pipeline._state import ParseState +from nameparser._pipeline._tokenize import tokenize +from nameparser._policy import FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, Policy +from nameparser._types import AmbiguityKind, Role + +_LEX = Lexicon( + titles=frozenset({"dr", "mr", "mrs", "sir"}), + given_name_titles=frozenset({"sir"}), + suffix_acronyms=frozenset({"phd", "md"}), + suffix_words=frozenset({"jr", "iii", "v"}), + particles=frozenset({"de", "la", "van"}), + particles_ambiguous=frozenset({"van"}), + conjunctions=frozenset({"and"}), + maiden_markers=frozenset({"née"}), +) + + +def _assigned(text: str, policy: Policy | None = None) -> ParseState: + state = ParseState(original=text, lexicon=_LEX, + policy=policy or Policy()) + return assign(group(classify(segment(tokenize( + extract_delimited(state)))))) + + +def _by_role(state: ParseState, role: Role) -> str: + return " ".join(t.text for t in state.tokens if t.role is role) + + +def test_given_first_positional() -> None: + out = _assigned("Dr. Juan de la Vega III") + assert _by_role(out, Role.TITLE) == "Dr." + assert _by_role(out, Role.GIVEN) == "Juan" + assert _by_role(out, Role.FAMILY) == "de la Vega" + assert _by_role(out, Role.SUFFIX) == "III" + + +def test_middles() -> None: + out = _assigned("John Quincy Adams Smith") + assert _by_role(out, Role.GIVEN) == "John" + assert _by_role(out, Role.MIDDLE) == "Quincy Adams" + assert _by_role(out, Role.FAMILY) == "Smith" + + +def test_single_token_takes_name_order_head() -> None: + assert _by_role(_assigned("John"), Role.GIVEN) == "John" + out = _assigned("John", Policy(name_order=FAMILY_FIRST)) + assert _by_role(out, Role.FAMILY) == "John" + + +def test_title_only() -> None: + out = _assigned("Dr.") + assert _by_role(out, Role.TITLE) == "Dr." + assert not _by_role(out, Role.GIVEN) + + +def test_leading_ambiguous_particle_reads_as_given_with_ambiguity() -> None: + out = _assigned("Van Johnson") + assert _by_role(out, Role.GIVEN) == "Van" + assert _by_role(out, Role.FAMILY) == "Johnson" + assert any(a.kind is AmbiguityKind.PARTICLE_OR_GIVEN + for a in out.ambiguities) + # unambiguous input: no ambiguity recorded + assert not _assigned("John Smith").ambiguities + + +def test_family_comma() -> None: + out = _assigned("de la Vega, Juan") + assert _by_role(out, Role.FAMILY) == "de la Vega" + assert _by_role(out, Role.GIVEN) == "Juan" + + +def test_family_comma_with_title_and_middle() -> None: + out = _assigned("Smith, Dr. John A.") + assert _by_role(out, Role.TITLE) == "Dr." + assert _by_role(out, Role.GIVEN) == "John" + assert _by_role(out, Role.MIDDLE) == "A." + assert _by_role(out, Role.FAMILY) == "Smith" + + +def test_family_comma_lone_post_comma_title() -> None: + # v1 parity: a lone post-comma title is a TITLE ('Smith, Dr.'), + # not a given name or suffix; post_rules later applies the + # title+lone-family handling + out = _assigned("Smith, Dr.") + assert _by_role(out, Role.TITLE) == "Dr." + assert _by_role(out, Role.FAMILY) == "Smith" + assert not _by_role(out, Role.GIVEN) + + +def test_suffix_comma_and_extra_segments() -> None: + out = _assigned("Smith, John, Jr.") + assert _by_role(out, Role.GIVEN) == "John" + assert _by_role(out, Role.FAMILY) == "Smith" + assert _by_role(out, Role.SUFFIX) == "Jr." + + +def test_trailing_suffix_run_no_comma() -> None: + out = _assigned("John Jack Kennedy PhD MD") + assert _by_role(out, Role.MIDDLE) == "Jack" + assert _by_role(out, Role.FAMILY) == "Kennedy" + assert _by_role(out, Role.SUFFIX) == "PhD MD" + + +def test_initial_veto_keeps_v_in_middle() -> None: + out = _assigned("John V. Smith") + assert _by_role(out, Role.MIDDLE) == "V." + assert _by_role(out, Role.FAMILY) == "Smith" + + +def test_nickname_only_leaves_name_fields_empty() -> None: + out = _assigned("(Jack)") + assert _by_role(out, Role.NICKNAME) == "Jack" + assert not _by_role(out, Role.GIVEN) and not _by_role(out, Role.FAMILY) + + +def test_single_name_with_nickname_goes_to_family() -> None: + out = _assigned("John (Jack)") + assert _by_role(out, Role.FAMILY) == "John" + assert not _by_role(out, Role.GIVEN) + + +def test_family_first_order() -> None: + out = _assigned("Yamada Taro", Policy(name_order=FAMILY_FIRST)) + assert _by_role(out, Role.FAMILY) == "Yamada" + assert _by_role(out, Role.GIVEN) == "Taro" + out2 = _assigned("Yamada Hanako Taro", + Policy(name_order=FAMILY_FIRST)) + assert _by_role(out2, Role.FAMILY) == "Yamada" + assert _by_role(out2, Role.GIVEN) == "Hanako" + assert _by_role(out2, Role.MIDDLE) == "Taro" + + +def test_family_first_given_last_order() -> None: + # Pinned against the actual built pipeline (2026-07-12), not guessed: + # 'Van' is particles_ambiguous and non-leading, so group's prefix + # chain (which is name_order-agnostic -- it lands upstream of assign + # and doesn't consult Policy) absorbs 'Van Anh Thu' into ONE piece + # before assign ever sees it. With only two name pieces left, + # FAMILY_FIRST_GIVEN_LAST's positional rule (family, then given for + # count==2) correctly assigns Nguyen -> FAMILY and the whole merged + # piece -> GIVEN; there is no MIDDLE piece to assign. This is a + # sensible consequence of the current, order-agnostic group stage -- + # not a contract violation of assign -- and is the exact kind of gap + # the Vietnamese-aware locale pack (#270/#272 follow-ups) is meant to + # close by teaching group to suppress the particle chain under this + # name_order. Do not "fix" this by changing assign. + out = _assigned("Nguyen Van Anh Thu", + Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) + assert _by_role(out, Role.FAMILY) == "Nguyen" + assert _by_role(out, Role.GIVEN) == "Van Anh Thu" + assert _by_role(out, Role.MIDDLE) == "" diff --git a/tests/v2/pipeline/test_classify.py b/tests/v2/pipeline/test_classify.py new file mode 100644 index 00000000..ef0ace19 --- /dev/null +++ b/tests/v2/pipeline/test_classify.py @@ -0,0 +1,78 @@ +from nameparser._lexicon import Lexicon +from nameparser._pipeline._classify import classify +from nameparser._pipeline._extract import extract_delimited +from nameparser._pipeline._segment import segment +from nameparser._pipeline._state import ParseState +from nameparser._pipeline._tokenize import tokenize +from nameparser._policy import Policy + +_LEX = Lexicon( + titles=frozenset({"dr", "sir"}), + given_name_titles=frozenset({"sir"}), + suffix_acronyms=frozenset({"phd", "ma"}), + suffix_words=frozenset({"jr", "v"}), + suffix_acronyms_ambiguous=frozenset({"ma"}), + particles=frozenset({"de", "la", "van"}), + particles_ambiguous=frozenset({"van"}), + conjunctions=frozenset({"and", "y"}), + bound_given_names=frozenset({"abdul"}), + maiden_markers=frozenset({"née"}), +) + + +def _classified(text: str) -> ParseState: + state = ParseState(original=text, lexicon=_LEX, policy=Policy()) + return classify(segment(tokenize(extract_delimited(state)))) + + +def _tags(state: ParseState, text: str) -> frozenset[str]: + return next(t.tags for t in state.tokens if t.text == text) + + +def test_vocabulary_tags() -> None: + out = _classified("Dr. van de la Smith and abdul née PhD Jr") + assert "vocab:title" in _tags(out, "Dr.") + assert {"particle", "vocab:particle-ambiguous"} <= _tags(out, "van") + assert "particle" in _tags(out, "de") + assert "vocab:particle-ambiguous" not in _tags(out, "de") + assert "conjunction" in _tags(out, "and") + assert "vocab:bound-given" in _tags(out, "abdul") + assert "vocab:maiden-marker" in _tags(out, "née") + assert "vocab:suffix" in _tags(out, "PhD") + assert {"vocab:suffix", "vocab:suffix-word"} <= _tags(out, "Jr") + assert _tags(out, "Smith") == frozenset() + + +def test_given_title_tag() -> None: + out = _classified("Sir John") + assert {"vocab:title", "vocab:given-title"} <= _tags(out, "Sir") + + +def test_initial_tag() -> None: + out = _classified("John A. B Smith") + assert "initial" in _tags(out, "A.") + assert "initial" in _tags(out, "B") + assert "initial" not in _tags(out, "John") + + +def test_ambiguous_suffix_acronym_needs_periods() -> None: + out = _classified("M.A. Ma") + assert "vocab:suffix" in _tags(out, "M.A.") + assert "vocab:suffix" not in _tags(out, "Ma") + assert "vocab:suffix-ambiguous" in _tags(out, "Ma") + + +def test_v_is_suffix_word_and_initial() -> None: + # both tags present; assign applies the veto, not classify + out = _classified("John V Smith") + assert {"vocab:suffix", "vocab:suffix-word", "initial"} <= _tags(out, "V") + + +def test_bare_ambiguous_acronym_in_acronyms_is_not_suffix() -> None: + # the default lexicon has suffix_acronyms_ambiguous SUBSET OF + # suffix_acronyms (v1 data shape); the plain membership test must + # exclude the ambiguous members or the period gate is dead code + # and bare 'Ed'/'Jd' silently become suffixes (PR review C1) + out = _classified("Ma M.A.") + assert "vocab:suffix" not in _tags(out, "Ma") + assert "vocab:suffix" in _tags(out, "M.A.") diff --git a/tests/v2/pipeline/test_extract.py b/tests/v2/pipeline/test_extract.py new file mode 100644 index 00000000..6f07c810 --- /dev/null +++ b/tests/v2/pipeline/test_extract.py @@ -0,0 +1,181 @@ +import dataclasses + +from nameparser._lexicon import Lexicon +from nameparser._pipeline._extract import extract_delimited +from nameparser._pipeline._state import ParseState +from nameparser._policy import Policy +from nameparser._types import AmbiguityKind, Role, Span + + +def _state(text: str, policy: Policy | None = None) -> ParseState: + return ParseState(original=text, lexicon=Lexicon.empty(), + policy=policy or Policy()) + + +def test_double_quoted_nickname_extracted() -> None: + # 0123456789012345678 + out = extract_delimited(_state('John "Jack" Kennedy')) + assert out.extracted == ((Role.NICKNAME, Span(6, 10)),) + assert out.masked == (Span(5, 11),) + + +def test_parenthesized_nickname_extracted() -> None: + out = extract_delimited(_state("John (Jack) Kennedy")) + assert out.extracted == ((Role.NICKNAME, Span(6, 10)),) + + +def test_same_char_delimiter_needs_boundaries() -> None: + # the apostrophe in O'Connor is not an opening quote + out = extract_delimited(_state("Sean O'Connor")) + assert out.extracted == () and out.masked == () + + +def test_single_quoted_nickname_at_boundaries() -> None: + # 01234567890123456789 + out = extract_delimited(_state("John 'Jack' Kennedy")) + assert out.extracted == ((Role.NICKNAME, Span(6, 10)),) + + +def test_unbalanced_delimiter_left_literal_with_ambiguity() -> None: + out = extract_delimited(_state('Jon "Nick Smith')) + assert out.extracted == () and out.masked == () + assert len(out.ambiguities) == 1 + assert out.ambiguities[0].kind is AmbiguityKind.UNBALANCED_DELIMITER + + +def test_no_spurious_unbalanced_from_role_overlapping_pairs() -> None: + # #273: '“' closes the German „…“ pair but OPENS the English “…” + # pair, and '»' closes «…» but opens the reversed »…« pair. A + # delimiter character consumed by another pair's successful + # extraction is literal for every other pair -- it must not + # surface as an unbalanced-delimiter ambiguity. + policy = Policy(nickname_delimiters=frozenset( + {("“", "”"), ("„", "“"), ("«", "»"), ("»", "«")})) + for text, inner in (("Hans „Hansi“ Müller", "Hansi"), + ("Jean «Petit» Dupont", "Petit")): + out = extract_delimited(_state(text, policy)) + assert [text[s.start:s.end] for _, s in out.extracted] == [inner] + assert out.ambiguities == () + + +def test_mixed_conventions_both_extract_leftmost_wins() -> None: + # Review finding (2026-07-19, three agents independently): with the + # default #273 pairs, a string containing TWO delimited asides in + # different conventions mis-extracted -- the earlier-sorted pair + # claimed the other's close character ('“' closes „…“ but opens + # “…”) as its own open and swallowed a bogus span across the real + # boundary, silently dropping the legitimate match. The scan must + # be position-driven: the leftmost genuine opener wins. + text = "Hans „Erster“ und “Zweiter” Müller" + out = extract_delimited(_state(text)) + assert [text[s.start:s.end] for _, s in out.extracted] == [ + "Erster", "Zweiter"] + assert out.ambiguities == () + + +def test_mixed_guillemet_directions_both_extract() -> None: + text = "Jean «Petit» Dupont »Rex« Martin" + out = extract_delimited(_state(text)) + assert [text[s.start:s.end] for _, s in out.extracted] == [ + "Petit", "Rex"] + assert out.ambiguities == () + + +def test_mixed_conventions_across_maiden_and_nickname_buckets() -> None: + # same shape across ROLES: guillemets routed to maiden must not let + # the reversed nickname pair steal the maiden close + policy = Policy(maiden_delimiters=frozenset({("«", "»")})) + text = "Jean «Dupont» Martin »Rex« Smith" + out = extract_delimited(_state(text, policy)) + got = {(role, text[s.start:s.end]) for role, s in out.extracted} + assert got == {(Role.MAIDEN, "Dupont"), (Role.NICKNAME, "Rex")} + assert out.ambiguities == () + + +def test_extraction_and_genuine_unbalanced_coexist() -> None: + # the kept path: a successful extraction must not suppress a REAL + # dangling open elsewhere in the same string + text = 'John (Jack) Smith "Extra' + out = extract_delimited(_state(text)) + assert [text[s.start:s.end] for _, s in out.extracted] == ["Jack"] + assert [a.kind for a in out.ambiguities] == [ + AmbiguityKind.UNBALANCED_DELIMITER] + + +def test_same_char_bulk_unbalanced_filtered_by_other_pairs_mask() -> None: + # the same-char forward-scan records EVERY dangling quote; one that + # sits inside a region another pair successfully extracts is + # literal content, not a dangling delimiter -- only the truly + # dangling ones surface. All three quotes here fail the close-walk + # (no quote is followed by a boundary), so the bulk pass records + # all three; the middle one lands inside the paren match. + # 01234567890123456789 + text = 'Jon "A (x "y) Bob "C' + out = extract_delimited(_state(text)) + assert [text[s.start:s.end] for _, s in out.extracted] == ['x "y'] + # quotes at 4 and 18 are genuinely dangling; the one at 10 was + # consumed by the parenthesized extraction + assert sorted(a.detail for a in out.ambiguities) == [ + "unmatched '\"' at offset 18; treated as literal text", + "unmatched '\"' at offset 4; treated as literal text", + ] + assert all(a.kind is AmbiguityKind.UNBALANCED_DELIMITER + for a in out.ambiguities) + + +def test_genuine_unbalanced_still_flagged_alongside_overlapping_pairs() -> None: + # the suppression must not swallow REAL unbalanced opens: here the + # German open has no close anywhere, and no other pair extracts + policy = Policy(nickname_delimiters=frozenset( + {("“", "”"), ("„", "“")})) + out = extract_delimited(_state("Hans „Hansi Müller", policy)) + assert out.extracted == () + assert [a.kind for a in out.ambiguities] == [ + AmbiguityKind.UNBALANCED_DELIMITER] + + +def test_maiden_delimiters_route_to_maiden() -> None: + policy = dataclasses.replace( + Policy(), + nickname_delimiters=frozenset({("'", "'"), ('"', '"')}), + maiden_delimiters=frozenset({("(", ")")}), + ) + out = extract_delimited(_state("Jane Smith (Jones)", policy)) + assert out.extracted == ((Role.MAIDEN, Span(12, 17)),) + + +def test_empty_enclosure_is_masked_but_not_extracted() -> None: + out = extract_delimited(_state("John () Kennedy")) + assert out.extracted == () + assert out.masked == (Span(5, 7),) + + +def test_multiple_extracts_and_no_overlap() -> None: + # 0123456789012345678901234 + out = extract_delimited(_state('"Jack" John (Jonny) Kim')) + roles = {span: role for role, span in out.extracted} + assert roles == {Span(1, 5): Role.NICKNAME, Span(13, 18): Role.NICKNAME} + + +def test_adjacent_pairs_extract_separately() -> None: + out = extract_delimited(_state('John "A" "B" Kim')) + assert len(out.extracted) == 2 + + +def test_close_at_end_of_string() -> None: + out = extract_delimited(_state('John "Jack"')) + assert out.extracted == ((Role.NICKNAME, Span(6, 10)),) + + +def test_unmatched_open_at_last_char() -> None: + out = extract_delimited(_state('John "')) + assert out.extracted == () + assert len(out.ambiguities) == 1 + + +def test_nested_delimiters_inner_scan_order_pins() -> None: + # parens win over quotes when the quote chars sit flush against the + # parens (their word-boundary test fails); the inner quote chars + # flow into the extracted span verbatim -- v1 parity, deliberate + out = extract_delimited(_state('John ("Jack") Kim')) + assert out.extracted == ((Role.NICKNAME, Span(6, 12)),) diff --git a/tests/v2/pipeline/test_group.py b/tests/v2/pipeline/test_group.py new file mode 100644 index 00000000..182a603d --- /dev/null +++ b/tests/v2/pipeline/test_group.py @@ -0,0 +1,135 @@ +from nameparser._lexicon import Lexicon +from nameparser._pipeline._classify import classify +from nameparser._pipeline._extract import extract_delimited +from nameparser._pipeline._group import group +from nameparser._pipeline._segment import segment +from nameparser._pipeline._state import ParseState +from nameparser._pipeline._tokenize import tokenize +from nameparser._policy import Policy +from nameparser._types import Role + +_LEX = Lexicon( + titles=frozenset({"mr", "mrs", "secretary", "the", "state"}), + suffix_acronyms=frozenset({"phd"}), + suffix_words=frozenset({"jr"}), + particles=frozenset({"de", "la", "van", "von", "und", "zu"}), + particles_ambiguous=frozenset({"van"}), + conjunctions=frozenset({"and", "of", "the", "y"}), + bound_given_names=frozenset({"abdul"}), + maiden_markers=frozenset({"née", "geb"}), +) +# NOTE: "the" sits in both titles and conjunctions here, mirroring v1's +# real vocabulary overlap; the subset rule only constrains particles. + + +def _grouped(text: str) -> ParseState: + state = ParseState(original=text, lexicon=_LEX, policy=Policy()) + return group(classify(segment(tokenize(extract_delimited(state))))) + + +def _piece_texts(state: ParseState) -> list[list[str]]: + return [[" ".join(state.tokens[i].text for i in piece) + for piece in seg] for seg in state.pieces] + + +def test_no_joins_pass_through() -> None: + out = _grouped("John Smith") + assert _piece_texts(out) == [["John", "Smith"]] + + +def test_conjunction_joins_neighbors() -> None: + out = _grouped("Mr. and Mrs. John Smith") + assert _piece_texts(out) == [["Mr. and Mrs.", "John", "Smith"]] + # joined-to-a-title piece is flagged a derived title + assert "title" in out.piece_tags[0][0] + + +def test_contiguous_conjunctions_join_first() -> None: + out = _grouped("The Secretary of State Hillary Clinton") + assert _piece_texts(out) == [["The Secretary of State", "Hillary", "Clinton"]] + assert "title" in out.piece_tags[0][0] + + +def test_single_letter_conjunction_prefers_initial_when_short() -> None: + # v1 Google Code issue 11 ("john e smith"): 3 rootname parts, + # single-letter conjunction "y" + out = _grouped("John y Smith") + assert _piece_texts(out) == [["John", "y", "Smith"]] + + +def test_prefix_chain_joins_to_following() -> None: + out = _grouped("Juan de la Vega") + assert _piece_texts(out) == [["Juan", "de la Vega"]] + + +def test_prefix_chain_absorbs_through_to_next_suffix() -> None: + out = _grouped("Juan de la Vega Martinez PhD") + assert _piece_texts(out) == [["Juan", "de la Vega Martinez", "PhD"]] + + +def test_leading_prefix_is_never_chained() -> None: + # "Van Johnson": the leading piece is a first name, not a particle + out = _grouped("Van Johnson") + assert _piece_texts(out) == [["Van", "Johnson"]] + + +def test_von_und_zu_bridges() -> None: + # conjunction "und" joins two prefixes; the joined piece is a derived + # prefix and still chains onto the following name (v1 PR #191) + out = _grouped("Otto von und zu Habsburg") + assert _piece_texts(out) == [["Otto", "von und zu Habsburg"]] + + +def test_bound_given_joins_with_three_rootnames() -> None: + assert _piece_texts(_grouped("abdul rahman al-said")) == \ + [["abdul rahman", "al-said"]] + # only two rootname pieces: no join (v1 reserve_last) + assert _piece_texts(_grouped("abdul rahman")) == [["abdul", "rahman"]] + + +def test_phd_split_across_tokens_merges_as_suffix() -> None: + out = _grouped("John Smith Ph. D.") + assert _piece_texts(out) == [["John", "Smith", "Ph. D."]] + assert "suffix" in out.piece_tags[0][2] + # continuation tokens of the merged piece carry the stable "joined" + # tag so the suffix string view can heal the split (", " join would + # otherwise render 'Ph., D.') + d_tok = next(t for t in out.tokens if t.text == "D.") + assert "joined" in d_tok.tags + + +def test_maiden_marker_consumes_tail() -> None: + out = _grouped("Jane Smith née Jones") + assert _piece_texts(out) == [["Jane", "Smith"]] + maiden = [t.text for t in out.tokens if t.role is Role.MAIDEN] + assert maiden == ["Jones"] + # the marker token itself is structural: dropped from assembly + née_idx = next(i for i, t in enumerate(out.tokens) if t.text == "née") + assert née_idx in out.dropped + + +def test_maiden_marker_stops_at_suffix() -> None: + out = _grouped("Jane Smith née Jones PhD") + maiden = [t.text for t in out.tokens if t.role is Role.MAIDEN] + assert maiden == ["Jones"] + assert _piece_texts(out)[0][-1] == "PhD" + + +def test_leading_marker_is_not_consumed() -> None: + # "née Jones" alone: marker at piece 0 has no name before it + out = _grouped("née Jones") + assert _piece_texts(out) == [["née", "Jones"]] + + +def test_initials_do_not_count_as_rootnames_for_conjunction_carveout() -> None: + # v1 parity: 'J.' is an initial, so total rootnames stay under 4 and + # the single-letter conjunction 'y' is treated as an initial, not joined + out = _grouped("J. Ruiz y Gomez") + assert _piece_texts(out) == [["J.", "Ruiz", "y", "Gomez"]] + + +def test_suffix_comma_name_segment_gets_no_additional_count() -> None: + # v1 parity: additional_parts_count applies to FAMILY_COMMA parts only; + # ', PhD' must not tip the single-letter-conjunction carve-out + out = _grouped("John y Smith, PhD") + assert _piece_texts(out) == [["John", "y", "Smith"], ["PhD"]] diff --git a/tests/v2/pipeline/test_post_rules.py b/tests/v2/pipeline/test_post_rules.py new file mode 100644 index 00000000..ca7da24d --- /dev/null +++ b/tests/v2/pipeline/test_post_rules.py @@ -0,0 +1,122 @@ +from nameparser._lexicon import Lexicon +from nameparser._pipeline import run +from nameparser._pipeline._state import ParseState +from nameparser._policy import PatronymicRule, Policy +from nameparser._types import Role + +_LEX = Lexicon( + titles=frozenset({"mr", "sir"}), + given_name_titles=frozenset({"sir"}), + particles=frozenset({"de", "la", "van"}), + particles_ambiguous=frozenset({"van"}), +) + + +def _parsed(text: str, policy: Policy | None = None) -> ParseState: + return run(ParseState(original=text, lexicon=_LEX, + policy=policy or Policy())) + + +def _by_role(state: ParseState, role: Role) -> str: + return " ".join(t.text for t in state.tokens if t.role is role) + + +def test_plain_title_with_single_name_swaps_to_family() -> None: + out = _parsed("Mr. Johnson") + assert _by_role(out, Role.FAMILY) == "Johnson" + assert not _by_role(out, Role.GIVEN) + + +def test_given_name_title_keeps_given() -> None: + out = _parsed("Sir Bob") + assert _by_role(out, Role.GIVEN) == "Bob" + assert not _by_role(out, Role.FAMILY) + + +def test_no_swap_when_more_fields_present() -> None: + out = _parsed("Mr. John Johnson") + assert _by_role(out, Role.GIVEN) == "John" + assert _by_role(out, Role.FAMILY) == "Johnson" + + +_ES = Policy(patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})) +_TK = Policy(patronymic_rules=frozenset({PatronymicRule.TURKIC})) + + +def test_east_slavic_rotation() -> None: + out = _parsed("Сидоров Иван Петрович", _ES) + assert _by_role(out, Role.GIVEN) == "Иван" + assert _by_role(out, Role.MIDDLE) == "Петрович" + assert _by_role(out, Role.FAMILY) == "Сидоров" + + +def test_east_slavic_needs_one_one_one() -> None: + # four tokens: left unchanged (v1 parity) + out = _parsed("Anna Maria Petrova Ivanovna", _ES) + assert _by_role(out, Role.GIVEN) == "Anna" + + +def test_east_slavic_skips_comma_forms() -> None: + # v1 parity: patronymic reorder never fires on comma input -- + # the comma already established the family + out = _parsed("Abramovich, Roman Petrovich", _ES) + assert _by_role(out, Role.FAMILY) == "Abramovich" + assert _by_role(out, Role.GIVEN) == "Roman" + + +def test_east_slavic_skips_when_middle_is_also_patronymic() -> None: + # v1 parity: given + patronymic + patronymic-derived surname + # (Abramovich) must not rotate + out = _parsed("Roman Petrovich Abramovich", _ES) + assert _by_role(out, Role.GIVEN) == "Roman" + assert _by_role(out, Role.MIDDLE) == "Petrovich" + assert _by_role(out, Role.FAMILY) == "Abramovich" + + +def test_east_slavic_off_by_default() -> None: + out = _parsed("Сидоров Иван Петрович") + assert _by_role(out, Role.GIVEN) == "Сидоров" + + +def test_turkic_rotation() -> None: + out = _parsed("Mammadova Aygun Ali kizi", _TK) + assert _by_role(out, Role.GIVEN) == "Aygun" + assert _by_role(out, Role.MIDDLE) == "Ali kizi" + assert _by_role(out, Role.FAMILY) == "Mammadova" + + +def test_leading_never_given_particle_folds_into_family() -> None: + # v1 handle_non_first_name_prefix: a leading particle that is never + # a given name ('de') means the whole name is a surname + out = _parsed("de la Vega") + assert _by_role(out, Role.FAMILY) == "de la Vega" + assert not _by_role(out, Role.GIVEN) + + +def test_leading_ambiguous_particle_stays_given() -> None: + # 'van' is particles_ambiguous: the given reading stands (v1 parity) + out = _parsed("van Gogh") + assert _by_role(out, Role.GIVEN) == "van" + assert _by_role(out, Role.FAMILY) == "Gogh" + + +def test_degenerate_bare_particle_stays_given() -> None: + # v1's guard: with no middle or family, a bare 'de' keeps given='de' + # rather than inventing a surname + out = _parsed("de") + assert _by_role(out, Role.GIVEN) == "de" + assert not _by_role(out, Role.FAMILY) + + +def test_middle_as_family_folds_middles() -> None: + # v1 handle_middle_name_as_last, opt-in: middles prepend to family + out = _parsed("John Quincy Adams Smith", + Policy(middle_as_family=True)) + assert _by_role(out, Role.GIVEN) == "John" + assert not _by_role(out, Role.MIDDLE) + assert _by_role(out, Role.FAMILY) == "Quincy Adams Smith" + + +def test_middle_as_family_off_by_default() -> None: + out = _parsed("John Quincy Adams Smith") + assert _by_role(out, Role.MIDDLE) == "Quincy Adams" diff --git a/tests/v2/pipeline/test_segment.py b/tests/v2/pipeline/test_segment.py new file mode 100644 index 00000000..5aa5a9fe --- /dev/null +++ b/tests/v2/pipeline/test_segment.py @@ -0,0 +1,89 @@ +from nameparser._lexicon import Lexicon +from nameparser._pipeline._extract import extract_delimited +from nameparser._pipeline._segment import segment +from nameparser._pipeline._state import ParseState, Structure +from nameparser._pipeline._tokenize import tokenize +import dataclasses + +from nameparser._policy import Policy +from nameparser._types import AmbiguityKind + +# synthetic vocabulary: behavior given a lexicon, never default() contents +_LEX = Lexicon( + suffix_acronyms=frozenset({"phd"}), + suffix_words=frozenset({"jr", "v"}), +) + + +def _segmented(text: str) -> ParseState: + state = ParseState(original=text, lexicon=_LEX, policy=Policy()) + return segment(tokenize(extract_delimited(state))) + + +def _texts(state: ParseState, seg: tuple[int, ...]) -> list[str]: + return [state.tokens[i].text for i in seg] + + +def test_no_comma() -> None: + out = _segmented("John Smith") + assert out.structure is Structure.NO_COMMA + assert [_texts(out, s) for s in out.segments] == [["John", "Smith"]] + + +def test_family_comma() -> None: + out = _segmented("Smith, John") + assert out.structure is Structure.FAMILY_COMMA + assert [_texts(out, s) for s in out.segments] == [["Smith"], ["John"]] + + +def test_suffix_comma_when_all_rest_groups_are_suffixes() -> None: + out = _segmented("John Smith, PhD") + assert out.structure is Structure.SUFFIX_COMMA + assert [_texts(out, s) for s in out.segments] == [["John", "Smith"], ["PhD"]] + + +def test_suffix_comma_lenient_accepts_initial_shaped_suffix_word() -> None: + # "V" is initial-shaped; the strict test vetoes it, the post-comma + # lenient test accepts suffix_words unconditionally (v1 parity) + out = _segmented("John Ingram, V") + assert out.structure is Structure.SUFFIX_COMMA + + +def test_family_comma_with_trailing_suffix_segment() -> None: + out = _segmented("Smith, John, Jr.") + assert out.structure is Structure.FAMILY_COMMA + assert [_texts(out, s) for s in out.segments] == [["Smith"], ["John"], ["Jr."]] + + +def test_single_pre_comma_word_never_suffix_comma() -> None: + # v1: suffix-comma requires >1 word before the comma + out = _segmented("Johnson, Jr.") + assert out.structure is Structure.FAMILY_COMMA + + +def test_excess_non_suffix_segment_flags_comma_structure() -> None: + out = _segmented("Smith, John, Extra, Jr.") + assert out.structure is Structure.FAMILY_COMMA + kinds = [a.kind for a in out.ambiguities] + assert AmbiguityKind.COMMA_STRUCTURE in kinds + + +def test_empty_input_yields_no_segments() -> None: + assert _segmented("").segments == () + + +def test_comma_only_input_is_no_comma_structure() -> None: + out = _segmented(",,,") + assert out.structure is Structure.NO_COMMA + assert out.segments == () + + +def test_strict_comma_suffixes_veto_lenient_only_members() -> None: + # lenient_comma_suffixes=False: the post-comma test drops back to + # the strict predicate, so initial-shaped suffix words no longer + # qualify and the structure reads FAMILY_COMMA + state = ParseState( + original="John Ingram, V", lexicon=_LEX, + policy=dataclasses.replace(Policy(), lenient_comma_suffixes=False)) + out = segment(tokenize(extract_delimited(state))) + assert out.structure is Structure.FAMILY_COMMA diff --git a/tests/v2/pipeline/test_state.py b/tests/v2/pipeline/test_state.py new file mode 100644 index 00000000..4b0c0423 --- /dev/null +++ b/tests/v2/pipeline/test_state.py @@ -0,0 +1,100 @@ +import dataclasses + +from nameparser._lexicon import Lexicon +from nameparser._pipeline._state import ParseState, Structure, WorkToken +from nameparser._policy import Policy +from nameparser._types import Role, Span + + +def _state(text: str) -> ParseState: + return ParseState(original=text, lexicon=Lexicon.empty(), policy=Policy()) + + +def test_state_defaults_are_empty() -> None: + s = _state("John Smith") + assert s.tokens == () and s.segments == () and s.pieces == () + 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 == () + + +def test_state_is_frozen_and_replace_works() -> None: + s = _state("x") + tok = WorkToken("x", Span(0, 1)) + s2 = dataclasses.replace(s, tokens=(tok,)) + assert s.tokens == () and s2.tokens == (tok,) + assert s2.tokens[0].role is None and s2.tokens[0].tags == frozenset() + + +def test_worktoken_carries_optional_role() -> None: + t = WorkToken("Jack", Span(6, 10), role=Role.NICKNAME) + assert t.role is Role.NICKNAME + + +def test_stage_field_ownership() -> None: + # The ParseState docstring's ownership map, pinned mechanically: run + # the whole case corpus through the fold stage by stage and assert + # each stage only changes the fields it owns. Converts the prose + # contract into a test (a future stage clobbering another stage's + # field fails here, not in a distant assertion). + import dataclasses as _dc + + from nameparser import Lexicon as _Lexicon + from nameparser._pipeline import STAGES + + from ..cases import CASES + + ownership = { + "extract_delimited": {"extracted", "masked", "ambiguities"}, + "tokenize": {"tokens", "comma_offsets"}, + "segment": {"segments", "structure", "ambiguities"}, + "classify": {"tokens"}, + "group": {"tokens", "pieces", "piece_tags", "dropped"}, + "assign": {"tokens", "ambiguities"}, + "post_rules": {"tokens"}, + } + assert {s.__name__ for s in STAGES} == set(ownership) + # Within the tokens themselves the contract is finer: texts and + # spans are fixed at tokenize (the anti-#100 invariant -- tokens + # are never re-created), classify touches only tags, and the + # role-assigning stages touch only roles (group also tags, for the + # ph-d "joined" marker). + token_ownership = { + "classify": {"tags"}, + "group": {"tags", "role"}, + "assign": {"role"}, + # post_rules also tags: the middle_as_family fold marks folded + # tokens vocab:folded-middle for the family view's prepend order + "post_rules": {"role", "tags"}, + } + for case in CASES: + if case.locale is not None: + # locale rows dissolve into a Policy only through parser_for + # (nameparser._parser); this test exercises the raw stage + # pipeline directly, and the same inputs already run here + # under the equivalent synthetic Policy row (_ES/_TK) -- + # covering them again through a resolved locale policy would + # be redundant, not additive. + continue + state = ParseState(original=case.text, lexicon=_Lexicon.default(), + policy=case.policy or Policy()) + for stage in STAGES: + before = {f.name: getattr(state, f.name) + for f in _dc.fields(state)} + state = stage(state) + changed = {name for name, value in before.items() + if getattr(state, name) != value} + assert changed <= ownership[stage.__name__], ( + f"{case.id}: {stage.__name__} changed {changed - ownership[stage.__name__]}") + if stage.__name__ not in token_ownership: + continue + allowed = token_ownership[stage.__name__] + assert len(state.tokens) == len(before["tokens"]), ( + f"{case.id}: {stage.__name__} changed the token count") + for old, new in zip(before["tokens"], state.tokens): + token_changed = { + f.name for f in _dc.fields(old) + if getattr(old, f.name) != getattr(new, f.name)} + assert token_changed <= allowed, ( + f"{case.id}: {stage.__name__} changed token fields " + f"{token_changed - allowed} on {old.text!r}") diff --git a/tests/v2/pipeline/test_tokenize.py b/tests/v2/pipeline/test_tokenize.py new file mode 100644 index 00000000..3e94e184 --- /dev/null +++ b/tests/v2/pipeline/test_tokenize.py @@ -0,0 +1,72 @@ +import dataclasses + +from nameparser._lexicon import Lexicon +from nameparser._pipeline._extract import extract_delimited +from nameparser._pipeline._state import ParseState +from nameparser._pipeline._tokenize import tokenize +from nameparser._policy import Policy +from nameparser._types import Role + + +def _tokenized(text: str, policy: Policy | None = None) -> ParseState: + state = ParseState(original=text, lexicon=Lexicon.empty(), + policy=policy or Policy()) + return tokenize(extract_delimited(state)) + + +def test_whitespace_split_with_spans() -> None: + out = _tokenized("John Smith") + assert [(t.text, tuple(t.span)) for t in out.tokens] == [ + ("John", (0, 4)), ("Smith", (6, 11)), + ] + assert all(t.role is None for t in out.tokens) + + +def test_provenance_text_equals_original_slice() -> None: + out = _tokenized(" Dr. Juan de la Vega ") + for t in out.tokens: + assert t.text == out.original[t.span.start:t.span.end] + + +def test_commas_are_separators_and_recorded() -> None: + out = _tokenized("Smith, John") + assert [t.text for t in out.tokens] == ["Smith", "John"] + assert out.comma_offsets == (5,) + + +def test_fullwidth_and_arabic_commas_segment() -> None: + out = _tokenized("سميث، جون") + assert out.comma_offsets == (4,) + out2 = _tokenized("山田,太郎") + assert out2.comma_offsets == (2,) + + +def test_extracted_regions_are_skipped_and_tokenized_with_role() -> None: + out = _tokenized('John "Jack Jr" Kennedy') + main = [(t.text, t.role) for t in out.tokens if t.role is None] + nick = [(t.text, t.role) for t in out.tokens if t.role is Role.NICKNAME] + assert main == [("John", None), ("Kennedy", None)] + assert nick == [("Jack", Role.NICKNAME), ("Jr", Role.NICKNAME)] + # tokens are span-sorted overall + starts = [t.span.start for t in out.tokens] + assert starts == sorted(starts) + + +def test_comma_inside_extracted_region_is_not_an_offset() -> None: + out = _tokenized('John "Jack, Jr" Kim') + assert out.comma_offsets == () + nick = [t.text for t in out.tokens if t.role is Role.NICKNAME] + assert nick == ["Jack", "Jr"] + + +def test_emoji_and_bidi_are_ignorable_by_policy() -> None: + out = _tokenized("John‏ \U0001f600Smith") + assert [t.text for t in out.tokens] == ["John", "Smith"] + keep = dataclasses.replace(Policy(), strip_emoji=False, strip_bidi=False) + out2 = _tokenized("John \U0001f600Smith", keep) + assert [t.text for t in out2.tokens] == ["John", "\U0001f600Smith"] + + +def test_empty_and_whitespace_yield_no_tokens() -> None: + assert _tokenized("").tokens == () + assert _tokenized(" ").tokens == () diff --git a/tests/v2/pipeline/test_vocab.py b/tests/v2/pipeline/test_vocab.py new file mode 100644 index 00000000..a8e21326 --- /dev/null +++ b/tests/v2/pipeline/test_vocab.py @@ -0,0 +1,40 @@ +from nameparser._lexicon import Lexicon +from nameparser._pipeline._vocab import is_initial, is_suffix_lenient, is_suffix_strict + +_LEX = Lexicon( + suffix_acronyms=frozenset({"phd", "ma"}), + suffix_words=frozenset({"jr", "v"}), + suffix_acronyms_ambiguous=frozenset({"ma"}), +) + + +def test_is_initial() -> None: + assert is_initial("A.") + assert is_initial("j.") + assert is_initial("B") + assert not is_initial("Jo") + assert not is_initial("b") # bare lowercase letter is not an initial + + +def test_strict_suffix_initial_veto() -> None: + assert is_suffix_strict("PhD", _LEX) + assert not is_suffix_strict("V.", _LEX) # initial veto + assert not is_suffix_strict("V", _LEX) # initial veto + assert is_suffix_strict("Jr", _LEX) + + +def test_ambiguous_acronym_needs_periods_and_beats_the_veto() -> None: + assert is_suffix_strict("M.A.", _LEX) + assert not is_suffix_strict("Ma", _LEX) + + +def test_lenient_accepts_suffix_words_unconditionally() -> None: + assert is_suffix_lenient("V", _LEX) + assert is_suffix_lenient("V.", _LEX) + assert not is_suffix_lenient("Ma", _LEX) + + +def test_strict_excludes_bare_ambiguous_even_when_in_acronyms() -> None: + # mirrors the real data shape: ambiguous is a SUBSET of acronyms + assert not is_suffix_strict("Ma", _LEX) + assert is_suffix_strict("M.A.", _LEX) diff --git a/tests/v2/test_benchmark.py b/tests/v2/test_benchmark.py new file mode 100644 index 00000000..c3f77509 --- /dev/null +++ b/tests/v2/test_benchmark.py @@ -0,0 +1,28 @@ +"""Perf smoke (core spec §7 tail): parse cost stays v1-comparable +(microseconds per name). Deliberately generous bound -- guards against +order-of-magnitude regressions, does not gate normal variance.""" +import time + +from nameparser import parse + + +def test_parse_thousand_names_under_a_second() -> None: + parse("warm up the default parser cache") + start = time.perf_counter() + for i in range(1000): + parse(f"Dr. Juan{i} de la Vega III") + elapsed = time.perf_counter() - start + assert elapsed < 1.0, f"1000 parses took {elapsed:.2f}s" + + +def test_facade_thousand_names_under_a_second() -> None: + # the legacy-API path (what all existing users call): snapshot + # resolution must stay generation-cached, not rebuilt per instance + from nameparser import HumanName + + HumanName("warm up the caches") + start = time.perf_counter() + for i in range(1000): + HumanName(f"Dr. Juan{i} de la Vega III") + elapsed = time.perf_counter() - start + assert elapsed < 1.0, f"1000 facade parses took {elapsed:.2f}s" diff --git a/tests/v2/test_cases.py b/tests/v2/test_cases.py new file mode 100644 index 00000000..3495a26f --- /dev/null +++ b/tests/v2/test_cases.py @@ -0,0 +1,26 @@ +"""Core runner over the shared case table (spec §7.2). The facade +runner (migration plan) consumes the same CASES.""" +import pytest + +from nameparser import Parser, Role, locales, parser_for + +from .cases import CASES, Case + +_FIELDS = tuple(r.value for r in Role) # declaration order is canonical + + +def _parser_for_case(case: Case) -> Parser: + if case.locale is not None: + return parser_for(locales.get(case.locale)) + return Parser(policy=case.policy) if case.policy else Parser() + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: c.id) +def test_case(case: Case) -> None: + parser = _parser_for_case(case) + pn = parser.parse(case.text) + actual = {f: getattr(pn, f) for f in _FIELDS if getattr(pn, f)} + assert actual == case.expect, f"{case.text!r} ({case.classification})" + kinds = sorted(a.kind.value for a in pn.ambiguities) + assert kinds == sorted(case.ambiguities), \ + f"{case.text!r} ({case.classification})" diff --git a/tests/v2/test_cli.py b/tests/v2/test_cli.py new file mode 100644 index 00000000..861e25d6 --- /dev/null +++ b/tests/v2/test_cli.py @@ -0,0 +1,51 @@ +import json +import subprocess +import sys + + +def _run(*args: str) -> subprocess.CompletedProcess: + return subprocess.run([sys.executable, "-m", "nameparser", *args], + capture_output=True, text=True) + + +def test_cli_prints_repr_capitalized_and_initials() -> None: + out = _run("dr. juan de la vega iii").stdout + assert "juan" in out # raw repr first + assert "Juan" in out # capitalized repr second + assert "Initials:" in out + + +def test_cli_json() -> None: + proc = _run("John Smith", "--json") + data = json.loads(proc.stdout) + assert data["given"] == "John" and data["family"] == "Smith" + + +def test_cli_no_args_usage() -> None: + proc = _run() + assert proc.returncode != 0 + + +def test_cli_locale() -> None: + proc = _run("Сидоров Иван Петрович", "--locale", "ru") + assert proc.returncode == 0 + assert "Иван" in proc.stdout + + proc = _run("Сидоров Иван Петрович", "--locale", "ru", "--json") + data = json.loads(proc.stdout) + assert "Иван" in data["given"] + + +def test_cli_locale_unknown_code_lists_available() -> None: + proc = _run("John Smith", "--locale", "xx") + assert proc.returncode != 0 + assert "ru" in proc.stderr and "tr_az" in proc.stderr + + +def test_cli_locale_empty_string_errors_not_silent_default() -> None: + # --locale "" is a mistake (empty shell variable, typo), not a + # request for the default parser; a truthiness check would swallow + # it silently -- it must error like any other unknown code + proc = _run("John Smith", "--locale", "") + assert proc.returncode != 0 + assert "ru" in proc.stderr and "tr_az" in proc.stderr diff --git a/tests/v2/test_config_shim.py b/tests/v2/test_config_shim.py new file mode 100644 index 00000000..d267b529 --- /dev/null +++ b/tests/v2/test_config_shim.py @@ -0,0 +1,641 @@ +"""Shim Constants/SetManager/TupleManager (migration spec §3).""" +import copy +import pickle +import warnings +from pathlib import Path + +import pytest + +from nameparser import GIVEN_FIRST, Lexicon, PatronymicRule, Policy +from nameparser._config_shim import ( + CONSTANTS, Constants, SetManager, TupleManager, _DelimiterManager, + _RegexesProxy, _cached_parser, +) + +_DATA_DIR = Path(__file__).parent / "data" + + +def test_set_manager_normalizes_and_holds_membership() -> None: + s = SetManager(["Dr", "MRS."]) + assert "dr" in s and "Dr" in s # lc() normalization, both ways + assert "mrs" in s + assert len(s) == 2 + assert sorted(s) == ["dr", "mrs"] + + +def test_set_manager_add_remove_chain_and_keyerror() -> None: + s = SetManager() + assert s.add("Dame", "Fra") is s # chainable, v1 parity + assert "dame" in s + assert s.remove("Dame") is s + assert "dame" not in s + with pytest.raises(KeyError): # 1.3.0 grace period ended (#243) + s.remove("never-there") + + +def test_set_manager_call_is_removed() -> None: + s = SetManager(["dr"]) + with pytest.raises(TypeError): # #243: __call__ removed in 2.0 + s() # type: ignore[operator] + + +def test_set_manager_operators_and_equality() -> None: + a, b = SetManager(["a", "b"]), SetManager(["b", "c"]) + assert a | b == {"a", "b", "c"} + assert a & b == {"b"} + assert a - b == {"a"} + assert a | {"z"} == {"a", "b", "z"} + assert {"z"} | a == {"a", "b", "z"} # reflected: set op manager + assert {"a", "b", "c"} - a == {"c"} # operand order matters here + assert a == {"a", "b"} + assert a == SetManager(["a", "b"]) and a != b + with pytest.raises(TypeError): # mutable, unhashable (v1 parity) + hash(a) + + +def test_set_manager_reports_mutations_to_owner() -> None: + bumps = [] + s = SetManager(["a"], _on_change=lambda: bumps.append(1)) + s.add("b") + s.remove("a") + assert len(bumps) == 2 + s.add("b") # no-op: already present + assert len(bumps) == 2 # must not bump (v1 parity) + + +def test_set_manager_partial_remove_still_notifies_owner() -> None: + bumps = [] + s = SetManager(["a", "b"], _on_change=lambda: bumps.append(1)) + with pytest.raises(KeyError): + s.remove("a", "missing") # "a" IS removed before the raise + assert "a" not in s + assert len(bumps) == 1 # the real removal was reported + + +def test_set_manager_accepts_v1_pickle_state() -> None: + s = SetManager.__new__(SetManager) + s.__setstate__({"elements": {"dr", "mr"}, "_on_change": None}) + assert "dr" in s and len(s) == 2 + # the shim's own key spelling, with un-normalized elements: loading + # must re-normalize rather than trust the blob passed through lc() + s2 = SetManager.__new__(SetManager) + s2.__setstate__({"_elements": {"Dr", "MRS."}}) + assert "dr" in s2 and "mrs" in s2 + assert sorted(s2) == ["dr", "mrs"] + + +def test_set_manager_pickle_round_trip() -> None: + # in-process round trip of a blob we just built; pickle is not a + # security boundary here (same stance as the v2 pickle guards) + t = pickle.loads(pickle.dumps(SetManager(["Dr", "Mrs."]))) + assert t == SetManager(["dr", "mrs"]) + + +def test_tuple_manager_attribute_access_and_unknown_key() -> None: + t = TupleManager({"mcdonald": "McDonald"}) + assert t.mcdonald == "McDonald" + assert t["mcdonald"] == "McDonald" + with pytest.raises(AttributeError, match="mcdonalds"): # #256 + t.mcdonalds + + +def test_tuple_manager_mutations_bump_owner() -> None: + bumps = [] + t = TupleManager({"a": "A"}, _on_change=lambda: bumps.append(1)) + t["b"] = "B" + del t["a"] + t.pop("b") + assert len(bumps) == 3 + + +def test_tuple_manager_pop_default_on_missing_key_is_noop() -> None: + bumps = [] + t = TupleManager({"a": "A"}, _on_change=lambda: bumps.append(1)) + assert t.pop("missing", None) is None + assert len(bumps) == 0 # no-op: key was never present + + +def test_tuple_manager_bulk_mutations_bump_owner() -> None: + # dict's C fast paths (update, setdefault, |=, clear, popitem) must + # notify too, or a cached parser built from the owner goes stale + bumps = [] + t = TupleManager(_on_change=lambda: bumps.append(1)) + t.update({"a": "A", "b": "B"}) + assert len(bumps) == 2 # per-key, via __setitem__ + assert t.setdefault("c", "C") == "C" + assert len(bumps) == 3 + assert t.setdefault("a", "ignored") == "A" + assert len(bumps) == 3 # existing key: read, not write + t |= {"d": "D"} + assert len(bumps) == 4 + assert t.popitem()[0] in "abcd" + assert len(bumps) == 5 + t.clear() + assert len(bumps) == 6 + t.clear() + assert len(bumps) == 6 # already empty: no-op + + +def test_tuple_manager_pickle_round_trip() -> None: + t = pickle.loads(pickle.dumps(TupleManager({"a": "A"}))) + assert dict(t) == {"a": "A"} + assert t._on_change is None + + +def test_delimiter_manager_sentinels_only() -> None: + d = _DelimiterManager({"parenthesis": "parenthesis"}) + moved = d.pop("parenthesis") + d2 = _DelimiterManager() + d2["parenthesis"] = moved # the documented bucket-move idiom + assert "parenthesis" in d2 + with pytest.raises(TypeError, match="quoted_word"): + d2["angle_brackets"] = "custom" # spec §3: custom keys raise + + +def test_delimiter_manager_no_bypass_via_constructor_or_update() -> None: + # dict's C fast paths (dict.__init__, dict.update) skip a subclass's + # __setitem__ -- the sentinel rule must hold on every mutation path + with pytest.raises(TypeError, match="quoted_word"): + _DelimiterManager({"angle_brackets": "x"}) + with pytest.raises(TypeError, match="quoted_word"): + _DelimiterManager(angle_brackets="x") + with pytest.raises(TypeError, match="quoted_word"): + _DelimiterManager().update({"angle_brackets": "x"}) + with pytest.raises(TypeError, match="quoted_word"): + _DelimiterManager().setdefault("angle_brackets", "x") + with pytest.raises(TypeError, match="quoted_word"): + d0 = _DelimiterManager() + d0 |= {"angle_brackets": "x"} + # update through the validated path still notifies the owner per key + bumps = [] + d = _DelimiterManager(_on_change=lambda: bumps.append(1)) + d.update({"quoted_word": "quoted_word", "parenthesis": "parenthesis"}) + assert dict(d) == {"quoted_word": "quoted_word", + "parenthesis": "parenthesis"} + assert len(bumps) == 2 + + +def test_regexes_reads_ok_assignment_raises() -> None: + r = _RegexesProxy() + assert r.word.match("Smith") # type: ignore[attr-defined] # reads keep working + with pytest.raises(TypeError, match="strip_bidi"): + r.bidi = None # slot-aware message + with pytest.raises(TypeError, match="Policy"): + r.roman_numeral = None # generic message + + +def test_regexes_membership_iteration_and_deepcopy() -> None: + r = _RegexesProxy() + assert "word" in r # membership is a read + assert "nope" not in r + assert "word" in sorted(r) # iteration is a read + assert "word" in r.keys() + # dunder probes must raise AttributeError, not resolve as regex + # names -- the classic copy.deepcopy regression (see AGENTS.md) + assert isinstance(copy.deepcopy(r), _RegexesProxy) + + +def test_constants_default_fields_present() -> None: + c = Constants() + assert "dr" in c.titles + assert "phd" in c.suffix_acronyms + assert "van" in c.prefixes + assert c.patronymic_name_order is False + assert c.middle_name_as_last is False + assert c.capitalize_name is False + assert c.force_mixed_case_capitalization is False + assert c.string_format == "{title} {first} {middle} {last} {suffix} ({nickname})" + assert c.suffix_delimiter is None + # every named sentinel is a default nickname bucket (the v1 trio + # plus the #273 typographic pairs); derived from the map itself so + # a new sentinel can't leave this pin stale + from nameparser._config_shim import _SENTINEL_PAIRS + + assert set(c.nickname_delimiters) == set(_SENTINEL_PAIRS) + assert {"quoted_word", "double_quotes", "parenthesis", + "smart_double_quotes", "guillemets"} <= set(_SENTINEL_PAIRS) + assert len(c.maiden_delimiters) == 0 + + +def test_constants_mutation_bumps_generation() -> None: + c = Constants() + g0 = c._generation + c.titles.add("zqxtitle") # not a default title (real bump) + assert c._generation > g0 + g1 = c._generation + c.patronymic_name_order = True + assert c._generation > g1 + + +def test_private_constants_do_not_warn_shared_singleton_does() -> None: + c = Constants() + with warnings.catch_warnings(): + warnings.simplefilter("error") + c.titles.add("zqxtitle") # private: silent + with pytest.deprecated_call(match="Lexicon"): + CONSTANTS.titles.add("zqxtest") + with pytest.deprecated_call(): + CONSTANTS.titles.remove("zqxtest") # leave the singleton clean + + +def test_scalar_noop_assignment_neither_bumps_nor_warns() -> None: + # managers already suppress no-op mutations (no-op add, pop with + # default); re-assigning a scalar's current value must match -- + # neither a generation bump nor, on the shared singleton, the + # deprecation warning + c = Constants() + g0 = c._generation + c.string_format = c.string_format + assert c._generation == g0 + g_shared = CONSTANTS._generation + with warnings.catch_warnings(): + warnings.simplefilter("error") + CONSTANTS.string_format = CONSTANTS.string_format + assert CONSTANTS._generation == g_shared + + +def test_empty_attribute_default_assignment_raises() -> None: + c = Constants() + with pytest.raises(AttributeError, match="#255"): + c.empty_attribute_default = None + + +def test_regexes_attribute_assignment_raises() -> None: + with pytest.raises(TypeError, match="Policy"): + Constants().regexes = {} # type: ignore[assignment] + + +def test_constants_copy_is_independent() -> None: # #260 + c = Constants() + d = c.copy() + d.titles.add("zqxtitle") # not a default title (real addition) + assert "zqxtitle" in d.titles and "zqxtitle" not in c.titles + assert d._generation != -1 # a real instance with its own counter + + +def test_constants_pickle_round_trip() -> None: + c = Constants() + c.titles.add("zqxtitle") + loaded = pickle.loads(pickle.dumps(c)) + assert "zqxtitle" in loaded.titles + loaded.titles.add("fra") # mutations still tracked + assert "fra" in loaded.titles + + +def test_pre_1_3_constants_blob_raises() -> None: # #279 + # pre-1.3.0 blobs are recognizable by the computed property their + # dir()-sweep __getstate__ captured; the 1.4 warning promised + # ValueError in 2.0 + with pytest.raises(ValueError, match="#279|1.3"): + Constants.__new__(Constants).__setstate__( + {"prefixes": set(), "suffixes_prefixes_titles": set()}) + + +def test_v14_constants_state_shape_accepted() -> None: + # v1.4 __getstate__ emitted public field names -> manager values; + # scalars ride along. empty_attribute_default is accepted and + # DROPPED (#255: empty is always '' in 2.0). + c = Constants.__new__(Constants) + c.__setstate__({"prefixes": {"van", "de"}, + "string_format": "{first} {last}", + "empty_attribute_default": None}) + assert "van" in c.prefixes + assert c.string_format == "{first} {last}" + assert not hasattr(c, "empty_attribute_default") or True # dropped + + +def test_v14_constants_blob_unpickles_into_shim() -> None: + # tests/v2/data/constants_v14.pickle was produced by a real 1.4.0 + # install (`uv run --no-project --with "nameparser==1.4.*" ...`), + # not synthesized here -- it is the actual bytes a caller upgrading + # from 1.4 to 2.0 would hand to pickle.load(). Since the M11 swap + # replaced nameparser.config.Constants with this shim, the blob's + # pickled class reference now resolves here. Its nested `regexes` + # field pickles as nameparser.config.RegexTupleManager, reconstructed + # by the unpickler before Constants.__setstate__ runs -- see + # RegexTupleManager's docstring in _config_shim.py. + with open(_DATA_DIR / "constants_v14.pickle", "rb") as f: + loaded = pickle.load(f) + assert isinstance(loaded, Constants) + assert "van" in loaded.prefixes + assert "dr" in loaded.titles + assert "jr" in loaded.suffix_not_acronyms + assert loaded.string_format == "{title} {first} {middle} {last} {suffix} ({nickname})" + + +def test_snapshot_field_translation() -> None: + c = Constants() + lexicon, policy, defaults = c._snapshot() + # drift guard: a default Constants must resolve to EXACTLY the v2 + # defaults -- a field populated in _default_lexicon() but forgotten + # in _snapshot() (or vice versa) fails loudly here + assert lexicon == Lexicon.default() + assert policy == Policy() + assert isinstance(lexicon, Lexicon) and isinstance(policy, Policy) + assert lexicon.suffix_words == frozenset(c.suffix_not_acronyms) + # complement translation: v1 marks never-given, v2 marks may-be-given + assert lexicon.particles_ambiguous == \ + frozenset(c.prefixes) - frozenset(c.non_first_name_prefixes) + assert lexicon.suffix_acronyms_ambiguous <= lexicon.suffix_acronyms + assert policy.name_order == GIVEN_FIRST + assert policy.patronymic_rules == frozenset() + assert defaults.string_format == c.string_format + # a snapshot is a pure read: no generation bump, no deprecation + # warning even on the shared singleton + g0 = CONSTANTS._generation + with warnings.catch_warnings(): + warnings.simplefilter("error") + CONSTANTS._snapshot() + assert CONSTANTS._generation == g0 + + +def test_snapshot_patronymic_and_middle_flags() -> None: + c = Constants() + c.patronymic_name_order = True + c.middle_name_as_last = True + _, policy, _ = c._snapshot() + assert policy.patronymic_rules == frozenset( + {PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) + assert policy.middle_as_family is True + + +def test_typographic_delimiter_sentinels_present_and_movable() -> None: + # #273: the eight typographic pairs surface in the v1 API as new + # named sentinels, so the documented keyed idioms (pop/move/del) + # work on them exactly like the original trio + c = Constants() + assert "smart_double_quotes" in c.nickname_delimiters + assert "guillemets" in c.nickname_delimiters + c.maiden_delimiters["guillemets"] = c.nickname_delimiters.pop("guillemets") + _, policy, _ = c._snapshot() + assert ("«", "»") in policy.maiden_delimiters + assert ("«", "»") not in policy.nickname_delimiters + + +def test_snapshot_delimiter_bucket_move() -> None: + c = Constants() + c.maiden_delimiters["parenthesis"] = c.nickname_delimiters.pop("parenthesis") + _, policy, _ = c._snapshot() + assert ("(", ")") in policy.maiden_delimiters + assert ("(", ")") not in policy.nickname_delimiters + + +def test_snapshot_overlap_keeps_v1_nickname_precedence() -> None: + # v1 semantics: a pair present in BOTH v1 buckets parses as nickname. + # 2.0's Policy resolves overlap the other way (maiden wins), so the + # snapshot pre-subtracts on the maiden side -- a v1 user who added + # parens to maiden_delimiters WITHOUT removing them from + # nickname_delimiters keeps their 1.x parse through the facade. + c = Constants() + c.maiden_delimiters["parenthesis"] = ("(", ")") # nickname still has it + _, policy, _ = c._snapshot() + assert ("(", ")") in policy.nickname_delimiters + assert ("(", ")") not in policy.maiden_delimiters + from nameparser import HumanName + + n = HumanName("Jane (Jones) Smith", constants=c) + assert n.nickname == "Jones" and n.maiden == "" + + +def test_snapshot_ambiguous_removed_acronym_intersects() -> None: + c = Constants() + c.suffix_acronyms.remove("jd") # 'jd' stays in ambiguous + lexicon, _, _ = c._snapshot() + assert "jd" not in lexicon.suffix_acronyms + assert "jd" not in lexicon.suffix_acronyms_ambiguous # subset holds + + +def test_parser_cache_shared_across_equal_snapshots() -> None: + a, b = Constants(), Constants() + la, pa, _ = a._snapshot() + lb, pb, _ = b._snapshot() + assert _cached_parser(la, pa) is _cached_parser(lb, pb) + + +# -- Gap 1: Constants() constructor kwargs (v1.4 parity, #238/#242/#244) ---- + + +def test_constants_kwarg_replaces_field_others_keep_defaults() -> None: + default = Constants() + c = Constants(titles=["Abc"]) + assert set(c.titles) == {"abc"} # lc()-normalized + assert "abc" not in default.titles # sanity: not a real default + # a kwarg REPLACES that field's vocabulary wholesale (v1 parity); the + # other eight set fields are untouched + for field in ("prefixes", "suffix_acronyms", "suffix_not_acronyms", + "suffix_acronyms_ambiguous", "first_name_titles", + "conjunctions", "bound_first_names", + "non_first_name_prefixes"): + assert set(getattr(c, field)) == set(getattr(default, field)) + + +def test_constants_kwarg_bare_string_raises_typeerror() -> None: + with pytest.raises(TypeError, match="titles"): + Constants(titles="abc") # type: ignore[arg-type] + + +def test_constants_kwarg_non_iterable_raises_typeerror() -> None: + with pytest.raises(TypeError): + Constants(titles=5) # type: ignore[arg-type] + + +def test_constants_kwarg_capitalization_exceptions_wraps_tuple_manager() -> None: + c = Constants(capitalization_exceptions={"mcdonald": "McDonald"}) + assert isinstance(c.capitalization_exceptions, TupleManager) + assert c.capitalization_exceptions["mcdonald"] == "McDonald" + + +def test_constants_kwarg_regexes_raises_typeerror() -> None: + with pytest.raises(TypeError, match="Policy"): + Constants(regexes={}) # type: ignore[call-arg] + + +def test_constants_kwarg_unknown_raises_typeerror() -> None: + with pytest.raises(TypeError): + Constants(bogus=1) # type: ignore[call-arg] + + +def test_constants_kwarg_feeds_facade_parse() -> None: + from nameparser._facade import HumanName + + c = Constants(titles=["zzqtitle"]) + n = HumanName("Zzqtitle Judy Dench", constants=c) + assert n.title == "Zzqtitle" + + +# -- Restoring v1.3/1.4 manager hardening (#221/#238/#241/#242/#260) ------- + + +def test_set_manager_discard_is_noop_on_missing_and_notifies_on_real_change() -> None: + bumps = [] + s = SetManager(["a"], _on_change=lambda: bumps.append(1)) + assert s.discard("missing") is s # never raises, chainable + assert len(bumps) == 0 # no-op: nothing removed + assert s.discard("A") is s # normalized match + assert "a" not in s + assert len(bumps) == 1 + + +def test_set_manager_clear_notifies_only_if_non_empty() -> None: + bumps = [] + s = SetManager(["a", "b"], _on_change=lambda: bumps.append(1)) + assert s.clear() is s + assert len(s) == 0 + assert len(bumps) == 1 + assert s.clear() is s # already empty: no-op + assert len(bumps) == 1 + + +def test_set_manager_operators_accept_arbitrary_iterables() -> None: + a = SetManager(["a", "b"]) + assert a | ["B.", "z"] == {"a", "b", "z"} # list operand, normalized + assert a & (x for x in ["A", "q"]) == {"a"} # generator operand + assert a - ["a"] == {"b"} + assert a ^ ["b", "c"] == {"a", "c"} # symmetric difference + assert ["b", "c"] ^ a == {"a", "c"} # reflected + + +def test_set_manager_bare_str_or_bytes_operand_raises_typeerror() -> None: + a = SetManager(["a"]) + with pytest.raises(TypeError): + a | "ab" + with pytest.raises(TypeError): + a & b"ab" + with pytest.raises(TypeError): + "ab" | a # reflected form too + + +def test_set_manager_comparison_operators() -> None: + a = SetManager(["a", "b"]) + assert a <= {"a", "b", "c"} + assert a < {"a", "b", "c"} + assert not (a < {"a", "b"}) + assert {"a", "b", "c"} >= a + assert a <= SetManager(["a", "b", "c"]) + assert SetManager(["a", "b", "c"]) > a + + +def test_set_manager_constructor_rejects_bare_str() -> None: + with pytest.raises(TypeError): # #238/#241: not shredded to chars + SetManager("dr") # type: ignore[arg-type] + + +def test_set_manager_constructor_rejects_bare_bytes_with_decode_hint() -> None: + with pytest.raises(TypeError, match="decode"): + SetManager(b"dr") # type: ignore[arg-type] + + +def test_set_manager_constructor_rejects_non_str_element() -> None: + with pytest.raises(TypeError): + SetManager([None]) # type: ignore[list-item] + + +def test_set_manager_add_rejects_bytes_with_decode_hint() -> None: + s = SetManager() + with pytest.raises(TypeError, match="decode"): + s.add(b"dr") # type: ignore[arg-type] + + +def test_constants_setattr_rejects_bare_str_like_constructor() -> None: + c = Constants() + with pytest.raises(TypeError, match="conjunctions"): + c.conjunctions = "and" # type: ignore[assignment] + + +def test_tuple_manager_constructor_rejects_bare_str() -> None: + with pytest.raises(TypeError): # #242 + TupleManager("ab") # type: ignore[arg-type] + + +def test_tuple_manager_constructor_rejects_iterable_of_strings() -> None: + with pytest.raises(TypeError): # #242: 2-char strings silently split + TupleManager(["ab", "cd"]) # type: ignore[arg-type] + + +def test_tuple_manager_unknown_key_error_names_known_keys() -> None: + t = TupleManager({"mcdonald": "McDonald", "obrien": "O'Brien"}) + with pytest.raises(AttributeError, match="mcdonald"): + t.bogus + + +def test_tuple_manager_attribute_style_set_and_delete() -> None: + bumps = [] + t = TupleManager({"a": "A"}, _on_change=lambda: bumps.append(1)) + t.b = "B" # attribute-style routes to dict + assert t["b"] == "B" + assert len(bumps) == 1 + del t.a + assert "a" not in t + assert len(bumps) == 2 + # the manager's own private attribute is unaffected + assert t._on_change is not None + + +def test_constants_copy_preserves_subclass() -> None: # #260 + class MyConstants(Constants): + pass + + c = MyConstants() + d = c.copy() + assert type(d) is MyConstants + + +def test_constants_repr_shows_collection_counts() -> None: # #221 + c = Constants() + r = repr(c) + assert r.startswith(" None: # #221 + c = Constants() + assert "capitalize_name" not in repr(c) # default: not shown + c.capitalize_name = True + assert "capitalize_name: True" in repr(c) + + +def test_constants_bool_kwargs() -> None: + # v1.4 constructor kwargs used by the customize.rst doctests + c = Constants(middle_name_as_last=True) + assert c.middle_name_as_last is True + assert c.patronymic_name_order is False + d = Constants(patronymic_name_order=True) + _, policy, _ = d._snapshot() + assert len(policy.patronymic_rules) == 2 + + +def test_set_manager_partial_add_still_notifies_owner() -> None: + # a TypeError mid-argument-list leaves earlier additions applied; + # the owner must hear about them or its cached parser goes stale + bumps: list[int] = [] + s = SetManager(["a"], _on_change=lambda: bumps.append(1)) + with pytest.raises(TypeError, match="decode"): + s.add("b", b"bad") # type: ignore[arg-type] + assert "b" in s + assert len(bumps) == 1 + + +def test_set_manager_remove_discard_bytes_decode_hint() -> None: + s = SetManager(["dr"]) + with pytest.raises(TypeError, match="decode"): + s.remove(b"dr") # type: ignore[arg-type] + with pytest.raises(TypeError, match="decode"): + s.discard(b"dr") # type: ignore[arg-type] + + +def test_delimiter_manager_constructor_bare_str_gets_242_error() -> None: + # the parent's #242 guard, not dict's cryptic ValueError + with pytest.raises(TypeError, match="mapping or iterable"): + _DelimiterManager("ab") # type: ignore[arg-type] + with pytest.raises(TypeError, match="pairs"): + _DelimiterManager(["ab", "cd"]) # type: ignore[list-item] + + +def test_constants_shared_flag_is_read_only() -> None: + c = Constants() + with pytest.raises(AttributeError, match="read-only"): + c._shared = True + with pytest.raises(AttributeError, match="read-only"): + CONSTANTS._shared = False + assert CONSTANTS._shared is True and c._shared is False diff --git a/tests/v2/test_contracts.py b/tests/v2/test_contracts.py new file mode 100644 index 00000000..be4381b7 --- /dev/null +++ b/tests/v2/test_contracts.py @@ -0,0 +1,68 @@ +"""Stable-string contract tests (core spec §7.4): every enum member and +stable tag has a canonical triggering input, parametrized by iterating +the registries -- a new member without an entry here fails loudly.""" +import pytest + +from nameparser import Parser, Policy, parse +from nameparser._policy import PatronymicRule +from nameparser._types import STABLE_TAGS, AmbiguityKind + +_AMBIGUITY_TRIGGERS: dict[AmbiguityKind, str | None] = { + AmbiguityKind.PARTICLE_OR_GIVEN: "Van Johnson", + AmbiguityKind.UNBALANCED_DELIMITER: 'Jon "Nick Smith', + AmbiguityKind.COMMA_STRUCTURE: "Smith, John, Extra, Jr.", + # no emitter yet -- arrives with locale-pack order detection (2.x) + AmbiguityKind.ORDER: None, + # no emitter yet -- arrives with suffix/nickname refinement (2.x) + AmbiguityKind.SUFFIX_OR_NICKNAME: None, +} + + +@pytest.mark.parametrize("kind", [ + pytest.param(k, marks=pytest.mark.xfail( + strict=True, reason=f"{k.value}: emitter not yet implemented")) + if k in _AMBIGUITY_TRIGGERS and _AMBIGUITY_TRIGGERS[k] is None else k + for k in AmbiguityKind +]) +def test_every_ambiguity_kind_has_a_registered_trigger( + kind: AmbiguityKind) -> None: + assert kind in _AMBIGUITY_TRIGGERS, ( + f"new AmbiguityKind {kind.value!r} needs a canonical trigger " + f"(or an explicit None with its planned emitter)") + trigger = _AMBIGUITY_TRIGGERS[kind] + assert trigger is not None # None triggers are strict-xfail marked + kinds = {a.kind for a in parse(trigger).ambiguities} + assert kind in kinds + + +_PATRONYMIC_TRIGGERS: dict[PatronymicRule, tuple[str, str]] = { + # rule -> (input, expected given) + PatronymicRule.EAST_SLAVIC: ("Сидоров Иван Петрович", "Иван"), + PatronymicRule.TURKIC: ("Mammadova Aygun Ali kizi", "Aygun"), +} + + +@pytest.mark.parametrize("rule", list(PatronymicRule)) +def test_every_patronymic_rule_has_a_trigger(rule: PatronymicRule) -> None: + assert rule in _PATRONYMIC_TRIGGERS + text, expected_given = _PATRONYMIC_TRIGGERS[rule] + p = Parser(policy=Policy(patronymic_rules=frozenset({rule}))) + assert p.parse(text).given == expected_given + + +_TAG_TRIGGERS: dict[str, tuple[str, str]] = { + # tag -> (input, token text carrying the tag) + "particle": ("Juan de la Vega", "de"), + "conjunction": ("Mr. and Mrs. John Smith", "and"), + "initial": ("John A. Smith", "A."), + "joined": ("John Ph. D.", "D."), +} + + +@pytest.mark.parametrize("tag", sorted(STABLE_TAGS)) +def test_every_stable_tag_has_a_trigger(tag: str) -> None: + assert tag in _TAG_TRIGGERS + text, token_text = _TAG_TRIGGERS[tag] + pn = parse(text) + tagged = next(t for t in pn.tokens if t.text == token_text) + assert tag in tagged.tags diff --git a/tests/v2/test_facade.py b/tests/v2/test_facade.py new file mode 100644 index 00000000..0873d529 --- /dev/null +++ b/tests/v2/test_facade.py @@ -0,0 +1,450 @@ +"""The 2.0 HumanName facade (migration spec §2).""" +import pickle +import warnings +from pathlib import Path + +import pytest + +from nameparser._config_shim import CONSTANTS, Constants +from nameparser._facade import HumanName + +_DATA_DIR = Path(__file__).parent / "data" + + +def test_basic_parse_and_v1_spellings() -> None: + n = HumanName("Dr. Juan de la Vega III") + assert n.title == "Dr." + assert n.first == "Juan" # v1 spelling of core 'given' + assert n.last == "de la Vega" # v1 spelling of core 'family' + assert n.suffix == "III" + assert n.original == "Dr. Juan de la Vega III" + assert n.full_name == "Dr. Juan de la Vega III" + + +def test_bytes_raises_with_decode_hint() -> None: # #245 + with pytest.raises(TypeError, match="decode"): + HumanName(b"John Smith") # type: ignore[arg-type] + + +def test_constants_none_raises_migration_hint() -> None: # #261 + with pytest.raises(TypeError, match="constants"): + HumanName("John Smith", constants=None) + + +def test_full_name_assignment_reparses() -> None: + n = HumanName("John Smith") + n.full_name = "Jane Doe" + assert (n.first, n.last) == ("Jane", "Doe") + + +def test_private_constants_mutation_honored_lazily() -> None: + # NB: the task spec's example used "dame" as a marker not in the + # default titles, but nameparser/config/titles.py already lists + # "dame" -- swapped in a nonsense marker to keep the test's premise + # (mutate a private Constants, next parse sees the change) true. + c = Constants() + n = HumanName("Zzqtitle Judy Dench", constants=c) + assert n.title == "" # 'zzqtitle' not a default title + c.titles.add("zzqtitle") + n.full_name = "Zzqtitle Judy Dench" # next parse sees the change + assert n.title == "Zzqtitle" + + +def test_capitalize_name_flag_capitalizes_on_parse() -> None: + c = Constants() + c.capitalize_name = True + n = HumanName("john smith", constants=c) + assert (n.first, n.last) == ("John", "Smith") + + +def test_constructor_suffix_delimiter_layers_onto_policy() -> None: + n = HumanName("Doe, John, RN - CRNA", constants=Constants(), + suffix_delimiter=" - ") + assert n.suffix == "RN, CRNA" + + +def test_subclass_overriding_no_hooks_never_warns() -> None: + class Plain(HumanName): + pass + + with warnings.catch_warnings(): + warnings.simplefilter("error") + Plain("John Smith") + + +def test_subclass_hook_override_warns_once_per_class() -> None: # #280 + class Custom(HumanName): + def parse_pieces(self, parts: object, additional_parts_count: int = 0) -> object: + return parts + + with pytest.deprecated_call(match="parse_pieces"): + Custom("John Smith") + with warnings.catch_warnings(): + warnings.simplefilter("error") + Custom("Jane Doe") # second instance: silent + + +def test_c_property_and_has_own_config() -> None: + n = HumanName("John Smith") + assert n.C is CONSTANTS + assert n.has_own_config is False + m = HumanName("John Smith", constants=Constants()) + assert m.has_own_config is True + + +def test_dirty_tracking_reuses_cached_parser_across_unchanged_generation() -> None: + n = HumanName("John Smith") + assert n._resolve() is n._resolve() + + c = Constants() + m = HumanName("John Smith", constants=c) + parser_before = m._resolve() + c.titles.add("zzq") # bumps the generation + parser_after = m._resolve() + assert parser_before is not parser_after + + +def test_component_kwargs_bypass_parsing() -> None: + n = HumanName(first="John", last="de la Vega") + assert n.first == "John" and n.last == "de la Vega" + assert n.full_name == "" + + +def test_field_assignment_str_list_none() -> None: + n = HumanName("John Smith") + n.first = "Jane" + assert n.first == "Jane" + n.middle = ["Q", "Xavier"] # lists join with space + assert n.middle == "Q Xavier" + n.middle = None # None clears + assert n.middle == "" + assert n.last == "Smith" # untouched fields survive + assert n.full_name == "John Smith" # no re-parse (v1 parity) + + +def test_list_attributes_are_snapshots() -> None: # spec §2 exc. 1 + n = HumanName("John Quincy Adams Smith") + lst = n.middle_list + lst.append("HACKED") + assert "HACKED" not in n.middle_list + + +def test_derived_views() -> None: + n = HumanName("Juan Q. de la Vega") + assert n.surnames == "Q. de la Vega" # middle + last (v1 shape) + assert n.given_names == "Juan Q." # first + middle + assert n.last_prefixes == "de la" + assert n.last_base == "Vega" + + +def test_split_last_all_prefix_guard() -> None: + n = HumanName(last="de la") # every word is a particle + assert n.last_prefixes == "" # v1 guard: no stripping + assert n.last_base == "de la" + + +@pytest.mark.parametrize("member", [ + "title", "first", "middle", "last", "suffix", "nickname", "maiden", +]) +def test_every_member_set_get_and_list(member: str) -> None: + n = HumanName() + setattr(n, member, "Alpha Beta") + # the suffix string view joins parts with ", " (v1 parity) + joined = "Alpha, Beta" if member == "suffix" else "Alpha Beta" + assert getattr(n, member) == joined + assert getattr(n, f"{member}_list") == ["Alpha", "Beta"] + + +def test_list_assignment_rejects_non_str_elements() -> None: + n = HumanName("John Smith") + with pytest.raises(TypeError, match="strings"): + n.first = [1, 2] # type: ignore[list-item] + with pytest.raises(TypeError, match="strings"): + n.first = [None] # type: ignore[list-item] + + +def test_list_assignment_multiword_elements_join() -> None: + n = HumanName("John Smith") + n.middle = ["ab", "cd ef"] + assert n.middle == "ab cd ef" + + +def test_suffix_list_heals_joined_continuations() -> None: # v1 fix_phd + n = HumanName("John Ph. D.") + assert n.suffix == "Ph. D." + assert n.suffix_list == ["Ph. D."] # ONE element, v1 parity + + +def test_str_uses_string_format_with_v1_cleanup() -> None: + n = HumanName("Dr. Juan de la Vega III") + assert str(n) == "Dr. Juan de la Vega III" + n2 = HumanName("John Smith") # empty nickname: no ' ()' + assert str(n2) == "John Smith" + n2.string_format = "{last}, {first}" + assert str(n2) == "Smith, John" + + +def test_str_none_format_falls_back_to_space_join() -> None: + # v1-identical: the format wraps the nickname in parens, the plain + # member join does not + n = HumanName('John "Jack" Kennedy') + assert str(n) == "John Kennedy (Jack)" + n.string_format = None + assert str(n) == "John Kennedy Jack" + + +def test_getitem_unknown_key_raises_attribute_error() -> None: + n = HumanName("John Smith") + with pytest.raises(AttributeError): # v1 behavior, not KeyError + n["nope"] + + +def test_repr_v1_shape() -> None: + r = repr(HumanName("John Smith")) + assert r.startswith(" None: + n = HumanName("John Smith") + assert list(n) == ["John", "Smith"] + assert len(n) == 2 + assert len(HumanName("")) == 0 # documented emptiness check + + +def test_getitem_str_ok_slice_raises() -> None: # #258 + n = HumanName("John Smith") + assert n["first"] == "John" + with pytest.raises(TypeError, match="#258"): + n[1:-1] # type: ignore[index] + with pytest.raises(TypeError): + n["first"] = "Jane" # type: ignore[index] # no __setitem__ in 2.0 + + +def test_eq_and_hash_are_object_identity() -> None: # #223 + a, b = HumanName("John Smith"), HumanName("John Smith") + assert a != b and a == a + assert hash(a) != hash(b) or a is b # default object hash + + +def test_as_dict_v1_keys() -> None: + n = HumanName("Dr. John Smith") + d = n.as_dict() + assert d["title"] == "Dr." and d["first"] == "John" and d["last"] == "Smith" + assert set(n.as_dict(include_empty=False)) == {"title", "first", "last"} + + +def test_initials_v1_semantics() -> None: + assert HumanName("Sir Bob Andrew Dole").initials() == "B. A. D." + assert HumanName("Sir Bob Andrew Dole").initials_list() == ["B", "A", "D"] + n = HumanName("Doe, John A.", initials_delimiter="", initials_separator="") + assert n.initials() == "J A D" + # prefixes/conjunctions are filtered except in first names (v1 rule) + assert HumanName("Juan de la Vega").initials() == "J. V." + + +def test_initials_format_kwarg() -> None: + n = HumanName("Sir Bob Andrew Dole", initials_format="{first} {middle}") + assert n.initials() == "B. A." + + +def test_render_default_setters_validate() -> None: + n = HumanName("John Smith") + with pytest.raises(TypeError, match="initials_delimiter"): + n.initials_delimiter = 5 # type: ignore[assignment] + with pytest.raises(TypeError, match="initials_format"): + n.initials_format = 5 # type: ignore[assignment] + with pytest.raises(TypeError, match="initials_separator"): + n.initials_separator = None # type: ignore[assignment] + with pytest.raises(TypeError, match="string_format"): + n.string_format = 5 # type: ignore[assignment] + with pytest.raises(TypeError, match="suffix_delimiter"): + n.suffix_delimiter = 5 # type: ignore[assignment] + n.string_format = None # None allowed for these two + n.suffix_delimiter = None + + +def test_capitalize_gate_and_force() -> None: + n = HumanName("bob v. de la macdole-eisenhower phd") + n.capitalize() + assert str(n) == "Bob V. de la MacDole-Eisenhower Ph.D." + m = HumanName("Shirley Maclaine") # mixed case: untouched + m.capitalize() + assert str(m) == "Shirley Maclaine" + m.capitalize(force=True) + assert str(m) == "Shirley MacLaine" + + +def test_force_mixed_case_flag_feeds_default() -> None: + c = Constants() + c.force_mixed_case_capitalization = True + n = HumanName("Shirley Maclaine", constants=c) + n.capitalize() # force=None -> flag -> True + assert str(n) == "Shirley MacLaine" + + +def test_matches_and_comparison_key() -> None: + # NB: the task spec's example compared "Dr. John A. Smith" against + # "John Smith" -- but matches()/comparison_key() are component-wise + # over all seven fields (v1 parity, verified against live 1.4), so a + # title/middle-initial mismatch always fails the match; swapped in + # case/order variations that actually exercise "case-insensitive, + # component-wise" without dropping fields. + n = HumanName("Dr. John A. Smith") + assert n.matches("dr. john a. smith") + assert n.matches(HumanName("smith, dr. john a.")) + assert not n.matches("Jane Smith") + assert n.comparison_key() == HumanName("DR. JOHN A. SMITH").comparison_key() + + +def test_pickle_round_trip_preserves_components() -> None: + n = HumanName("Dr. Juan de la Vega III") + n.first = "Johan" # mutated state must survive + loaded = pickle.loads(pickle.dumps(n)) + assert loaded.first == "Johan" + assert loaded.last == "de la Vega" + assert loaded.original == "Dr. Juan de la Vega III" + assert loaded.C is CONSTANTS # shared sentinel restored + + +# -- Gap 2: parse_full_name() (v1's documented re-parse idiom) ------------- + + +def test_parse_full_name_reparses_documented_idiom() -> None: + c = Constants() + n = HumanName("Zzqtitle Judy Dench", constants=c) + assert n.title == "" # 'zzqtitle' not a default title + c.titles.add("zzqtitle") # mutate C in place, no full_name reassignment + n.parse_full_name() # documented v1 idiom + assert n.title == "Zzqtitle" + + +def test_subclass_overriding_parse_full_name_warns() -> None: # #280 + class CustomReparse(HumanName): + def parse_full_name(self) -> None: + pass + + with pytest.deprecated_call(match="parse_full_name"): + CustomReparse("John Smith") + + +# -- Gap 3: HumanName.C setter (v1.4 #239) ---------------------------------- + + +def test_c_setter_assigns_and_next_parse_honors_it() -> None: + n = HumanName("John Smith") + c2 = Constants() + c2.titles.add("zzqtitle") + n.C = c2 + assert n.C is c2 + n.full_name = "Zzqtitle Judy Dench" # next parse: v1's setter only stored, no reparse + assert n.title == "Zzqtitle" + + +def test_c_setter_none_raises_migration_hint() -> None: + n = HumanName("John Smith") + with pytest.raises(TypeError, match="constants"): + n.C = None # type: ignore[assignment] + + +def test_c_setter_non_constants_raises() -> None: + n = HumanName("John Smith") + with pytest.raises(TypeError): + n.C = 42 # type: ignore[assignment] + + +# -- Gap 4: matches() error message names the facade type ------------------- + + +def test_matches_type_error_names_facade_type() -> None: + n = HumanName("John Smith") + with pytest.raises(TypeError, match="HumanName"): + n.matches(None) # type: ignore[arg-type] + + +def test_v14_humanname_blob_unpickles() -> None: + # tests/v2/data/humanname_v14.pickle was produced by a real 1.4.0 + # install (`uv run --no-project --with "nameparser==1.4.*" ...`) on + # HumanName("Dr. Juan de la Vega III"); components come back exactly + # as pickled, NOT re-parsed. Since the M11 swap replaced + # nameparser.parser.HumanName with this facade, the blob's pickled + # class reference now resolves here (mirrors + # test_v14_constants_blob_unpickles_into_shim in test_config_shim.py). + # pickle.load is safe here: humanname_v14.pickle is a repo-controlled + # fixture generated by this task from a real 1.4.0 install (Step 2 + # above), not data from an untrusted source. + with open(_DATA_DIR / "humanname_v14.pickle", "rb") as f: + loaded = pickle.load(f) + assert isinstance(loaded, HumanName) + assert loaded.first == "Juan" and loaded.last == "de la Vega" + assert loaded.title == "Dr." and loaded.suffix == "III" + assert loaded.original == "Dr. Juan de la Vega III" + assert loaded.C is CONSTANTS # shared sentinel restored (v1.4's C: None) + + +def test_suffix_delimiter_reassignment_after_construction() -> None: + n = HumanName("Doe, John, RN - CRNA") + assert n.suffix == "RN - CRNA" # no delimiter: one entry + n.suffix_delimiter = " - " + n.parse_full_name() + assert n.suffix == "RN, CRNA" # setter invalidated the Policy + n.suffix_delimiter = None + n.parse_full_name() + assert n.suffix == "RN - CRNA" # and back + + +def test_c_swap_keeps_instance_suffix_delimiter() -> None: + # both invalidation paths together: swapping C must not lose the + # instance-level delimiter layered onto the new snapshot's Policy + n = HumanName("Doe, John, RN - CRNA", suffix_delimiter=" - ") + assert n.suffix == "RN, CRNA" + n.C = Constants() + n.parse_full_name() + assert n.suffix == "RN, CRNA" + + +def test_failed_reparse_leaves_state_consistent() -> None: + # _resolve() raising must not leave full_name pointing at the new + # value over the OLD parsed fields + n = HumanName("John Smith") + + class _Boom(Exception): + pass + + def explode() -> None: + raise _Boom + + n._C = Constants() + n._C._snapshot = explode # type: ignore[method-assign, assignment] + n._snapshot_gen = -1 + with pytest.raises(_Boom): + n.full_name = "Jane Doe" + assert n.full_name == "John Smith" + assert n.first == "John" + + +def test_middle_as_family_list_view_matches_string_view() -> None: + # v1 PREPENDED middle_list to last_list; the list view must agree + # with the string view, not with raw token order + c = Constants() + c.middle_name_as_last = True + n = HumanName("Hassan, Mohamad Ahmad Ali", constants=c) + assert n.last == "Ahmad Ali Hassan" + assert n.last_list == ["Ahmad", "Ali", "Hassan"] + + +def test_cold_import_order_config_first() -> None: + # the _facade cycle-breaker (import nameparser.config first) is + # order-dependent; this pins BOTH cold-start directions in fresh + # interpreters so an import-sorting pass cannot silently break one + import subprocess + import sys + + for first in ("nameparser.config", "nameparser._facade"): + proc = subprocess.run( + [sys.executable, "-c", + f"import {first}; import nameparser; " + f"print(nameparser.HumanName('John Smith').last)"], + capture_output=True, text=True) + assert proc.returncode == 0, (first, proc.stderr) + assert proc.stdout.strip() == "Smith" diff --git a/tests/v2/test_facade_cases.py b/tests/v2/test_facade_cases.py new file mode 100644 index 00000000..90932553 --- /dev/null +++ b/tests/v2/test_facade_cases.py @@ -0,0 +1,75 @@ +"""Facade runner (migration spec §5): the shared case table asserted +through HumanName. Deleted wholesale in 3.0 with the facade.""" +import pytest + +from nameparser import Policy +from nameparser._config_shim import Constants +from nameparser._facade import HumanName + +from .cases import CASES, Case + +_V1_KEY = {"given": "first", "family": "last"} # identity for the rest + +#: the one non-default maiden_delimiters shape the table exercises +#: ("nickname_bucket_wins_when_shared"): expressible in v1 Constants via +#: the delimiter-manager's parenthesis sentinel added to the maiden +#: bucket, leaving the nickname bucket at its (also-parenthesis-holding) +#: default -- see _config_shim._DelimiterManager. +_MAIDEN_PARENS = frozenset({("(", ")")}) + + +def _constants_for(case: Case) -> Constants | None: + """Translate the row's Policy to a Constants, or None if the policy + has no v1 spelling (those rows are core-only).""" + policy = case.policy or Policy() + default = Policy() + c = Constants() + maiden_via_sentinel = ( + policy.maiden_delimiters == _MAIDEN_PARENS + and policy.nickname_delimiters == default.nickname_delimiters + ) + unexpressible = ( + policy.name_order != default.name_order + or policy.lenient_comma_suffixes != default.lenient_comma_suffixes + or policy.strip_emoji != default.strip_emoji + or policy.strip_bidi != default.strip_bidi + or policy.nickname_delimiters != default.nickname_delimiters + or (policy.maiden_delimiters != default.maiden_delimiters + and not maiden_via_sentinel) + ) + if unexpressible: + return None + if policy.patronymic_rules: + c.patronymic_name_order = True + if policy.middle_as_family: + c.middle_name_as_last = True + if policy.extra_suffix_delimiters: + c.suffix_delimiter = next(iter(policy.extra_suffix_delimiters)) + if maiden_via_sentinel: + # bucket-move idiom (see _DelimiterManager docstring): adds + # parenthesis to maiden while nickname keeps its own default + # (which already includes parenthesis) -- the nickname reading + # still wins on a shared delimiter, matching the row's Policy. + c.maiden_delimiters["parenthesis"] = "parenthesis" + return c + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: c.id) +def test_facade_case(case: Case) -> None: + if case.locale is not None: + # v1 Constants' patronymic bool enables BOTH the East Slavic and + # Turkic rules at once, so it cannot express a single-rule pack + # faithfully -- these rows are core-only (proven via parser_for + # in test_cases.py instead). + pytest.skip("locale rows are core-only") + constants = _constants_for(case) + if constants is None: + pytest.skip("policy not expressible through v1 Constants") + name = HumanName(case.text, constants=constants) + for field, expected in case.expect.items(): + assert getattr(name, _V1_KEY.get(field, field)) == expected, ( + f"{case.id}: {field}") + for field in {"title", "given", "middle", "family", "suffix", + "nickname", "maiden"} - set(case.expect): + assert getattr(name, _V1_KEY.get(field, field)) == "", ( + f"{case.id}: {field} expected empty") diff --git a/tests/v2/test_layering.py b/tests/v2/test_layering.py new file mode 100644 index 00000000..7ddcf910 --- /dev/null +++ b/tests/v2/test_layering.py @@ -0,0 +1,224 @@ +"""Enforce the conventions doc's contracts mechanically: import +layering, public exports, and the pickle layout guards.""" +import ast +import pathlib + +import nameparser + +PKG = pathlib.Path(nameparser.__file__).parent + +# ALLOWED keys whose module must exist on disk -- the exists() skip in +# test_layering_contract is for entries that precede their module within +# a plan, and without this list a renamed existing module would silently +# drop out of enforcement. Later tasks add each stage file as it lands. +_MUST_EXIST = {"_types.py", "_lexicon.py", "_policy.py", "_locale.py", + "_render.py", "_pipeline/_state.py", "_pipeline/__init__.py", + "_pipeline/_extract.py", "_pipeline/_tokenize.py", + "_pipeline/_vocab.py", "_pipeline/_segment.py", + "_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"} + +_PIPELINE_STAGE_ALLOWED = ( + "nameparser._types", "nameparser._lexicon", "nameparser._policy", + # stages share _state plus in-package helpers (_vocab, _group's + # piece predicates); the prefix still forbids _render/_locale/_parser + "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 +_LOCALE_PACK_ALLOWED = ( + "nameparser._lexicon", "nameparser._locale", "nameparser._policy", +) + +# module -> prefixes it may import from within nameparser +ALLOWED = { + # call-time imports only (inside the rendering-delegate method + # bodies); module level stays import-free. TYPE_CHECKING imports + # are skipped by _nameparser_imports and need no entry. + "_types.py": ("nameparser._render", "nameparser._parser"), + # intent: DATA modules only, during 2.x -- mechanically this admits + # any config submodule; "data only" holds by convention/review + "_lexicon.py": ("nameparser.config.",), + "_policy.py": ("nameparser._types",), + # _types is a downward import (bottom of the graph): Locale shares + # the _guarded_getstate/_guarded_setstate pickle functions + "_locale.py": ("nameparser._types", "nameparser._lexicon", + "nameparser._policy"), + # _lexicon is needed at runtime: capitalized(lexicon=None) resolves + # to Lexicon.default() + "_render.py": ("nameparser._types", "nameparser._lexicon"), + # every stage module: state + the three config/type modules + "_pipeline/_state.py": ("nameparser._types", "nameparser._lexicon", + "nameparser._policy"), + "_pipeline/__init__.py": ("nameparser._pipeline.",), + "_pipeline/_vocab.py": _PIPELINE_STAGE_ALLOWED, + "_pipeline/_extract.py": _PIPELINE_STAGE_ALLOWED, + "_pipeline/_tokenize.py": _PIPELINE_STAGE_ALLOWED, + "_pipeline/_segment.py": _PIPELINE_STAGE_ALLOWED, + "_pipeline/_classify.py": _PIPELINE_STAGE_ALLOWED, + "_pipeline/_group.py": _PIPELINE_STAGE_ALLOWED, + "_pipeline/_assign.py": _PIPELINE_STAGE_ALLOWED, + "_pipeline/_post_rules.py": _PIPELINE_STAGE_ALLOWED, + "_pipeline/_assemble.py": _PIPELINE_STAGE_ALLOWED, + # Parser sits on everything except _render and the facade + "_parser.py": ("nameparser._types", "nameparser._lexicon", + "nameparser._policy", "nameparser._locale", + "nameparser._pipeline"), + # facade layer (migration spec §2/§3): may import anything public + # plus _render (unused yet) and each other. _facade delegates + # parsing to the core Parser resolved from the bound Constants shim. + # The bare "nameparser.config" import (not just its submodules) is + # deliberate -- it resolves an import cycle, see _facade.py's comment. + "_facade.py": ("nameparser._config_shim", "nameparser._lexicon", + "nameparser._parser", "nameparser._types", + "nameparser._render", "nameparser.util", + "nameparser.config"), + # the Constants/SetManager/TupleManager shim: config DATA modules + # stay the single vocabulary source through 2.x (nameparser.config.*) + "_config_shim.py": ("nameparser._lexicon", "nameparser._parser", + "nameparser._policy", "nameparser.util", + "nameparser.config"), + # PEP 562 lazy loader: the only static import is _locale (for the + # Locale return type); pack modules load via importlib.import_module + # (a runtime string, invisible to the AST walker) -- the + # "nameparser.locales." prefix documents that reach for humans even + # though the walker never exercises it. + "locales/__init__.py": ("nameparser._locale", "nameparser.locales."), + "locales/ru.py": _LOCALE_PACK_ALLOWED, + "locales/tr_az.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",), + # CLI (migration spec §6): imports only the public package, same as + # any other consumer -- no access to internal modules. + "__main__.py": ("nameparser",), +} + + +def _is_type_checking(test: ast.expr) -> bool: + return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or ( + isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING") + + +def _nameparser_imports(path: pathlib.Path) -> list[str]: + """All nameparser-internal imports in the module, at any nesting + depth, EXCEPT those under `if TYPE_CHECKING:` -- annotation-only + imports are not runtime dependencies.""" + found: list[str] = [] + + def _record(node: ast.AST) -> None: + # guard checked on EVERY node uniformly, so `elif TYPE_CHECKING:` + # (a nested If in orelse) is skipped exactly like the plain form + if isinstance(node, ast.If) and _is_type_checking(node.test): + for stmt in node.orelse: + _record(stmt) + return + if isinstance(node, ast.Import): + found.extend(a.name for a in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + found.append(node.module) + for child in ast.iter_child_nodes(node): + _record(child) + + _record(ast.parse(path.read_text())) + return [m for m in found if m.startswith("nameparser")] + + +def _permitted(imported: str, allowed: tuple[str, ...]) -> bool: + # An entry ending in "." is a pure prefix (subpackage contents only); + # any other entry means that exact module or its submodules -- a bare + # startswith would also admit siblings like nameparser._types_helpers. + for entry in allowed: + if entry.endswith("."): + if imported.startswith(entry): + return True + elif imported == entry or imported.startswith(entry + "."): + return True + return False + + +def test_layering_contract() -> None: + for mod, allowed in ALLOWED.items(): + path = PKG / mod + if not path.exists(): + assert mod not in _MUST_EXIST, ( + f"{mod} keyed in ALLOWED but file is missing") + continue # entries may precede their module within a plan + for imported in _nameparser_imports(path): + assert _permitted(imported, allowed), ( + f"{mod} imports {imported}, which the layering contract " + f"forbids (allowed prefixes: {allowed or 'none'})" + ) + + +def test_lexicon_never_imports_config_package_root_or_parser() -> None: + for imported in _nameparser_imports(PKG / "_lexicon.py"): + assert imported != "nameparser.config" + assert not imported.startswith("nameparser.parser") + + +def test_public_exports() -> None: + expected = { + "Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName", + "Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "UNSET", + "GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST", + "DEFAULT_NICKNAME_DELIMITERS", "Locale", + "Parser", "parse", "parser_for", + } + assert expected <= set(nameparser.__all__) + for name in expected: + assert getattr(nameparser, name) is not None + + +def test_type_checking_imports_do_not_count(tmp_path: pathlib.Path) -> None: + mod = tmp_path / "snippet.py" + mod.write_text( + "from typing import TYPE_CHECKING\n" + "if TYPE_CHECKING:\n" + " from nameparser._lexicon import Lexicon\n" + "elif TYPE_CHECKING:\n" + " import nameparser.never_runtime\n" + "else:\n" + " import nameparser.util\n" + "def f():\n" + " from nameparser import _render\n" + ) + assert _nameparser_imports(mod) == ["nameparser.util", "nameparser"] + + +def test_every_frozen_dataclass_carries_the_pickle_guards() -> None: + # Forgetting the two class-body assignments is SILENT -- + # @dataclass(slots=True) installs its own working pickle methods + # without skew detection. Lexicon keeps a private copy of the guard + # (layering keeps _lexicon import-free of _types). + import dataclasses + import inspect + + import nameparser._lexicon + import nameparser._locale + import nameparser._parser + import nameparser._policy + import nameparser._types + from nameparser._types import _guarded_getstate, _guarded_setstate + + # _pipeline internals (ParseState, WorkToken, ...) are exempt: they + # are never pickled and are not public API. + modules = (nameparser._types, nameparser._lexicon, + nameparser._policy, nameparser._locale, nameparser._parser) + for module in modules: + for _, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != module.__name__: + continue + if not dataclasses.is_dataclass(cls): + continue + if cls.__name__ == "Lexicon": + assert "__getstate__" in cls.__dict__ + assert "__setstate__" in cls.__dict__ + continue + assert cls.__dict__.get("__getstate__") is _guarded_getstate, cls + assert cls.__dict__.get("__setstate__") is _guarded_setstate, cls diff --git a/tests/v2/test_lexicon.py b/tests/v2/test_lexicon.py new file mode 100644 index 00000000..ba82fff7 --- /dev/null +++ b/tests/v2/test_lexicon.py @@ -0,0 +1,214 @@ +import dataclasses + +import pytest + +from nameparser._lexicon import Lexicon + + +def test_entries_are_normalized_at_construction() -> None: + lex = Lexicon(titles=frozenset({"Dr.", "MR"})) + assert lex.titles == frozenset({"dr", "mr"}) + + +def test_default_sources_v1_vocabulary() -> None: + lex = Lexicon.default() + assert "dr" in lex.titles + assert "van" in lex.particles + assert "phd" in lex.suffix_acronyms + # flipped model: 'dos' is never-given in v1, so NOT ambiguous here + assert "dos" in lex.particles and "dos" not in lex.particles_ambiguous + assert "van" in lex.particles_ambiguous + # v1's CAPITALIZATION_EXCEPTIONS maps 'phd' -> 'Ph.D.' (verbatim, not + # normalized -- only keys are lowercased/period-stripped at + # construction, values pass through unchanged). + assert lex.capitalization_exceptions_map["phd"] == "Ph.D." + # maiden markers source from the same data-module pattern (#274); + # non-colliding Cyrillic entries live in the default per the locales + # design's sorting rule, and both ё/е spellings are listed because + # case normalization does not fold them. + assert "geborene" in lex.maiden_markers + assert "урожденная" in lex.maiden_markers and "урождённая" in lex.maiden_markers + # #269 fidelity: entries whose spelling casefold() would mutate must + # arrive in the lexicon exactly as authored in the data modules + # (final sigma intact, ß intact) -- the review that caught 'κοσ'. + assert "κος" in lex.titles and "κοσ" not in lex.titles + assert "großfürst" in lex.titles and "grossfürst" not in lex.titles + + +def test_default_is_cached_single_instance() -> None: + assert Lexicon.default() is Lexicon.default() + + +def test_particles_ambiguous_must_be_subset_of_particles() -> None: + with pytest.raises(ValueError, match="subset"): + Lexicon(particles_ambiguous=frozenset({"van"})) + + +def test_capitalization_exceptions_canonical_and_no_aliasing() -> None: + exceptions = {"phd": "PhD", "ii": "II"} + lex = Lexicon.empty() + lex2 = dataclasses.replace(lex, capitalization_exceptions=exceptions) # type: ignore[arg-type] + exceptions["iii"] = "III" # mutate caller's dict afterwards + assert "iii" not in lex2.capitalization_exceptions_map + # canonical: insertion order does not affect equality/hash + lex3 = dataclasses.replace(lex, capitalization_exceptions=(("ii", "II"), ("phd", "PhD"))) + assert lex2 == lex3 and hash(lex2) == hash(lex3) + + +def test_lexicon_is_hashable() -> None: + assert isinstance(hash(Lexicon.default()), int) + + +def test_lexicon_rejects_bare_string_vocab() -> None: + with pytest.raises(TypeError, match="bare string"): + Lexicon(titles="dr") # type: ignore[arg-type] + + +def test_lexicon_rejects_non_str_vocab_entries() -> None: + with pytest.raises(TypeError, match="entries must be strings"): + Lexicon(titles={"Dr.", 42}) # type: ignore[arg-type] + + +def test_entries_normalizing_to_empty_raise() -> None: + # "." or "" is a data bug (stray split artifact, empty CSV cell); + # dropping it silently would also let a future data-module typo + # vanish instead of failing CI. One rule for vocab AND exception keys. + with pytest.raises(ValueError, match="normalizes to empty"): + Lexicon(titles=frozenset({"Dr.", "."})) + with pytest.raises(ValueError, match="normalizes to empty"): + Lexicon.empty().add(titles={" "}) + with pytest.raises(ValueError, match="normalizes to empty"): + Lexicon(capitalization_exceptions=(("...", "X"), ("phd", "PhD"))) + + +def test_colliding_exception_keys_dedupe_last_wins() -> None: + # 'Phd.' and 'phd' collide under edge-period normalization + lex = Lexicon(capitalization_exceptions=(("Phd.", "A"), ("phd", "B"))) + assert lex.capitalization_exceptions == (("phd", "B"),) + rebuilt = Lexicon(capitalization_exceptions=lex.capitalization_exceptions_map) # type: ignore[arg-type] + assert rebuilt == lex and hash(rebuilt) == hash(lex) + + +def test_lexicon_rejects_non_str_exception_values() -> None: + with pytest.raises(TypeError, match="str -> str"): + Lexicon(capitalization_exceptions={"phd": 42}) # type: ignore[dict-item, arg-type] + + +def test_add_and_remove_return_new_lexicons() -> None: + # "zqtitle" is a synthetic word absent from v1's TITLES data (unlike + # e.g. "dra", the feminine "dr." abbreviation, which is already there). + base = Lexicon.default() + lex = base.add(titles={"zqtitle"}).remove(suffix_words={"esquire"}) + assert "zqtitle" in lex.titles and "zqtitle" not in base.titles + # precondition, or the removal assertion passes vacuously (this is a + # guard for the operation under test, not a vocabulary content pin) + assert "esquire" in base.suffix_words + assert "esquire" not in lex.suffix_words + + +def test_add_unknown_field_raises_with_valid_names() -> None: + with pytest.raises(TypeError, match="prefixes"): + Lexicon.default().add(prefixes={"van"}) # v1 name: helpful error + + +def test_add_capitalization_exceptions_raises_pointing_at_replace() -> None: + with pytest.raises(TypeError, match="dataclasses.replace"): + Lexicon.default().add(capitalization_exceptions={"x": "X"}) + + +def test_union_is_fieldwise_and_right_biased_for_exceptions() -> None: + a = dataclasses.replace(Lexicon.empty(), + capitalization_exceptions=(("phd", "PhD"),)) + a = a.add(titles={"dr"}) + b = dataclasses.replace(Lexicon.empty(), + capitalization_exceptions=(("phd", "Ph.D."),)) + b = b.add(titles={"mr"}) + u = a | b + assert u.titles == frozenset({"dr", "mr"}) + assert u.capitalization_exceptions_map["phd"] == "Ph.D." # right wins + + +def test_remove_breaking_subset_invariant_raises() -> None: + lex = Lexicon(particles=frozenset({"van"}), particles_ambiguous=frozenset({"van"})) + with pytest.raises(ValueError, match="subset"): + lex.remove(particles={"van"}) # would orphan particles_ambiguous + + +def test_pickle_round_trip_preserves_equality_and_cap_map() -> None: + # _cap_map holds a MappingProxyType, which pickle rejects; Lexicon + # must round-trip anyway because Parser is picklable by construction + # (spec: 2026-07-11-v2-core-api-design.md) and a Parser holds a Lexicon. + import pickle + + for lex in (Lexicon.default(), + Lexicon.empty().add(titles={"Dr."})): + loaded = pickle.loads(pickle.dumps(lex)) + assert loaded == lex + assert hash(loaded) == hash(lex) + assert (loaded.capitalization_exceptions_map + == lex.capitalization_exceptions_map) + + +def test_lexicon_rejects_mapping_for_plain_vocab_field() -> None: + # A dict here almost always means the caller confused the field with + # capitalization_exceptions; silently keeping just the keys would be + # the lone quiet coercion on an otherwise fail-loud surface. + with pytest.raises(TypeError, match="mapping"): + Lexicon(titles={"Dr.": "Doctor"}) # type: ignore[arg-type] + with pytest.raises(TypeError, match="mapping"): + Lexicon.empty().add(titles={"Dr.": "Doctor"}) + + +def test_capitalization_exceptions_rejects_malformed_shapes() -> None: + with pytest.raises(TypeError, match="bare string"): + Lexicon(capitalization_exceptions="ab") # type: ignore[arg-type] + with pytest.raises(TypeError, match="pairs"): + Lexicon(capitalization_exceptions=(("a", "b", "c"),)) # type: ignore[arg-type] + with pytest.raises(TypeError, match="pairs"): + # a 2-char string entry would unpack into two chars and silently + # store {"a": "b"} -- reject str entries outright + Lexicon(capitalization_exceptions=("ab",)) # type: ignore[arg-type] + + +def test_setstate_rejects_mismatched_field_layout() -> None: + # A pickle from a different Lexicon version (field added/renamed) + # must fail at load time with a message naming the mismatch, not at + # some later attribute read far from the unpickle site. + lex = Lexicon.empty() + good_state = lex.__getstate__() + missing = dict(good_state) + del missing["titles"] + with pytest.raises(ValueError, match="titles"): + Lexicon.__new__(Lexicon).__setstate__(missing) + extra = dict(good_state) + extra["zq_future_field"] = frozenset() + with pytest.raises(ValueError, match="zq_future_field"): + Lexicon.__new__(Lexicon).__setstate__(extra) + + +def test_normalization_lowercases_and_keeps_interior_periods() -> None: + # v1's lc() semantics: lowercase, EDGE periods trimmed, interior + # periods KEPT ('J.R.' must not collapse to 'jr' and hit the + # periodless vocabulary -- v1 parity, pinned live 2026-07-17). + # Suffix-ACRONYM membership alone strips periods (see + # _vocab.suffix_as_written). + lex = Lexicon(titles=frozenset({"STRAßE", "Ph.D", "Dr."})) + assert lex.titles == frozenset({"straße", "ph.d", "dr"}) + + +def test_normalization_preserves_spellings_casefold_would_mutate() -> None: + # lower(), not casefold(): casefold's caseless-matching folds would + # rewrite stored vocabulary -- Greek final sigma flattens ('κος' -> + # the misspelling 'κοσ') and German ß expands ('großfürst' -> + # 'grossfürst'). lower() follows Unicode SpecialCasing contextually + # ('ΚΟΣ' -> 'κος') and keeps entries as authored, matching v1's lc(). + lex = Lexicon(titles=frozenset({"ΚΟΣ", "Großfürst"})) + assert lex.titles == frozenset({"κος", "großfürst"}) + + +def test_suffix_ambiguous_must_be_subset_of_acronyms() -> None: + # same invariant as particles_ambiguous <= particles: the ambiguous + # set marks a subset of suffix_acronyms, and classify's period gate + # depends on the membership tests agreeing about it + with pytest.raises(ValueError, match="subset"): + Lexicon(suffix_acronyms_ambiguous=frozenset({"ma"})) diff --git a/tests/v2/test_locale.py b/tests/v2/test_locale.py new file mode 100644 index 00000000..924a7ce2 --- /dev/null +++ b/tests/v2/test_locale.py @@ -0,0 +1,76 @@ +import pytest + +from nameparser._lexicon import Lexicon +from nameparser._locale import Locale +from nameparser._policy import PatronymicRule, PolicyPatch + + +def test_locale_holds_code_lexicon_fragment_and_patch() -> None: + ru = Locale( + code="ru", + lexicon=Lexicon.empty(), + policy=PolicyPatch( + patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})), + ) + assert ru.code == "ru" + assert ru.policy.patronymic_rules == frozenset( + {PatronymicRule.EAST_SLAVIC}) + + +def test_locale_defaults_to_empty_patch() -> None: + assert Locale(code="xx", lexicon=Lexicon.empty()).policy == PolicyPatch() + + +def test_locale_code_must_be_nonempty_lowercase() -> None: + with pytest.raises(ValueError, match="lowercase"): + Locale(code="RU", lexicon=Lexicon.empty()) + with pytest.raises(ValueError, match="non-empty"): + Locale(code="", lexicon=Lexicon.empty()) + + +def test_locale_code_rejects_whitespace() -> None: + # caught by the [a-z0-9_]+ charset pin (a dedicated whitespace check + # would be pure redundancy; the charset message is accurate) + for bad in ("ru ", " ru", "ru\n", "r u"): + with pytest.raises(ValueError, match="a-z0-9_"): + Locale(code=bad, lexicon=Lexicon.empty()) + + +def test_locale_is_hashable() -> None: + loc = Locale(code="ru", lexicon=Lexicon.empty()) + assert isinstance(hash(loc), int) + + +def test_locale_validates_component_types() -> None: + with pytest.raises(TypeError, match="Lexicon"): + Locale(code="ru", lexicon={"titles": set()}) # type: ignore[arg-type] + with pytest.raises(TypeError, match="PolicyPatch"): + Locale(code="ru", lexicon=Lexicon.empty(), policy={"name_order": None}) # type: ignore[arg-type] + with pytest.raises(TypeError, match="must be a str"): + Locale(code=5, lexicon=Lexicon.empty()) # type: ignore[arg-type] + + +def test_locale_with_lexicon_pickles_round_trip() -> None: + import pickle + + loc = Locale(code="ru", lexicon=Lexicon.empty().add(titles={"Dr."})) + assert pickle.loads(pickle.dumps(loc)) == loc + + +def test_locale_code_is_pinned_to_registry_charset() -> None: + # Codes become registry keys the moment parser_for and third-party + # packs exist: every accepted character is supported forever, so pin + # [a-z0-9_]+ now (matches ru/tr_az). One separator only -- allowing + # both '-' and '_' would make tr-az and tr_az distinct keys. + for bad in ("ru!", "tr-az", "тр", "zh/tw"): + with pytest.raises(ValueError, match="a-z0-9_"): + Locale(code=bad, lexicon=Lexicon.empty()) + assert Locale(code="tr_az", lexicon=Lexicon.empty()).code == "tr_az" + + +def test_setstate_rejects_layout_skew() -> None: + loc = Locale(code="ru", lexicon=Lexicon.empty()) + state = dict(loc.__getstate__()) + del state["code"] + with pytest.raises(ValueError, match="code"): + Locale.__new__(Locale).__setstate__(state) diff --git a/tests/v2/test_locales.py b/tests/v2/test_locales.py new file mode 100644 index 00000000..2ea1753e --- /dev/null +++ b/tests/v2/test_locales.py @@ -0,0 +1,531 @@ +"""The locale pack layer (locales spec §2-3): lazy access, the two +2.0.0 packs, composition, and the non-interference gate.""" +import functools +import importlib +import json +import re +from collections.abc import Callable, Iterable +from pathlib import Path +from types import ModuleType + +import pytest + +from nameparser import Locale, Parser, locales, parse, parser_for +from nameparser._lexicon import Lexicon +from nameparser._policy import PatronymicRule, Policy +from nameparser.locales import ru as _ru +from nameparser.locales import tr_az as _tr_az + +# one JSON-encoded name string per line, blank lines skipped -- the same +# convention tools/differential/compare.py::main() reads (kept as two +# small copies on purpose: tools/ is a standalone uv-run script tree, +# not an importable package, and making it one costs more than 3 lines) +_CORPUS = [ + json.loads(line) + for line in (Path(__file__).parents[2] / "tools" / "differential" + / "corpus.jsonl").read_text().splitlines() + if line.strip() +] + +# The registry is the pack contract (design note 2026-07-18, option C): +# the DEVIATES requirement, the rotator lists, and the non-interference +# gate below all iterate the registered packs, so adding a pack is one +# registry entry plus one rotator list -- the contract meta-test fails +# until both exist, instead of a human remembering to extend N tests. +def _pack_modules() -> dict[str, ModuleType]: + out: dict[str, ModuleType] = {} + for modname, attr in locales._REGISTRY.values(): + module = importlib.import_module(modname) + out[getattr(module, attr).code] = module + return out + + +_PACKS = _pack_modules() + +# 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} + + +@functools.cache +def _default_parse(name: str) -> dict: + """Default-parser components for `name` (treat as read-only -- the + dict is cached and shared across callers).""" + return _DEFAULT_PARSER.parse(name).as_dict() + + +def test_locales_module_attribute_access() -> None: + assert isinstance(locales.RU, Locale) + assert locales.RU.code == "ru" + assert locales.RU is locales.RU # cached, not rebuilt + + +def test_locales_get_and_available() -> None: + assert locales.get("ru") is locales.RU + assert set(locales.available()) == {"ru", "tr_az"} + with pytest.raises(KeyError, match="ru, tr_az"): + locales.get("xx") + + +def test_tr_az_pack_contents() -> None: + assert locales.TR_AZ.code == "tr_az" + assert locales.TR_AZ.policy.patronymic_rules == frozenset( + {PatronymicRule.TURKIC}) + assert locales.TR_AZ.lexicon == Lexicon.empty() + + +def test_pack_marker_regexes_stay_in_sync_with_post_rules() -> None: + # ru.py/tr_az.py hand-copy their DEVIATES marker regexes from + # _pipeline._post_rules (layering forbids a pack importing the + # pipeline -- see each pack's module docstring). Assert pattern + # equality mechanically so drift fails here, not at the L6 + # non-interference gate. + from nameparser._pipeline import _post_rules + + for pack_regex, rule_regex in ( + (_ru._EAST_SLAVIC, _post_rules._EAST_SLAVIC), + (_ru._EAST_SLAVIC_CYR, _post_rules._EAST_SLAVIC_CYR), + (_tr_az._TURKIC, _post_rules._TURKIC), + (_tr_az._TURKIC_CYR, _post_rules._TURKIC_CYR), + ): + assert pack_regex.pattern == rule_regex.pattern + assert pack_regex.flags == rule_regex.flags + + +def test_ru_deviates_scans_per_token() -> None: + # Regression: the rule rotates on the family-POSITION token, so a + # whole-string search missed a patronymic ending followed by a + # suffix -- under-declaration, the unsafe direction for the + # non-interference gate. The pack rotates this name; DEVIATES must + # say so. + assert _ru.DEVIATES("Ivan Petr Sidorovich Jr.") + # Over-declaration is the accepted safe direction: the ending + # matches a token although the rule's 1 given + 1 middle + + # 1 family shape never fires on a two-token name. + assert _ru.DEVIATES("Sidorovich Anna") + assert not _ru.DEVIATES("John Smith") + + +def test_tr_az_deviates_scans_per_token() -> None: + # Mirror of the RU regression above: the Turkic marker regexes are + # fullmatch-shaped (^...$), so anything but a per-token scan would + # miss a marker followed by a suffix (under-declaration) -- and a + # whole-string match would never fire at all on multi-token names. + assert _tr_az.DEVIATES("Mammadova Aygun Ali kizi Jr.") + # over-declaration stays the safe direction: a bare marker-shaped + # token declares even where the rule won't rewrite anything + assert _tr_az.DEVIATES("Kizi Anna") + assert not _tr_az.DEVIATES("John Smith") + # markers are whole-token: a name merely CONTAINING one must not + # declare ("Ogluev" is not a marker) + assert not _tr_az.DEVIATES("Ogluev Ivan") + + +def test_locales_unknown_attribute() -> None: + with pytest.raises(AttributeError, match="XX"): + locales.XX + + +def test_ru_plus_tr_az_unions_patronymic_rules() -> None: + p = parser_for(locales.RU, locales.TR_AZ) + assert p.policy.patronymic_rules == frozenset( + {PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) + # both rules live: one name from each pack's case segment + ru = p.parse("Сидоров Иван Петрович") + assert ru.given == "Иван" + tr = p.parse("Mammadova Aygun Ali kizi") + assert (tr.given, tr.middle, tr.family) == ( + "Aygun", "Ali kizi", "Mammadova") + + +def test_pack_over_custom_base() -> None: + base = Parser(lexicon=Lexicon.default().add(titles={"zqxcustom"})) + p = parser_for(locales.RU, base=base) + n = p.parse("Zqxcustom Иван Петрович") + assert n.title == "Zqxcustom" # base lexicon survives the fold + + +def test_pack_preserves_custom_base_policy() -> None: + # the lexicon-survival twin above has a policy counterpart: a + # policy-only pack must ADD its patronymic rule without resetting + # unrelated policy fields the base customized + base = Parser(policy=Policy(strip_emoji=False)) + p = parser_for(locales.RU, base=base) + assert p.policy.strip_emoji is False + assert p.policy.patronymic_rules == frozenset( + {PatronymicRule.EAST_SLAVIC}) + + +def test_parser_for_results_chain_as_bases() -> None: + # parser_for's result is itself a valid base= -- packs applied in + # two steps accumulate exactly like one call with both packs + chained = parser_for(locales.TR_AZ, base=_PACKED["ru"]) + assert chained.policy.patronymic_rules == frozenset( + {PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) + assert chained.parse("Сидоров Иван Петрович").given == "Иван" + assert chained.parse("Mammadova Aygun Ali kizi").family == "Mammadova" + + +def test_locales_import_is_lazy(monkeypatch: pytest.MonkeyPatch) -> None: + # importing the package must not import any pack module; PEP 562 + # loads them on first attribute access (spec §2: "importing + # nameparser never pays for pack data"). monkeypatch snapshots + # sys.modules AND the parent package attribute (the fresh import + # below rebinds nameparser.locales), so everything rolls back even + # if an assert fails mid-test (manual del + reload left other tests + # running against a half-restored module cache on failure). + import sys + + import nameparser as _np + + monkeypatch.setattr(_np, "locales", _np.locales) + for mod in list(sys.modules): + if mod.startswith("nameparser.locales"): + monkeypatch.delitem(sys.modules, mod) + import nameparser.locales + assert "nameparser.locales.ru" not in sys.modules + nameparser.locales.RU + assert "nameparser.locales.ru" in sys.modules + + +def _assert_non_interference( + packed: Parser, deviates: Callable[[str], bool], corpus: Iterable[str], +) -> int: + """Return the number of DECLARED deviations seen; fail on any + undeclared one (spec §5.2 = the pack-acceptance rejection rule).""" + declared = 0 + for name in corpus: + base = _default_parse(name) + got = packed.parse(name).as_dict() + if got != base: + assert deviates(name), ( + f"UNDECLARED deviation: {name!r}\n" + f" default: {base}\n packed: {got}") + declared += 1 + return declared + + +def _default_corpus() -> list[str]: + from .cases import CASES + + return list(_CORPUS) + [c.text for c in CASES + if c.locale is None and c.policy is None] + + +# Synthetic rotator names, keyed by pack code: one per alternation +# branch of each pack's marker regexes (both scripts), in the shape that +# makes the rule fire (RU: family-first, patronymic last, plain middle; +# TR_AZ: a standalone marker token). They serve the gate's POSITIVE side +# -- without them the shared corpus exercises TR_AZ with a single name, +# leaving the gate nearly vacuous (review finding, 2026-07-17). Branch +# named per row, enforced by the branch-coverage test below. +_ROTATORS: dict[str, list[str]] = {} +_ROTATORS["ru"] = [ + "Sidorov Ivan Petrovich", # ovich + "Sidorova Anna Petrovna", # ovna + "Karpov Oleg Sergeevich", # evich + "Karpova Olga Sergeevna", # evna + "Petrova Maria Nikitichna", # ichna + "Ulyanov Vladimir Ilyich", # ilyich + "Bulgakov Ivan Kuzmich", # kuzmich + "Titov Pyotr Lukich", # lukich + "Orlov Semyon Fomich", # fomich + "Zaitsev Andrei Fokich", # fokich + "Сидоров Иван Петрович", # ович + "Сидорова Анна Петровна", # овна + "Карпов Олег Сергеевич", # евич + "Карпова Ольга Сергеевна", # евна + "Петрова Мария Никитична", # ична + "Ульянов Владимир Ильич", # ильич + "Булгаков Иван Кузьмич", # кузьмич + "Титов Пётр Лукич", # лукич + "Орлов Семён Фомич", # фомич + "Зайцев Андрей Фокич", # фокич +] +_ROTATORS["tr_az"] = [ + "Aliyev Ali Vali oglu", # oglu + "Aliyev Rashad Vali oğlu", # oğlu + "Mammadov Elchin Hasan ogly", # ogly + "Karimov Aziz Vali ogli", # ogli + "Karimov Alisher Vali o'g'li", # o['’ʻ]g['’ʻ]li, ASCII ' + "Yusupov Botir Vali o’g’li", # o['’ʻ]g['’ʻ]li, typographic ’ + "Mammadova Aygun Hasan qizi", # qizi + "Aliyeva Leyla Vali qızı", # qızı + "Mammadova Sevinj Ali kizi", # kizi + "Asanova Aigul Bolot kyzy", # kyzy + "Guliyeva Nigar Vali gyzy", # gyzy + "Nazarbayev Nursultan Abish uly", # uly + "Zheenbekov Sooronbay Sharip uulu", # uulu + "Алиев Али Вали оглу", # оглу + "Мамедов Эльчин Гасан оглы", # оглы + "Алиев Рашад Вали оғлу", # оғлу + "Каримов Алишер Вали ўғли", # ўғли + "Юсупов Ботир Вали угли", # угли + "Мамедова Айгюн Гасан кызы", # кызы + "Алиева Лейла Вали гызы", # гызы + "Назарбаева Дарига Нурсултан қызы", # қызы + "Каримова Лола Вали қизи", # қизи + "Назарбаев Нурсултан Абиш улы", # улы + "Токаев Касым Кемел ұлы", # ұлы + "Жээнбеков Сооронбай Шарип уулу", # уулу +] + + +def test_registry_is_the_pack_contract() -> None: + # spec §2 authoring requirement 3, enforced structurally (design + # note 2026-07-18, option C): every registered pack module must + # declare its deviation surface, and every pack must feed the gate's + # positive side -- a new pack fails HERE until it ships both, rather + # than a reviewer remembering to extend the gate by hand. + assert set(_PACKS) == set(locales.available()) + for code, module in _PACKS.items(): + assert callable(getattr(module, "DEVIATES", None)), ( + f"pack {code!r} does not declare DEVIATES") + assert set(_ROTATORS) == set(_PACKS), ( + "every pack needs a rotator list (and only registered packs)") + + +def _alternation_branches(pattern: str) -> list[str]: + """Split a marker regex of the shape '(a|b|...)$' / '^(a|b|...)$' + into its alternatives. The packs' character classes contain no '|', + so a flat split is safe (and the shape assert guards the day one + stops being true).""" + inner = pattern.removeprefix("^").removesuffix("$") + assert inner.startswith("(") and inner.endswith(")") and "|" not in ( + pattern.replace(inner, "")) + return inner[1:-1].split("|") + + +@pytest.mark.parametrize("code", sorted(_PACKS)) +def test_rotators_cover_every_marker_branch(code: str) -> None: + # The rotator lists' per-row branch comments are a human convention; + # this enforces them mechanically: every alternation branch of every + # marker regex a pack defines (any module-level re.Pattern) must be + # hit by some rotator token, so a regex edit that adds a branch + # (kept in sync with _post_rules by the pattern-equality test above) + # fails HERE until a rotator name covers it. Anchoring follows the + # pattern's own shape: '^(...)$' = whole-token marker, '(...)$' = + # token ending. + tokens = [tok for name in _ROTATORS[code] for tok in name.split()] + patterns = [v for v in vars(_PACKS[code]).values() + if isinstance(v, re.Pattern)] + assert patterns, f"pack {code!r} defines no marker regexes" + for regex in patterns: + for branch in _alternation_branches(regex.pattern): + anchored = regex.pattern.startswith("^") + branch_re = re.compile( + f"^({branch})$" if anchored else f"({branch})$", re.I) + assert any(branch_re.search(tok) for tok in tokens), ( + f"no {code!r} rotator exercises marker branch {branch!r}") + + +@pytest.mark.parametrize("code, name", [ + (code, name) for code in sorted(_ROTATORS) + for name in _ROTATORS[code]]) +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) + assert _PACKS[code].DEVIATES(name) + + +@pytest.mark.parametrize("code", sorted(_PACKS)) +def test_non_interference_each_pack(code: str) -> None: + corpus = _default_corpus() + _ROTATORS[code] + declared = _assert_non_interference( + _PACKED[code], _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) + for n in _ROTATORS[code]] + corpus = _default_corpus() + all_rotators + packed = parser_for(*(locales.get(code) for code in sorted(_PACKS))) + declared = _assert_non_interference( + packed, + lambda n: any(m.DEVIATES(n) for m in _PACKS.values()), + corpus) + assert declared >= len(all_rotators) + + +# -- #269: non-Latin default vocabulary (Cyrillic, Greek, Arabic, Hebrew) -- +# +# This is DEFAULT vocabulary (nameparser/config/titles.py, +# conjunctions.py, prefixes.py), not a locale pack -- it lives here +# because it's the non-Latin counterpart to the pack tests above. Every +# row was verified live against a runtime-augmented Lexicon.default() +# before the data landed (2026-07-17), so these pin actual observed +# behavior, not guesses -- see the per-script comments below for the +# rows that came out differently than a first guess would suggest. +@pytest.mark.parametrize("name, field, expected", [ + # Cyrillic (ru/uk) titles. + ("г-н Иван Петров", "title", "г-н"), + ("г-жа Мария Иванова", "title", "г-жа"), + ("д-р Мария Иванова", "title", "д-р"), + ("проф Петро Шевченко", "title", "проф"), + ("акад Іван Франко", "title", "акад"), + ("пан Тарас Шевченко", "title", "пан"), + ("пані Марія Іванова", "title", "пані"), + # Cyrillic conjunction "и": v1's single-letter carve-out (Google + # Code issue 11, the "john e smith" bug) treats a bare + # single-alphabetic-character conjunction in a short name as more + # likely an initial (group._group_segment), so a 3-piece chain + # ("проф и акад Іван Франко") does NOT join -- "и" reads as a given + # name instead. The chain only joins once the segment has enough + # rootname pieces (total >= 4); pinned against that actual behavior + # with a 5-piece name instead of the shorter guess. + ("проф и акад Тарас Григорович Шевченко", "title", "проф и акад"), + ("проф та акад Іван Франко", "title", "проф та акад"), + # Ukrainian "і" is single-character like "и", so the same + # single-letter carve-out applies: pinned with a 5-piece name. + ("проф і акад Тарас Григорович Шевченко", "title", "проф і акад"), + # Greek titles + conjunction (και has 3 letters, so the single-char + # initial carve-out above never applies to it). Bare κ is NOT + # shipped -- it collides with the initial+surname shape (see the + # regression test below). + # Match-time contract of the lower()-not-casefold() normalization + # (review 2026-07-19): case variants that lower() folds still + # match; the ASCII-SS spelling of ß does NOT (v1-parity -- casefold + # would have matched it, at the cost of mutating stored spellings). + ("ΚΟΣ Γιώργος Παπαδόπουλος", "title", "ΚΟΣ"), + ("GROẞFÜRST Otto Schmidt", "title", "GROẞFÜRST"), + ("GROSSFÜRST Otto Schmidt", "title", ""), + ("δρ Νίκος Παπαδόπουλος", "title", "δρ"), + ("κος Γιώργος Παπαδόπουλος", "title", "κος"), + ("κα Μαρία Παπαδοπούλου", "title", "κα"), + ("καθ και δρ Νίκος Παπαδόπουλος", "title", "καθ και δρ"), + # Arabic patronymic/clan prefixes: non-leading "بن"/"بنت" chain onto + # the family, mirroring the Latin "von"/"bin" prefix-chain rule. + ("محمد بن سلمان", "family", "بن سلمان"), + ("فاطمة بنت محمد", "family", "بنت محمد"), + # "ابن" (alternate spelling) and the clan prefix "آل" are + # never-given: non-leading they chain onto the family, and a + # LEADING "آل" consumes the whole name as family (no given), like + # "de Mesnil". + ("محمد ابن رشد", "family", "ابن رشد"), + ("محمد آل سعود", "family", "آل سعود"), + ("آل سعود", "family", "آل سعود"), + ("آل سعود", "given", ""), + # The kunya "أبو"/"ابو" is AMBIGUOUS (it can begin a standalone + # byname): leading it reads as the given name -- the "Van Johnson" + # rule -- while non-leading it chains onto the family like any + # particle. + ("أبو مازن", "given", "أبو"), + ("أحمد أبو خليل", "family", "أبو خليل"), + ("علي ابو خالد", "family", "ابو خالد"), + # "الشيخ" carries the FIRST_NAME_TITLES semantics of its + # transliterated cousin 'sheikh': a single following name reads as + # given, not family. + ("الشيخ محمد", "given", "محمد"), + # Arabic honorifics precede the GIVEN name, so all ship in + # given_name_titles like الشيخ: article forms (never given names) + # and the bare doctor/professor/engineer forms (not used as given + # names). Deferred with the same collision rule as мл/ст: bare + # سيد/شيخ/أمير/سلطان (common given names) and the 'د.' + # abbreviation (bare 'د' would swallow initials, the bare-κ trap). + ("الدكتور أحمد", "given", "أحمد"), + ("الدكتور أحمد", "title", "الدكتور"), + ("الدكتورة منى السيد", "title", "الدكتورة"), + ("الدكتورة منى السيد", "family", "السيد"), + ("دكتور أحمد", "given", "أحمد"), + ("دكتورة منى", "given", "منى"), + ("الأستاذ محمود", "given", "محمود"), + ("أستاذ محمود", "given", "محمود"), + ("أستاذة سلمى", "given", "سلمى"), + ("الأستاذة سلمى", "given", "سلمى"), + ("مهندس حسن", "given", "حسن"), + ("الشيخة موزة", "given", "موزة"), + ("الحاجة فاطمة", "given", "فاطمة"), + # compound with the bound given name: title + bound join + family + ("الحاج عبد الرحمن السيد", "title", "الحاج"), + ("الحاج عبد الرحمن السيد", "given", "عبد الرحمن"), + ("الحاج عبد الرحمن السيد", "family", "السيد"), + # "و" ("and") joins title chains like Cyrillic "и"; single-char + # conjunctions need the same single-letter carve-out headroom (enough + # rootname pieces), so pinned with the 6-piece shape the и row uses + ("الأستاذ و الدكتور طارق حسن السيد", "title", "الأستاذ و الدكتور"), + # Hebrew patronymic prefixes: same non-leading chain-onto-family + # behavior. + ("דוד בן גוריון", "family", "בן גוריון"), + ("שרה בת אברהם", "family", "בת אברהם"), + # Hebrew "מר" title (plain title, not FIRST_NAME_TITLES -- like + # 'mr', the following name reads as family). + ("מר דוד לוי", "title", "מר"), + # Hebrew title/suffix sweep (#269 follow-up): plain titles (Israeli + # convention: family follows, the מר precedent); both geresh/ + # gershayim spellings ship where an abbreviation carries one. + ("גברת רות כהן", "title", "גברת"), + ("פרופ' דוד לוי", "title", "פרופ'"), + ("פרופ׳ דוד לוי", "title", "פרופ׳"), + ("פרופסור רות כהן", "title", "פרופסור"), + ('עו"ד דוד לוי', "title", 'עו"ד'), + ("עו״ד דוד לוי", "title", "עו״ד"), + ("הרב עובדיה יוסף", "title", "הרב"), + # post-nominal Hebrew suffixes: ז"ל ("of blessed memory") and + # שליט"א (honorific for living rabbis) -- suffix words, exact + # token incl. the mid-word gershayim (inert in extraction, like + # the ד"ר title) + ('משה כהן ז"ל', "suffix", 'ז"ל'), + ("משה כהן ז״ל", "suffix", "ז״ל"), + ('הרב משה פיינשטיין שליט"א', "suffix", 'שליט"א'), + ("הרב משה פיינשטיין שליט״א", "title", "הרב"), + # Devanagari (hi/mr) titles -- a new #269 script: श्री/श्रीमती are + # the Mr./Mrs. analogs, डॉ the Dr. abbreviation (edge-period + # normalization makes "डॉ." match too). NO Latin twins on purpose: + # transliterated sri/shri collide with real given names (Sri + # Mulyani); the native script cannot. + ("श्री राम शर्मा", "title", "श्री"), + ("श्रीमती सीता शर्मा", "title", "श्रीमती"), + ("डॉ राम शर्मा", "title", "डॉ"), + ("डॉ. राम शर्मा", "title", "डॉ."), + # Geresh/gershayim gate (#269 step 2): probed live against + # extract_delimited's _open_ok/_close_ok boundary rules. Both the + # ASCII-quote spelling and the typographic Unicode spelling of + # "doctor"/"Mrs." survive extraction untouched -- the quote sits + # mid-word (no preceding whitespace before '"', and for the + # trailing "'" no following word boundary), so it never satisfies + # the boundary-valid open/close test and is left as literal text. + # Both spellings are shipped; see the titles.py comment. + ('ד"ר דוד לוי', "title", 'ד"ר'), + ("גב' דוד לוי", "title", "גב'"), + ("ד״ר דוד לוי", "title", "ד״ר"), + ("גב׳ דוד לוי", "title", "גב׳"), +]) +def test_269_nonlatin_vocabulary_parses( + name: str, field: str, expected: str) -> None: + assert getattr(parse(name), field) == expected + + +def test_269_vocabulary_reaches_the_v1_facade() -> None: + # one row per script through HumanName: the facade resolves its + # lexicon from the CONSTANTS shim snapshot, a different path from + # parse()'s Lexicon.default() -- proves the #269 data modules feed + # both surfaces + from nameparser import HumanName + + assert HumanName("г-н Иван Петров").title == "г-н" + assert HumanName("κος Γιώργος Παπαδόπουλος").title == "κος" + assert HumanName("محمد بن سلمان").last == "بن سلمان" + assert HumanName('ד"ר דוד לוי').title == 'ד"ר' + # one row per NEW vocabulary category (2026-07-19 batches): bound + # given name, Hebrew post-nominal suffix, Devanagari title + assert HumanName("عبد الرحمن محمد").first == "عبد الرحمن" + assert HumanName('משה כהן ז"ל').suffix == 'ז"ל' + assert HumanName("डॉ. राम शर्मा").title == "डॉ." + + +def test_269_bare_greek_kappa_not_a_title() -> None: + # Regression for the deferred bare 'κ' entry: were it in TITLES, + # _normalize's edge-period strip would make the abbreviated-initial + # 'Κ.' match it, degrading the very common initial+surname shape to + # title='Κ.' with an EMPTY given. Pin the correct reading. + n = parse("Κ. Παπαδόπουλος") + assert n.title == "" + assert n.given == "Κ." + assert n.family == "Παπαδόπουλος" diff --git a/tests/v2/test_parser.py b/tests/v2/test_parser.py new file mode 100644 index 00000000..2ed3477b --- /dev/null +++ b/tests/v2/test_parser.py @@ -0,0 +1,172 @@ +import pickle + +import pytest + +from nameparser import Lexicon, Locale, Parser, Policy, PolicyPatch, parse, parser_for +from nameparser._policy import ( + FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, PatronymicRule, +) +from nameparser._types import AmbiguityKind + + +def test_parser_defaults_and_properties() -> None: + p = Parser() + assert p.lexicon == Lexicon.default() + assert p.policy == Policy() + + +def test_parser_rejects_wrong_types_eagerly() -> None: + with pytest.raises(TypeError, match="lexicon"): + Parser(lexicon={"titles": set()}) # type: ignore[arg-type] + with pytest.raises(TypeError, match="policy"): + Parser(policy="strict") # type: ignore[arg-type] + + +def test_parse_end_to_end_with_default_vocabulary() -> None: + pn = parse("Dr. Juan de la Vega III") + assert pn.title == "Dr." + assert pn.given == "Juan" + assert pn.family == "de la Vega" + assert pn.suffix == "III" + assert str(pn) == "Dr. Juan de la Vega III" + + +def test_parse_rejects_non_str_with_decode_hint() -> None: + with pytest.raises(TypeError, match="decode"): + parse(b"John Smith") # type: ignore[arg-type] + with pytest.raises(TypeError, match="str"): + parse(None) # type: ignore[arg-type] + + +def test_degenerate_inputs_are_total() -> None: + # spec §5a table + assert not parse("") + assert not parse(" ") + assert parse("").original == "" + single = parse("John") + assert single.given == "John" + family_first = Parser(policy=Policy(name_order=FAMILY_FIRST)) + assert family_first.parse("Yamada").family == "Yamada" + title_only = parse("Dr.") + assert title_only.title == "Dr." and not title_only.given + unbalanced = parse('Jon "Nick Smith') + kinds = {a.kind for a in unbalanced.ambiguities} + assert AmbiguityKind.UNBALANCED_DELIMITER in kinds + assert '"Nick' in [t.text for t in unbalanced.tokens] # literal + + +def test_parser_is_picklable_and_frozen() -> None: + p = Parser(policy=Policy(name_order=FAMILY_FIRST)) + loaded = pickle.loads(pickle.dumps(p)) + assert loaded == p + assert loaded.parse("Yamada Taro").family == "Yamada" + with pytest.raises(AttributeError): + p.policy = Policy() # type: ignore[misc] + + +def test_parser_repr_composes_component_reprs() -> None: + assert repr(Parser()) == "Parser(Lexicon(default), Policy())" + p = Parser(policy=Policy(name_order=FAMILY_FIRST)) + assert repr(p) == "Parser(Lexicon(default), Policy(name_order=FAMILY_FIRST))" + + +def test_parsedname_repr_includes_ambiguities_line() -> None: + pn = parse("Van Johnson") + r = repr(pn) + assert "given: 'Van'" in r + assert "ambiguities:" in r and "particle-or-given" in r + + +def test_module_parse_reuses_the_default_parser() -> None: + import nameparser._parser as parser_mod + assert parser_mod._default_parser() is parser_mod._default_parser() + + +def test_parser_for_stacks_locales() -> None: + ru = Locale(code="ru", + lexicon=Lexicon.empty().add(titles={"г-н"}), + policy=PolicyPatch(patronymic_rules=frozenset( + {PatronymicRule.EAST_SLAVIC}))) + p = parser_for(ru) + assert PatronymicRule.EAST_SLAVIC in p.policy.patronymic_rules + pn = p.parse("г-н Сидоров Иван Петрович") + assert pn.title == "г-н" + assert pn.given == "Иван" + assert pn.family == "Сидоров" + + +def test_parser_for_rejects_non_locales() -> None: + with pytest.raises(TypeError, match="Locale"): + parser_for("ru") # type: ignore[arg-type] + + +def test_parser_for_wraps_pack_errors_with_identity() -> None: + # PolicyPatch validates lazily (by design), so an invalid value sits + # latent in a perfectly constructible Locale until apply time + bad = Locale(code="xx", lexicon=Lexicon.empty(), + policy=PolicyPatch(name_order=(1, 2, 3))) # type: ignore[arg-type] + # the rewrap preserves the taxonomy's exception type (here the + # non-Role element TypeError) while adding the pack identity + with pytest.raises(TypeError, match="while applying locale 'xx'"): + parser_for(bad) + + +def test_parser_for_warns_on_scalar_conflict() -> None: + a = Locale(code="aa", lexicon=Lexicon.empty(), + policy=PolicyPatch(strip_emoji=False)) + b = Locale(code="bb", lexicon=Lexicon.empty(), + policy=PolicyPatch(strip_emoji=True)) + with pytest.warns(UserWarning, match="strip_emoji"): + p = parser_for(a, b) + assert p.policy.strip_emoji is True # later wins + + +def test_matches_component_wise_case_insensitive() -> None: + pn = parse("John Smith") + assert pn.matches("JOHN SMITH") + assert pn.matches(parse("john smith")) + assert not pn.matches("John Smythe") + with pytest.raises(TypeError, match="str or ParsedName"): + pn.matches(42) # type: ignore[arg-type] + + +def test_family_first_given_last_places_middle_between() -> None: + # T1: the three-piece FAMILY_FIRST_GIVEN_LAST assignment -- family + # from the front, given from the END, middle between (not a rotation + # of FAMILY_FIRST) + p = Parser(policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) + pn = p.parse("Zeng Xiao Long") + assert (pn.family, pn.middle, pn.given) == ("Zeng", "Xiao", "Long") + + +def test_multiple_unbalanced_delimiters_each_reported() -> None: + # T4: the extract scan continues past the first unmatched opener; + # each one is reported and treated as literal text + pn = parse('John "Jack (Smith') + unbalanced = [a for a in pn.ambiguities + if a.kind is AmbiguityKind.UNBALANCED_DELIMITER] + assert len(unbalanced) == 2 + assert pn.given == "John" and pn.family == "(Smith" + assert not pn.nickname + + +def test_matches_accepts_explicit_parser() -> None: + family_first = Parser(policy=Policy(name_order=FAMILY_FIRST)) + pn = family_first.parse("Yamada Taro") + assert pn.matches("Yamada Taro", parser=family_first) + assert not pn.matches("Yamada Taro") # default parser reads given-first + + +def test_phd_split_heals_in_the_suffix_view() -> None: + # v1 parity via fix_phd: the split credential renders as one suffix + assert parse("John Ph. D.").suffix == "Ph. D." + assert parse("John Smith PhD MD").suffix == "PhD, MD" # unchanged + + +def test_phd_split_mid_name_is_a_suffix() -> None: + # v1 parity: fix_phd extracted the credential BEFORE parsing, so + # position never mattered; the merged piece is a suffix anywhere + pn = parse("Dr. John Ph. D. Smith") + assert pn.suffix == "Ph. D." + assert pn.family == "Smith" + assert pn.middle == "" diff --git a/tests/v2/test_policy.py b/tests/v2/test_policy.py new file mode 100644 index 00000000..47047d55 --- /dev/null +++ b/tests/v2/test_policy.py @@ -0,0 +1,303 @@ +import dataclasses + +import pytest + +from nameparser._policy import ( + FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, + PatronymicRule, Policy, PolicyPatch, UNSET, apply_patch, +) +from nameparser._types import Role + + +def test_order_constants_read_as_their_contents() -> None: + assert GIVEN_FIRST == (Role.GIVEN, Role.MIDDLE, Role.FAMILY) + assert FAMILY_FIRST == (Role.FAMILY, Role.GIVEN, Role.MIDDLE) + assert FAMILY_FIRST_GIVEN_LAST == (Role.FAMILY, Role.MIDDLE, Role.GIVEN) + + +def test_policy_defaults() -> None: + p = Policy() + assert p.name_order == GIVEN_FIRST + assert p.patronymic_rules == frozenset() + assert ("(", ")") in p.nickname_delimiters + assert p.maiden_delimiters == frozenset() + assert p.strip_emoji and p.strip_bidi and p.lenient_comma_suffixes + + +def test_policy_is_hashable_and_replaceable() -> None: + p = dataclasses.replace(Policy(), name_order=FAMILY_FIRST) + assert p.name_order == FAMILY_FIRST + assert isinstance(hash(p), int) + + +def test_name_order_must_be_permutation_and_error_names_constants() -> None: + with pytest.raises(ValueError, match="FAMILY_FIRST_GIVEN_LAST"): + Policy(name_order=(Role.TITLE, Role.GIVEN, Role.FAMILY)) + with pytest.raises(ValueError, match="GIVEN_FIRST"): + Policy(name_order=(Role.GIVEN, Role.GIVEN, Role.FAMILY)) + + +def test_name_order_restricted_to_the_three_exported_orders() -> None: + # _name_positions only implements the three exported semantics; the + # unnamed permutations would silently misassign (PR review I7). + # Pre-2.0 strictness is free: relaxing later is compatible. + with pytest.raises(ValueError, match="GIVEN_FIRST"): + Policy(name_order=(Role.MIDDLE, Role.GIVEN, Role.FAMILY)) + + +def test_name_order_rejects_non_role_elements_with_type_error() -> None: + # taxonomy: wrong element type -> TypeError, not the permutation + # ValueError (PR review polish) + with pytest.raises(TypeError, match="Role"): + Policy(name_order=(1, 2, 3)) # type: ignore[arg-type] + + +def test_patronymic_rules_coerce_and_reject() -> None: + p = Policy(patronymic_rules=frozenset({"east-slavic"})) # type: ignore[arg-type] + assert p.patronymic_rules == frozenset({PatronymicRule.EAST_SLAVIC}) + with pytest.raises(ValueError, match="east-slavic, turkic"): + Policy(patronymic_rules=frozenset({"klingon"})) # type: ignore[arg-type] + + +def test_delimiter_pairs_must_be_nonempty_string_pairs() -> None: + with pytest.raises(ValueError, match="non-empty"): + Policy(nickname_delimiters=frozenset({("", ")")})) + + +def test_delimiter_pair_rejects_two_char_string() -> None: + with pytest.raises(TypeError, match="tuples"): + Policy(nickname_delimiters=frozenset({"()"})) # type: ignore[arg-type] + + +def test_patronymic_rules_rejects_bare_string_and_non_iterable() -> None: + with pytest.raises(TypeError, match="bare string"): + Policy(patronymic_rules="east-slavic") # type: ignore[arg-type] + with pytest.raises(TypeError, match="iterable"): + Policy(patronymic_rules=5) # type: ignore[arg-type] + + +def test_patronymic_rules_true_points_at_the_v1_migration() -> None: + # v1's patronymic_name_order was a BOOL flag, so True is the single + # likeliest wrong value a migrator passes here -- the message must + # name the v1 flag and the working replacements, not just say + # "not iterable". Same guard on the PolicyPatch path (packs). + with pytest.raises(TypeError, match="patronymic_name_order"): + Policy(patronymic_rules=True) # type: ignore[arg-type] + with pytest.raises(TypeError, match="patronymic_name_order"): + PolicyPatch(patronymic_rules=True) # type: ignore[arg-type] + # non-patronymic union fields get the generic curated message + with pytest.raises(TypeError, match="extra_suffix_delimiters"): + PolicyPatch(extra_suffix_delimiters=True) # type: ignore[arg-type] + + +def test_maiden_delimiters_win_over_nickname_defaults() -> None: + # A pair routes to exactly one field, and listing it in + # maiden_delimiters is the specific intent -- so the one-liner works + # without knowing (or rebuilding) the nickname default set. The + # review that prompted this: routing parens to maiden used to + # require both buckets edited in tandem. + p = Policy(maiden_delimiters=frozenset({("(", ")")})) + assert ("(", ")") in p.maiden_delimiters + assert ("(", ")") not in p.nickname_delimiters + # canonicalization: the explicit-removal spelling (the documented + # set-math idiom) converges to the same value (equal AND same hash + # -- cache keys agree) + from nameparser import DEFAULT_NICKNAME_DELIMITERS + + explicit = Policy( + nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS - {("(", ")")}, + maiden_delimiters=frozenset({("(", ")")}), + ) + assert p == explicit and hash(p) == hash(explicit) + + +def test_maiden_wins_applies_to_typographic_pairs_too() -> None: + # the subtraction is pair-agnostic; pin one #273 pair end-to-end + from nameparser import Parser + + p = Policy(maiden_delimiters=frozenset({("«", "»")})) + assert ("«", "»") not in p.nickname_delimiters + n = Parser(policy=p).parse("Jean «Dupont» Martin") + assert n.maiden == "Dupont" and n.nickname == "" + + +def test_maiden_precedence_applies_through_policy_patch() -> None: + # apply_patch re-runs Policy's constructor, so a patch (e.g. from a + # locale pack) adding a maiden pair gets the same subtraction + patched = apply_patch( + Policy(), PolicyPatch(maiden_delimiters=frozenset({("(", ")")}))) + assert ("(", ")") in patched.maiden_delimiters + assert ("(", ")") not in patched.nickname_delimiters + + +def test_default_nickname_delimiters_constant_is_the_default() -> None: + from nameparser import DEFAULT_NICKNAME_DELIMITERS + + assert Policy().nickname_delimiters == DEFAULT_NICKNAME_DELIMITERS + # the documented set-math idiom stays valid input + p = Policy(nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS | {("«", "»")}) + assert ("«", "»") in p.nickname_delimiters + + +def test_policy_delimiters_coerce_to_frozensets() -> None: + p = Policy(nickname_delimiters=[("(", ")")]) # type: ignore[arg-type] + assert isinstance(p.nickname_delimiters, frozenset) + assert isinstance(hash(p), int) + assert p == Policy(nickname_delimiters=frozenset({("(", ")")})) + + +def test_policy_delimiters_do_not_alias_caller_containers() -> None: + source = {("(", ")")} + p = Policy(nickname_delimiters=source) # type: ignore[arg-type] + source.add(("'", "'")) + assert ("'", "'") not in p.nickname_delimiters + + +def test_extra_suffix_delimiters_validated_and_coerced() -> None: + with pytest.raises(TypeError, match="bare string"): + Policy(extra_suffix_delimiters="ab") # type: ignore[arg-type] + with pytest.raises(TypeError, match="must be strings"): + Policy(extra_suffix_delimiters=frozenset({5})) # type: ignore[arg-type] + with pytest.raises(ValueError, match="non-empty strings"): + Policy(extra_suffix_delimiters=frozenset({""})) + p = Policy(extra_suffix_delimiters=["-"]) # type: ignore[arg-type] + assert p.extra_suffix_delimiters == frozenset({"-"}) + assert isinstance(hash(p), int) + + +def test_policy_patch_mirrors_policy_field_names() -> None: + policy_fields = {f.name for f in dataclasses.fields(Policy)} + patch_fields = {f.name for f in dataclasses.fields(PolicyPatch)} + assert policy_fields == patch_fields + + +def test_policy_patch_mirrors_policy_field_types() -> None: + for f in dataclasses.fields(Policy): + patch_annotation = PolicyPatch.__dataclass_fields__[f.name].type + assert patch_annotation == f"{f.type} | _Unset" + + +def test_policy_patch_canonicalizes_union_fields() -> None: + p = PolicyPatch(extra_suffix_delimiters=frozenset({"-"})) + assert isinstance(p.extra_suffix_delimiters, frozenset) + assert isinstance(hash(p), int) + out = apply_patch(Policy(), PolicyPatch(extra_suffix_delimiters=["-"])) # type: ignore[arg-type] + assert out.extra_suffix_delimiters == frozenset({"-"}) + + +def test_policy_patch_rejects_bare_string_union_fields() -> None: + with pytest.raises(TypeError, match="bare string"): + PolicyPatch(extra_suffix_delimiters="ab") # type: ignore[arg-type] + + +def test_apply_patch_overrides_scalars_and_unions_sets() -> None: + base = Policy(patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})) + patch = PolicyPatch( + name_order=FAMILY_FIRST, + patronymic_rules=frozenset({PatronymicRule.TURKIC}), + ) + out = apply_patch(base, patch) + assert out.name_order == FAMILY_FIRST # override + assert out.patronymic_rules == frozenset( # union + {PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) + assert out.strip_emoji is True # untouched + + +def test_apply_patch_with_empty_patch_returns_same_policy() -> None: + base = Policy() + assert apply_patch(base, PolicyPatch()) is base + + +def test_unset_fields_are_distinguishable_from_defaults() -> None: + patch = PolicyPatch(strip_emoji=True) # explicitly set to the default value + assert patch.strip_emoji is True + assert PolicyPatch().strip_emoji is UNSET + + +def test_policy_rejects_non_bool_flags() -> None: + # "no" and "false" are truthy: storing them would silently invert + # the caller's intent downstream. + for flag in ("middle_as_family", "lenient_comma_suffixes", + "strip_emoji", "strip_bidi"): + with pytest.raises(TypeError, match="must be a bool"): + Policy(**{flag: "no"}) # type: ignore[arg-type] + + +def test_patronymic_rules_generator_errors_propagate_untouched() -> None: + # A ValueError raised inside the caller's own generator must not be + # rewritten as "unknown patronymic rule" with the traceback erased. + def bad_loader(): # noqa: ANN202 + yield "east-slavic" + raise ValueError("config line 7: bad int") + + with pytest.raises(ValueError, match="config line 7"): + Policy(patronymic_rules=bad_loader()) # type: ignore[arg-type] + + +def test_unknown_patronymic_rule_error_names_the_offender() -> None: + with pytest.raises(ValueError, match="klingon"): + Policy(patronymic_rules=iter(["east-slavic", "klingon"])) # type: ignore[arg-type] + + +def test_policy_patch_canonicalizes_scalar_name_order() -> None: + # A list name_order stored as-is made the patch -- and any Locale + # holding it -- unhashable, failing far from the construction site. + p = PolicyPatch(name_order=[Role.FAMILY, Role.GIVEN, Role.MIDDLE]) # type: ignore[arg-type] + assert p.name_order == (Role.FAMILY, Role.GIVEN, Role.MIDDLE) + assert isinstance(hash(p), int) + + +def test_apply_patch_revalidates_deferred_values() -> None: + # PolicyPatch documents lazy validation: invalid values sit latent in + # the patch and must fail when applied, not silently flow into Policy. + bad_order = PolicyPatch(name_order=(Role.TITLE, Role.GIVEN, Role.FAMILY)) + with pytest.raises(ValueError, match="exported orders"): + apply_patch(Policy(), bad_order) + bad_rules = PolicyPatch(patronymic_rules=frozenset({"klingon"})) # type: ignore[arg-type] + with pytest.raises(ValueError, match="valid rules"): + apply_patch(Policy(), bad_rules) + + +def test_all_set_valued_patch_fields_declare_union_composition() -> None: + # apply_patch is driven by this metadata; dropping it from one field + # would silently flip locale layering from union to override. + union_fields = { + f.name for f in dataclasses.fields(PolicyPatch) + if f.metadata.get("compose") == "union" + } + assert union_fields == { + "patronymic_rules", "nickname_delimiters", + "maiden_delimiters", "extra_suffix_delimiters", + } + + +def test_policy_and_patch_pickle_round_trip_preserves_unset_identity() -> None: + import pickle + + p = Policy(patronymic_rules=frozenset({PatronymicRule.TURKIC})) + assert pickle.loads(pickle.dumps(p)) == p + patch = PolicyPatch(strip_emoji=False) + loaded = pickle.loads(pickle.dumps(patch)) + assert loaded == patch + # apply_patch gates on 'value is UNSET'; an unpickled patch is only + # correct because Enum members round-trip BY IDENTITY. A plain + # object() sentinel would break this silently. + assert loaded.name_order is UNSET + assert loaded.strip_emoji is False + + +def test_setstate_rejects_layout_skew() -> None: + state = dict(Policy().__getstate__()) + del state["name_order"] + with pytest.raises(ValueError, match="name_order"): + Policy.__new__(Policy).__setstate__(state) + + +def test_name_order_rejects_bare_string() -> None: + # tuple("gmf") is ("g","m","f"): without the guard a bare string + # fell through to the permutation ValueError instead of the + # taxonomy's bare-string TypeError every other iterable field raises + with pytest.raises(TypeError, match="bare string"): + Policy(name_order="gmf") # type: ignore[arg-type] + with pytest.raises(TypeError, match="bare string"): + PolicyPatch(name_order="gmf") # type: ignore[arg-type] diff --git a/tests/v2/test_properties.py b/tests/v2/test_properties.py new file mode 100644 index 00000000..0e4440b0 --- /dev/null +++ b/tests/v2/test_properties.py @@ -0,0 +1,78 @@ +"""Property layer (core spec §7.3). Hypothesis is a dev dependency only. + +The alphabet is punctuation-heavy on purpose: plain st.text() spreads +over all of Unicode, so commas, quotes, and delimiters almost never +appear and the interesting planes go unexercised. derandomize=True +keeps runs reproducible on shared CI runners -- this layer guards +against regressions; exploratory fuzzing happened during review. +""" +from hypothesis import given, settings +from hypothesis import strategies as st + +from nameparser import Lexicon, Policy, parse +from nameparser._pipeline import run +from nameparser._pipeline._state import ParseState + +_ALPHABET = st.sampled_from( + 'abcdefgh ABC 12 .,،,\'"()«»‏‏\U0001f600éñßЖ-') + + +@given(st.text(alphabet=_ALPHABET, max_size=200)) +@settings(max_examples=300, deadline=None, derandomize=True) +def test_parse_never_raises_on_str(text: str) -> None: + parse(text) + + +@given(st.text(alphabet=_ALPHABET, max_size=200)) +@settings(max_examples=300, deadline=None, derandomize=True) +def test_provenance_for_parser_produced_names(text: str) -> None: + pn = parse(text) + for t in pn.tokens: + assert t.span is not None + assert t.text == pn.original[t.span.start:t.span.end] + + +@given(st.text(alphabet=_ALPHABET, max_size=100)) +@settings(max_examples=200, deadline=None, derandomize=True) +def test_capitalized_idempotent(text: str) -> None: + once = parse(text).capitalized() + assert once.capitalized() == once + + +@given(st.text(alphabet=_ALPHABET, max_size=100)) +@settings(max_examples=200, deadline=None, derandomize=True) +def test_render_reparse_reaches_fixpoint(text: str) -> None: + # render/reparse legitimately takes several rounds to stabilize on + # comma-heavy input (each round can re-segment); the invariant is + # BOUNDED CONVERGENCE, not one-step idempotence + s = str(parse(text)) + for _ in range(10): + nxt = str(parse(s)) + if nxt == s: + break + s = nxt + assert str(parse(s)) == s, f"no fixpoint within 10 rounds: {s!r}" + + +@given(st.text(alphabet=_ALPHABET, max_size=100)) +@settings(max_examples=300, deadline=None, derandomize=True) +def test_every_original_char_is_accounted_for(text: str) -> None: + # Reverse coverage (the dual of provenance): no character of the + # input silently vanishes. Every char lies in a token span, a + # masked delimited span, or is individually ignorable -- whitespace, + # a structural comma, or a char the strip options remove. Checked on + # the pre-assembly state because dropped/extracted tokens keep their + # spans there. + state = run(ParseState(original=text, lexicon=Lexicon.default(), + policy=Policy())) + covered: set[int] = set() + for tok in state.tokens: + covered.update(range(tok.span.start, tok.span.end)) + for span in state.masked: + covered.update(range(span.start, span.end)) + ignorable = {",", "،", ",", "\U0001f600", "‏"} + for i, ch in enumerate(text): + if i in covered or ch.isspace() or ch in ignorable: + continue + raise AssertionError( + f"char {ch!r} at {i} in {text!r} is unaccounted for") diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py new file mode 100644 index 00000000..aedaaff5 --- /dev/null +++ b/tests/v2/test_render.py @@ -0,0 +1,271 @@ +import pytest + +from nameparser._lexicon import Lexicon +from nameparser._render import _collapse, render +from nameparser._types import Ambiguity, AmbiguityKind, ParsedName, Role, Span, Token + + +def test_collapse_is_the_254_algorithm() -> None: + # normative: leading/trailing whitespace, doubled spaces, + # space-before-comma, one trailing comma char (incl. Arabic/CJK), + # leading/trailing ', ' debris, and empty-wrapper artifacts from + # empty fields are removed + assert _collapse(" John Smith ") == "John Smith" + assert _collapse("Smith , John") == "Smith, John" + assert _collapse("John Smith ,") == "John Smith" + assert _collapse("John Smith،") == "John Smith" # Arabic comma + assert _collapse("John Smith,") == "John Smith" # fullwidth comma + assert _collapse(", John Smith, ") == "John Smith" + assert _collapse("John Smith ()") == "John Smith" + assert _collapse("John Smith ''") == "John Smith" + assert _collapse('John Smith ""') == "John Smith" + assert _collapse("") == "" + + +def _pn(original: str, tokens: list[Token]) -> ParsedName: + return ParsedName(original=original, tokens=tuple(tokens)) + + +def _delavega() -> ParsedName: + # "Dr. Juan de la Vega III" -- spans verified by hand + # 0123456789012345678901234 + return _pn("Dr. Juan de la Vega III", [ + Token("Dr.", Span(0, 3), Role.TITLE), + Token("Juan", Span(4, 8), Role.GIVEN), + Token("de", Span(9, 11), Role.FAMILY, frozenset({"particle"})), + Token("la", Span(12, 14), Role.FAMILY, frozenset({"particle"})), + Token("Vega", Span(15, 19), Role.FAMILY), + Token("III", Span(20, 23), Role.SUFFIX), + ]) + + +def test_render_fills_fields_and_collapses() -> None: + pn = _delavega() + assert render(pn, "{title} {given} {middle} {family} {suffix}") \ + == "Dr. Juan de la Vega III" + # empty middle collapses; comma survives correctly + assert render(pn, "{family}, {given} {middle}") == "de la Vega, Juan" + + +def test_render_accepts_derived_view_keys() -> None: + assert render(_delavega(), "{family_base}, {given} {family_particles}") \ + == "Vega, Juan de la" + assert render(_delavega(), "{surnames}") == "de la Vega" + assert render(_delavega(), "{given_names}") == "Juan" + + +def test_render_every_role_key_is_valid() -> None: + pn = _delavega() + for role in Role: + render(pn, f"{{{role.value}}}") # must not raise + + +def test_render_unknown_key_raises_enriched_keyerror() -> None: + with pytest.raises(KeyError, match="valid fields"): + render(_delavega(), "{first}") # v1 spelling: redirected loudly + + +def test_render_empty_parse_is_empty_string() -> None: + assert render(_pn("", []), "{title} {given} {middle} {family} {suffix}") == "" + + +def test_parsedname_render_and_str_delegate() -> None: + pn = _delavega() + assert pn.render() == "Dr. Juan de la Vega III" + assert pn.render("{family}, {given}") == "de la Vega, Juan" + assert str(pn) == pn.render() + assert str(_pn("", [])) == "" + + +def _bobdole() -> ParsedName: + # "Sir Bob Andrew Dole" + # 01234567890123456789 + return _pn("Sir Bob Andrew Dole", [ + Token("Sir", Span(0, 3), Role.TITLE), + Token("Bob", Span(4, 7), Role.GIVEN), + Token("Andrew", Span(8, 14), Role.MIDDLE), + Token("Dole", Span(15, 19), Role.FAMILY), + ]) + + +def test_initials_default_spec() -> None: + assert _bobdole().initials() == "B. A. D." + + +def test_initials_skips_tagged_particles_outside_given() -> None: + # family "de la Vega" with particle tags -> only V contributes; + # a given-name token always contributes even if tagged + assert _delavega().initials() == "J. V." + # conjunction tag skips too + pn = _pn("Mr. and Mrs. Smith", [ + Token("Mr.", Span(0, 3), Role.TITLE), + Token("and", Span(4, 7), Role.FAMILY, frozenset({"conjunction"})), + Token("Smith", Span(13, 18), Role.FAMILY), + ]) + assert pn.initials("{family}") == "S." + + +def test_initials_custom_delimiter_and_separator() -> None: + assert _bobdole().initials(delimiter="", separator="") == "B A D" + + +def test_initials_multiword_group_joins_within_group() -> None: + pn = _pn("Mary Jane Watson", [ + Token("Mary", Span(0, 4), Role.GIVEN), + Token("Jane", Span(5, 9), Role.GIVEN), + Token("Watson", Span(10, 16), Role.FAMILY), + ]) + assert pn.initials() == "M. J. W." + + +def test_initials_custom_spec_and_unknown_key() -> None: + assert _bobdole().initials("{given} {middle}") == "B. A." + with pytest.raises(KeyError, match="valid fields"): + _bobdole().initials("{title}") + + +def test_initials_already_initial_words() -> None: + pn = _pn("J. Doe", [ + Token("J.", Span(0, 2), Role.GIVEN), + Token("Doe", Span(3, 6), Role.FAMILY), + ]) + assert pn.initials() == "J. D." + + +def test_initials_empty_group_renders_empty() -> None: + # v2 returns "" for an empty result -- no v1-style + # empty_attribute_default fallback + assert _bobdole().initials("{middle}") == "A." + assert _pn("Cher", [Token("Cher", Span(0, 4), Role.GIVEN)]).initials("{middle}") == "" + + +def _lowercase_mac() -> ParsedName: + # v1 capitalize() doctest input: 'bob v. de la macdole-eisenhower phd' + # 0123456789012345678901234567890123456 + return _pn("bob v. de la macdole-eisenhower phd", [ + Token("bob", Span(0, 3), Role.GIVEN), + Token("v.", Span(4, 6), Role.MIDDLE), + Token("de", Span(7, 9), Role.FAMILY), + Token("la", Span(10, 12), Role.FAMILY), + Token("macdole-eisenhower", Span(13, 31), Role.FAMILY), + Token("phd", Span(32, 35), Role.SUFFIX), + ]) + + +def test_capitalized_all_lower_input_v1_parity() -> None: + out = _lowercase_mac().capitalized() + assert out.given == "Bob" + assert out.middle == "V." + assert out.family == "de la MacDole-Eisenhower" # particles stay lower + assert out.suffix == "Ph.D." # exceptions map, verbatim + # same spans, new texts (provenance is a documented non-invariant) + assert [t.span for t in out.tokens] == [t.span for t in _lowercase_mac().tokens] + + +def test_capitalized_all_upper_input() -> None: + pn = _pn("JOHN SMITH", [ + Token("JOHN", Span(0, 4), Role.GIVEN), + Token("SMITH", Span(5, 10), Role.FAMILY), + ]) + assert str(pn.capitalized()) == "John Smith" + + +def test_capitalized_preserves_mixed_case_unless_forced() -> None: + pn = _pn("Shirley Maclaine", [ + Token("Shirley", Span(0, 7), Role.GIVEN), + Token("Maclaine", Span(8, 16), Role.FAMILY), + ]) + assert pn.capitalized() == pn # untouched + assert pn.capitalized(force=True).family == "MacLaine" + + +def test_capitalized_is_idempotent() -> None: + once = _lowercase_mac().capitalized() + assert once.capitalized() == once + assert once.capitalized(force=True) == once + + +def test_capitalized_with_explicit_lexicon() -> None: + # empty lexicon: no particle rule, no exceptions -> plain capitalize + out = _lowercase_mac().capitalized(Lexicon.empty()) + assert out.family == "De La MacDole-Eisenhower" + assert out.suffix == "Phd" + + +def test_capitalized_lowers_conjunctions_but_not_initial_shapes() -> None: + # v1's is_conjunction excludes initial-shaped words: a lowercase + # 'y' lowers, but an uppercase 'Y' looks like an initial and + # capitalizes ('JOSE ORTEGA Y GASSET' -> 'Jose Ortega Y Gasset', + # pinned live against v1.4 2026-07-17) + # 01234567890123456789 + pn = _pn("juan ortega y gasset", [ + Token("juan", Span(0, 4), Role.GIVEN), + Token("ortega", Span(5, 11), Role.FAMILY), + Token("y", Span(12, 13), Role.FAMILY, frozenset({"conjunction"})), + Token("gasset", Span(14, 20), Role.FAMILY), + ]) + out = pn.capitalized(force=True) + assert out.family == "Ortega y Gasset" + upper = _pn("JUAN ORTEGA Y GASSET", [ + Token("JUAN", Span(0, 4), Role.GIVEN), + Token("ORTEGA", Span(5, 11), Role.FAMILY), + Token("Y", Span(12, 13), Role.FAMILY, frozenset({"conjunction"})), + Token("GASSET", Span(14, 20), Role.FAMILY), + ]) + assert upper.capitalized(force=True).family == "Ortega Y Gasset" + + +def test_capitalized_rebuilds_ambiguity_tokens() -> None: + tok = Token("van", Span(0, 3), Role.GIVEN, frozenset({"particle"})) + pn = ParsedName( + original="van johnson", + tokens=(tok, Token("johnson", Span(4, 11), Role.FAMILY)), + ambiguities=(Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, + "leading 'van' may be a particle", (tok,)),), + ) + out = pn.capitalized() + # the ambiguity references the NEW capitalized token (subset invariant) + assert out.ambiguities[0].tokens[0] is out.tokens[0] + assert out.ambiguities[0].tokens[0].text == "Van" + + +def test_render_and_initials_reject_non_str_arguments() -> None: + # eager, like every constructor: not an AttributeError frames deep + pn = _delavega() + with pytest.raises(TypeError, match="spec must be a str"): + pn.render(7) # type: ignore[arg-type] + with pytest.raises(TypeError, match="spec must be a str"): + pn.initials(7) # type: ignore[arg-type] + with pytest.raises(TypeError, match="delimiter must be a str"): + pn.initials(delimiter=None) # type: ignore[arg-type] + with pytest.raises(TypeError, match="separator must be a str"): + pn.initials(separator=0) # type: ignore[arg-type] + + +def test_capitalized_rejects_non_lexicon_argument() -> None: + # previously a silent no-op on mixed-case input and a deep + # AttributeError on single-case input + with pytest.raises(TypeError, match="must be a Lexicon"): + _delavega().capitalized("garbage") # type: ignore[arg-type] + with pytest.raises(TypeError, match="must be a Lexicon"): + _lowercase_mac().capitalized({"titles": set()}) # type: ignore[arg-type] + + +def test_initials_given_tokens_ignore_skip_tags() -> None: + # documented: a given-name token contributes even when tagged (the + # PARTICLE_OR_GIVEN case -- 'van' read as a given name) + pn = _pn("van Johnson", [ + Token("van", Span(0, 3), Role.GIVEN, frozenset({"particle"})), + Token("Johnson", Span(4, 11), Role.FAMILY), + ]) + assert pn.initials() == "v. J." + + +def test_render_malformed_specs_surface_raw_format_errors() -> None: + # documented contract: only unknown KEYS get the enriched KeyError; + # positional fields and bad conversions raise str.format's own error + pn = _delavega() + with pytest.raises(IndexError): + pn.render("{}") + with pytest.raises(ValueError): + pn.render("{given!q}") diff --git a/tests/v2/test_reprs.py b/tests/v2/test_reprs.py new file mode 100644 index 00000000..60b41ea0 --- /dev/null +++ b/tests/v2/test_reprs.py @@ -0,0 +1,68 @@ + +from nameparser._lexicon import Lexicon +from nameparser._locale import Locale +from nameparser._policy import FAMILY_FIRST, PatronymicRule, Policy, PolicyPatch +from nameparser._types import Ambiguity, AmbiguityKind, ParsedName, Role, Span, Token + + +def test_token_repr_is_compact() -> None: + t = Token("de", Span(9, 11), Role.FAMILY, frozenset({"particle"})) + assert repr(t) == "Token('de' @9:11 FAMILY {particle})" + assert repr(Token("Jane", None, Role.GIVEN)) == "Token('Jane' @synthetic GIVEN)" + + +def test_ambiguity_repr_shows_kind_and_token_texts() -> None: + van = Token("Van", Span(0, 3), Role.GIVEN) + a = Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "detail", (van,)) + assert repr(a) == "Ambiguity('particle-or-given': 'Van')" + + +def test_parsedname_repr_lists_nonempty_fields_in_canonical_order() -> None: + pn = ParsedName("John Smith", ( + Token("John", Span(0, 4), Role.GIVEN), + Token("Smith", Span(5, 10), Role.FAMILY), + )) + assert repr(pn) == ( + "" + ) + + +def test_parsedname_repr_includes_ambiguities_line_when_present() -> None: + van = Token("Van", Span(0, 3), Role.GIVEN) + pn = ParsedName("Van Johnson", + (van, Token("Johnson", Span(4, 11), Role.FAMILY)), + (Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "d", (van,)),)) + assert "ambiguities: ['particle-or-given']" in repr(pn) + + +def test_empty_parsedname_repr() -> None: + assert repr(ParsedName("", ())) == "" + + +def test_policy_repr_shows_only_nondefault_fields() -> None: + assert repr(Policy()) == "Policy()" + p = Policy(name_order=FAMILY_FIRST, strip_bidi=False) + assert repr(p) == "Policy(name_order=FAMILY_FIRST, strip_bidi=False)" + + +def test_lexicon_repr_is_bounded() -> None: + assert repr(Lexicon.default()) == "Lexicon(default)" + lex = Lexicon.default().add(titles={"zqx", "zqy"}) + assert repr(lex) == "Lexicon(default + titles: +2)" + assert "zqx" not in repr(lex) # never dump contents + + +def test_locale_repr_shows_code_and_patched_fields() -> None: + ru = Locale("ru", Lexicon.empty(), + PolicyPatch(patronymic_rules=frozenset( + {PatronymicRule.EAST_SLAVIC}))) + assert repr(ru) == "Locale('ru': patronymic_rules)" + assert repr(Locale("xx", Lexicon.empty())) == "Locale('xx')" + + +def test_lexicon_repr_diffs_against_nearer_baseline() -> None: + # An empty()-built Lexicon must not render as default() minus ~700 + # entries -- diff against whichever named constructor is nearer. + assert repr(Lexicon.empty()) == "Lexicon(empty)" + lex = Lexicon.empty().add(titles={"zqx"}) + assert repr(lex) == "Lexicon(empty + titles: +1)" diff --git a/tests/v2/test_types.py b/tests/v2/test_types.py new file mode 100644 index 00000000..7df3c4d9 --- /dev/null +++ b/tests/v2/test_types.py @@ -0,0 +1,388 @@ +from collections.abc import Iterable + +import pytest + +from nameparser._types import ( + STABLE_TAGS, Ambiguity, AmbiguityKind, ParsedName, Role, Span, Token, +) + + +def test_role_declaration_order_is_canonical_field_order() -> None: + assert [r.value for r in Role] == [ + "title", "given", "middle", "family", "suffix", "nickname", "maiden", + ] + + +def test_token_construction_and_span_coercion() -> None: + t = Token("Juan", (0, 4), Role.GIVEN) # type: ignore[arg-type] + assert t.span == Span(0, 4) + assert isinstance(t.span, Span) + assert t.span.start == 0 and t.span.end == 4 + assert t.tags == frozenset() + + +def test_synthetic_token_has_no_span() -> None: + t = Token("Jane", None, Role.GIVEN) + assert t.span is None + + +def test_token_rejects_empty_text() -> None: + with pytest.raises(ValueError, match="non-empty"): + Token("", Span(0, 0), Role.GIVEN) + + +def test_token_rejects_inverted_span() -> None: + with pytest.raises(ValueError, match="start <= end"): + Token("x", Span(5, 2), Role.GIVEN) + + +def test_token_rejects_negative_span() -> None: + with pytest.raises(ValueError, match="start <= end"): + Token("x", Span(-1, 1), Role.GIVEN) + + +def test_token_rejects_malformed_span_shapes() -> None: + with pytest.raises(TypeError, match="expected a \\(start, end\\) pair"): + Token("x", (0, 4, 9), Role.GIVEN) # type: ignore[arg-type] + with pytest.raises(TypeError, match="expected a \\(start, end\\) pair"): + Token("x", 5, Role.GIVEN) # type: ignore[arg-type] + + +def test_token_rejects_non_string_text() -> None: + with pytest.raises(TypeError, match="got None"): + Token(None, None, Role.GIVEN) # type: ignore[arg-type] + + +def test_token_is_frozen_and_hashable() -> None: + t = Token("Juan", Span(0, 4), Role.GIVEN) + with pytest.raises(AttributeError): + t.text = "X" # type: ignore[misc] + assert hash(t) == hash(Token("Juan", Span(0, 4), Role.GIVEN)) + + +def test_ambiguity_kind_members_are_their_string_values() -> None: + assert AmbiguityKind.PARTICLE_OR_GIVEN == "particle-or-given" + assert AmbiguityKind("order") is AmbiguityKind.ORDER + + +def test_ambiguity_construction_coerces_kind_string() -> None: + t = Token("Van", Span(0, 3), Role.GIVEN, frozenset({"particle"})) + a = Ambiguity("particle-or-given", "leading 'van' may be a particle", (t,)) # type: ignore[arg-type] + assert a.kind is AmbiguityKind.PARTICLE_OR_GIVEN + assert a.tokens == (t,) + + +def test_ambiguity_rejects_unknown_kind() -> None: + with pytest.raises(ValueError, match="particle-or-given"): + Ambiguity("no-such-kind", "detail", ()) # type: ignore[arg-type] + + +def test_ambiguity_rejects_non_token_elements() -> None: + with pytest.raises(TypeError, match="only Token instances"): + Ambiguity("order", "detail", ("not-a-token",)) # type: ignore[arg-type] + + +def test_ambiguity_rejects_empty_detail() -> None: + with pytest.raises(ValueError, match="non-empty string"): + Ambiguity(AmbiguityKind.ORDER, "", ()) + + +def test_ambiguity_rejects_non_str_detail() -> None: + with pytest.raises(TypeError, match="must be a str"): + Ambiguity(AmbiguityKind.ORDER, None, ()) # type: ignore[arg-type] + + +def _pn(original: str, tokens: Iterable[Token], + ambiguities: Iterable[Ambiguity] = ()) -> ParsedName: + return ParsedName(original=original, tokens=tuple(tokens), + ambiguities=tuple(ambiguities)) + + +def test_parsedname_accepts_valid_spans_and_is_truthy() -> None: + pn = _pn("John Smith", [ + Token("John", Span(0, 4), Role.GIVEN), + Token("Smith", Span(5, 10), Role.FAMILY), + ]) + assert bool(pn) is True + + +def test_empty_parse_is_falsy() -> None: + assert bool(_pn("", [])) is False + assert bool(_pn(" ", [])) is False + + +def test_parsedname_rejects_out_of_bounds_span() -> None: + with pytest.raises(ValueError, match="out of bounds"): + _pn("John", [Token("Johnny", Span(0, 6), Role.GIVEN)]) + + +def test_parsedname_rejects_overlapping_spans() -> None: + with pytest.raises(ValueError, match="ascending"): + _pn("John Smith", [ + Token("John", Span(0, 4), Role.GIVEN), + Token("ohn S", Span(1, 6), Role.FAMILY), + ]) + + +def test_parsedname_rejects_descending_spans() -> None: + with pytest.raises(ValueError, match="ascending"): + _pn("John Smith", [ + Token("Smith", Span(5, 10), Role.FAMILY), + Token("John", Span(0, 4), Role.GIVEN), + ]) + + +def test_synthetic_tokens_skip_span_checks() -> None: + pn = _pn("John Smith", [ + Token("John", Span(0, 4), Role.GIVEN), + Token("Qux", None, Role.MIDDLE), + Token("Smith", Span(5, 10), Role.FAMILY), + ]) + assert len(pn.tokens) == 3 + + +def test_ambiguity_tokens_must_be_subset_of_parse_tokens() -> None: + inside = Token("Van", Span(0, 3), Role.GIVEN) + outside = Token("Zzz", None, Role.GIVEN) + with pytest.raises(ValueError, match="subset"): + _pn("Van Johnson", + [inside, Token("Johnson", Span(4, 11), Role.FAMILY)], + [Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "d", (outside,))]) + + +def test_parsedname_equality_is_strict_structural() -> None: + a = _pn("John", [Token("John", Span(0, 4), Role.GIVEN)]) + b = _pn("John", [Token("John", Span(0, 4), Role.GIVEN)]) + c = _pn("John ", [Token("John", Span(0, 4), Role.GIVEN)]) + assert a == b and hash(a) == hash(b) + assert a != c # different original: not interchangeable + + +def test_parsedname_rejects_non_str_original() -> None: + with pytest.raises(TypeError, match="must be a str"): + _pn(None, []) # type: ignore[arg-type] + + +def test_parsedname_rejects_non_token_and_non_ambiguity_elements() -> None: + with pytest.raises(TypeError, match="only Token instances"): + _pn("x", ["not-a-token"]) # type: ignore[list-item] + with pytest.raises(TypeError, match="only Ambiguity instances"): + _pn("John", [Token("John", Span(0, 4), Role.GIVEN)], ["nope"]) # type: ignore[list-item] + + +def _delavega() -> ParsedName: + # "Dr. Juan de la Vega III" -- hand-built, spans verified by hand + # 0123456789012345678901234 + return _pn("Dr. Juan de la Vega III", [ + Token("Dr.", Span(0, 3), Role.TITLE), + Token("Juan", Span(4, 8), Role.GIVEN), + Token("de", Span(9, 11), Role.FAMILY, frozenset({"particle"})), + Token("la", Span(12, 14), Role.FAMILY, frozenset({"particle"})), + Token("Vega", Span(15, 19), Role.FAMILY), + Token("III", Span(20, 23), Role.SUFFIX), + ]) + + +def test_string_properties_join_by_role() -> None: + pn = _delavega() + assert pn.title == "Dr." + assert pn.given == "Juan" + assert pn.middle == "" + assert pn.family == "de la Vega" + assert pn.suffix == "III" + assert pn.nickname == "" + assert pn.maiden == "" + + +def test_suffix_joins_with_comma_space() -> None: + pn = _pn("John Smith PhD MD", [ + Token("John", Span(0, 4), Role.GIVEN), + Token("Smith", Span(5, 10), Role.FAMILY), + Token("PhD", Span(11, 14), Role.SUFFIX), + Token("MD", Span(15, 17), Role.SUFFIX), + ]) + assert pn.suffix == "PhD, MD" + + +def test_derived_views_filter_on_stable_particle_tag() -> None: + # Pin the hard-coded "particle" string in _text_for to the published + # contract until parser tag-emission contract tests land. + assert "particle" in STABLE_TAGS + pn = _delavega() + assert pn.family_particles == "de la" + assert pn.family_base == "Vega" + assert pn.surnames == "de la Vega" # middle + family + assert pn.given_names == "Juan" # given + middle + + +def test_tokens_for_preserves_order() -> None: + pn = _delavega() + assert [t.text for t in pn.tokens_for(Role.FAMILY)] == ["de", "la", "Vega"] + assert pn.tokens_for(Role.NICKNAME) == () + + +def test_as_dict_canonical_order_and_empty_filtering() -> None: + pn = _delavega() + d = pn.as_dict() + assert list(d) == ["title", "given", "middle", "family", + "suffix", "nickname", "maiden"] + assert d["family"] == "de la Vega" and d["middle"] == "" + d2 = pn.as_dict(include_empty=False) + assert list(d2) == ["title", "given", "family", "suffix"] + + +def test_replace_swaps_field_with_synthetic_tokens_in_place() -> None: + pn = _delavega() + pn2 = pn.replace(given="Jean Paul") + assert pn2.given == "Jean Paul" + assert pn2.family == "de la Vega" # untouched + assert pn2.original == pn.original # provenance unchanged + assert all(t.span is None for t in pn2.tokens_for(Role.GIVEN)) + assert pn.given == "Juan" # source object unchanged + # positional: synthetic given tokens sit where the old ones were + assert [t.role for t in pn2.tokens][:3] == [Role.TITLE, Role.GIVEN, Role.GIVEN] + + +def test_replace_adds_missing_field_at_end() -> None: + pn = _pn("John Smith", [ + Token("John", Span(0, 4), Role.GIVEN), + Token("Smith", Span(5, 10), Role.FAMILY), + ]) + pn2 = pn.replace(suffix="Jr") + assert pn2.suffix == "Jr" + assert pn2.tokens[-1].role is Role.SUFFIX + + +def test_replace_with_empty_string_clears_field() -> None: + pn = _delavega() + assert pn.replace(title="").title == "" + + +def test_replace_rejects_unknown_field() -> None: + with pytest.raises(TypeError, match="given"): + _delavega().replace(firstname="X") + + +def test_replace_drops_ambiguities_referencing_removed_tokens() -> None: + van = Token("Van", Span(0, 3), Role.GIVEN) + pn = _pn("Van Johnson", + [van, Token("Johnson", Span(4, 11), Role.FAMILY)], + [Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "d", (van,))]) + assert pn.replace(given="Bob").ambiguities == () + assert pn.replace(family="Smith").ambiguities != () + + +def test_replace_rejects_non_str_value() -> None: + with pytest.raises(TypeError, match="must be a str"): + _delavega().replace(given=None) # type: ignore[arg-type] + + +def test_replace_appends_missing_roles_in_canonical_order() -> None: + pn = _pn("John", [Token("John", Span(0, 4), Role.GIVEN)]) + pn2 = pn.replace(maiden="X", suffix="Y") + assert [t.role for t in pn2.tokens] == [Role.GIVEN, Role.SUFFIX, Role.MAIDEN] + + +def test_comparison_key_is_casefolded_canonical_seven_tuple() -> None: + pn = _delavega() + assert pn.comparison_key() == ( + "dr.", "juan", "", "de la vega", "iii", "", "", + ) + upper = _pn("JUAN DE LA VEGA", [ + Token("JUAN", Span(0, 4), Role.GIVEN), + Token("DE", Span(5, 7), Role.FAMILY, frozenset({"particle"})), + Token("LA", Span(8, 10), Role.FAMILY, frozenset({"particle"})), + Token("VEGA", Span(11, 15), Role.FAMILY), + ]) + lower = _pn("juan de la vega", [ + Token("juan", Span(0, 4), Role.GIVEN), + Token("de", Span(5, 7), Role.FAMILY, frozenset({"particle"})), + Token("la", Span(8, 10), Role.FAMILY, frozenset({"particle"})), + Token("vega", Span(11, 15), Role.FAMILY), + ]) + assert upper.comparison_key() == lower.comparison_key() + + +def test_matches_casefolds_unicode_case_pairs() -> None: + # Deliberate 1.4 deviation (release log): comparison folds with + # casefold() where 1.4 used lower(). Comparison is the one surface + # where casefold's aggressive folds are WANTED -- ß/SS and final- + # sigma forms are the same name under different case conventions, + # and the key is opaque, so the spelling-mutation bug casefold + # caused in vocabulary storage (which stays lower(), v1 lc parity + # -- see _lexicon._normalize) cannot happen here. + from nameparser import HumanName, parse + + assert parse("Hans Straße").matches("HANS STRASSE") # ß <-> SS + assert parse("Νίκος Παπαδόπουλος").matches("Νίκοσ Παπαδόπουλοσ") # ς <-> σ + # same fold on the facade path -- both APIs share the deviation + assert HumanName("Hans Straße").matches("HANS STRASSE") + key = parse("Hans Straße").comparison_key() + assert key == parse("HANS STRASSE").comparison_key() + + +def test_token_rejects_bare_string_and_mapping_tags() -> None: + # frozenset("particle") is the set(str) footgun: eight single chars. + with pytest.raises(TypeError, match="bare string"): + Token("Van", None, Role.GIVEN, tags="particle") # type: ignore[arg-type] + with pytest.raises(TypeError, match="mapping"): + Token("Van", None, Role.GIVEN, tags={"particle": 1}) # type: ignore[arg-type] + + +def test_token_rejects_non_str_tags() -> None: + with pytest.raises(TypeError, match="tags must contain only strings"): + Token("Van", None, Role.GIVEN, tags=frozenset({1})) # type: ignore[arg-type] + + +def test_token_coerces_role_string_and_rejects_unknown() -> None: + # mirror Ambiguity.kind: coerce the string form, ValueError for any + # failed enum lookup (stdlib EnumType precedent). + assert Token("Juan", None, "given").role is Role.GIVEN # type: ignore[arg-type] + with pytest.raises(ValueError, match="title, given"): + Token("Juan", None, "chief") # type: ignore[arg-type] + with pytest.raises(ValueError, match="title, given"): + Token("Juan", None, 5) # type: ignore[arg-type] + + +def test_types_pickle_round_trip() -> None: + import pickle + + pn = _delavega() + assert pickle.loads(pickle.dumps(pn)) == pn + amb = Ambiguity(AmbiguityKind.ORDER, "two-comma structure", ()) + assert pickle.loads(pickle.dumps(amb)) == amb + tok = Token("de", Span(9, 11), Role.FAMILY, frozenset({"particle"})) + assert pickle.loads(pickle.dumps(tok)) == tok + + +def test_span_add_is_blocked() -> None: + # NamedTuple + would concatenate into a 4-tuple, the natural but + # wrong spelling of "covering span" (a real cover() arrives with the + # pipeline's join stage, its consumer). + with pytest.raises(TypeError, match="covering span"): + Span(0, 2) + Span(3, 4) # type: ignore[operator] + + +def test_token_rejects_bool_span_coordinates() -> None: + # bool is an int subclass; (False, True) is a comparison result + # leaking into a coordinate slot, not a span. + with pytest.raises(TypeError, match="pair of ints"): + Token("x", (False, True), Role.GIVEN) # type: ignore[arg-type] + + +def test_setstate_rejects_layout_skew_on_frozen_types() -> None: + # version-skewed pickles must fail at the LOAD site, naming the + # mismatch -- not at a distant attribute read (same policy as + # Lexicon; values are deliberately NOT re-validated: pickle is not + # a security boundary) + tok = Token("Juan", Span(0, 4), Role.GIVEN) + state = tok.__getstate__() + bad = dict(state) + del bad["tags"] + with pytest.raises(ValueError, match="tags"): + Token.__new__(Token).__setstate__(bad) + pn = _pn("Juan", [tok]) + bad_pn = dict(pn.__getstate__()) + bad_pn["zq_future"] = () + with pytest.raises(ValueError, match="zq_future"): + ParsedName.__new__(ParsedName).__setstate__(bad_pn) diff --git a/tools/differential/README.md b/tools/differential/README.md new file mode 100644 index 00000000..783f9c58 --- /dev/null +++ b/tools/differential/README.md @@ -0,0 +1,87 @@ +# Differential harness (v1 vs 2.0) + +Dev-only tooling for the 2.0 migration (migration plan S5). Not +shipped (excluded from the wheel by the packaging config -- only +`nameparser/` is packaged) and not CI-gated. Run it by hand when +touching parsing behavior, and before cutting a 2.0 release. + +Two processes, two environments: + +- `worker_v1.py` runs under a **pinned nameparser 1.4** installed fresh + from PyPI via a PEP 723 inline script. It must be invoked with + `uv run --no-project` -- **without `--no-project`, `uv` installs the + working tree as an editable dependency and the 1.4 pin never takes + effect**, silently comparing 2.0 against itself. +- `compare.py` runs in the project's own dev environment and imports + `nameparser` normally (the 2.0 facade, which still speaks the v1 + component names). + +## Running it + +``` +uv run python tools/differential/build_corpus.py --ref > tools/differential/corpus.jsonl # only when regenerating +uv run python tools/differential/compare.py +``` + +`compare.py` spawns the worker as a subprocess, feeds it every corpus +name as a line of JSON, and diffs the two component dicts on the seven +v1 field names (`title`, `first`, `middle`, `last`, `suffix`, +`nickname`, `maiden` -- both sides use these keys, so no field mapping +is needed). Every diff is checked against `expected_changes.toml`: + +- Matches a rule -> counted as an intentional, classified change. +- Matches no rule -> printed under `UNEXPLAINED` and the run exits 1. + +An unexplained diff means either a real 2.0 parity bug (fix it, don't +allowlist it) or a known change whose `expected_changes.toml` rule +needs widening. The run must exit 0 before a 2.0 release; the classified +summary it prints is the source for the "Behavior Changes" section of +`docs/release_log.rst`. + +## Corpus provenance + +`corpus.jsonl` is checked in as a test fixture. It was built by +`build_corpus.py`'s AST walk over every top-level `tests/test_*.py` +file (the v1-style test banks; `tests/v2/` is a separate 2.0-only +harness and is deliberately not scanned), reading each file via +`git show :` rather than the working tree: + +``` +uv run python tools/differential/build_corpus.py --ref 2d5d8c2 > tools/differential/corpus.jsonl +``` + +`2d5d8c2` ("Trim constant-factor waste on the tokenize hot path") is +the last commit before M12 reconciled/edited the v1 test banks against +the 2.0 facade -- M12 changed some expectations in place and deleted +bucket-A tests outright, which would have shrunk and skewed the +corpus. Reading history at an old ref via `git show` is a read-only +operation on this worktree's own log; it does not check out, stash, or +otherwise mutate anything. + +The AST extraction over-collects on purpose (string literals passed as +`HumanName(...)`'s first argument, plus string members of module-level +list/dict/tuple banks that contain a space) -- more candidate strings +is more coverage, and the corpus is deduplicated. Obvious non-names +(strings containing `{`, `@`, or a backslash -- format placeholders, +decorator/email-shaped fixtures, escape sequences) are dropped. + +Regenerate the corpus only if the v1 test banks are revisited again at +a still-earlier point in history; otherwise leave the checked-in file +alone so the harness stays comparable run to run. + +## `expected_changes.toml` + +Each `[[change]]` entry needs `issue` (a short label, ideally an +issue number or `fix()` matching a `tests/v2/cases.py` +classification) and may narrow its match with `name_regex` (searched +against the raw input string) and/or `fields` (the diffing rule +matches only if the observed diff fields are a subset of this list). +Keep both as tight as the actual diff allows -- a loose rule can mask +a real regression. + +Some entries in the seed list are for behavior families that this +particular corpus (pre-M12 v1 test strings) happens not to contain any +example of (e.g. custom suffix-delimiter rendering, which only fires +under a non-default `Policy`). They're kept in the file anyway, +matching the family documented in `tests/v2/cases.py`, so the rule is +ready the moment a matching string is added to the corpus. diff --git a/tools/differential/build_corpus.py b/tools/differential/build_corpus.py new file mode 100644 index 00000000..2c2613b8 --- /dev/null +++ b/tools/differential/build_corpus.py @@ -0,0 +1,107 @@ +"""Extract every plausible test-name string from the v1 test suite: +string literals passed as the first argument to HumanName(...) calls, +plus string elements/keys of module-level list/dict banks. Dev tooling +-- over-collection is fine, the comparator just parses more names. + +PROVENANCE: the checked-in tools/differential/corpus.jsonl was built +against commit 2d5d8c2 ("Trim constant-factor waste on the tokenize +hot path"), the last commit before M12 reconciled/edited the v1 test +banks (M12 rewrote expectations and deleted the bucket-A tests, which +would have shrunk the corpus). Reading historical test content is +fine; per the migration rules, no git *operations* are ever run against +the original (non-worktree) checkout -- this script only shells out to +`git show :` inside the current worktree's own history, +which contains that commit as an ancestor. + +Regenerate with: + uv run python tools/differential/build_corpus.py --ref 2d5d8c2 \\ + > tools/differential/corpus.jsonl +""" +import argparse +import ast +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TESTS = ROOT / "tests" + +# Obvious non-names the AST heuristic sweeps up along with real names: +# format-string placeholders, decorators/emails, escape sequences. +_NOISE_CHARS = ("{", "@", "\\") + + +def _is_name_like(value: str) -> bool: + return not any(ch in value for ch in _NOISE_CHARS) + + +def _strings_from(tree: ast.Module) -> set[str]: + found: set[str] = set() + for node in ast.walk(tree): + if (isinstance(node, ast.Call) + and getattr(node.func, "id", getattr( + node.func, "attr", "")) == "HumanName" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str)): + found.add(node.args[0].value) + if isinstance(node, ast.Assign) and isinstance( + node.value, (ast.List, ast.Tuple, ast.Dict)): + for c in ast.walk(node.value): + if isinstance(c, ast.Constant) and isinstance(c.value, str) \ + and " " in c.value and "\n" not in c.value: + found.add(c.value) + return {n for n in found if _is_name_like(n)} + + +def _working_tree_sources() -> dict[str, str]: + return {path.name: path.read_text() + for path in sorted(TESTS.glob("test_*.py"))} + + +def _ref_sources(ref: str) -> dict[str, str]: + """Read every top-level tests/test_*.py file as it existed at `ref`, + via `git show`, without touching the working tree or index.""" + listing = subprocess.run( + ["git", "-C", str(ROOT), "ls-tree", "-r", "--name-only", ref, + "--", "tests"], + capture_output=True, text=True, check=True).stdout + paths = sorted( + p for p in listing.splitlines() + if p.startswith("tests/") and Path(p).parent == Path("tests") + and Path(p).name.startswith("test_") and p.endswith(".py")) + sources = {} + for path in paths: + show = subprocess.run( + ["git", "-C", str(ROOT), "show", f"{ref}:{path}"], + capture_output=True, text=True, check=True) + sources[Path(path).name] = show.stdout + return sources + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument( + "--ref", default=None, + help="git ref to read tests/test_*.py from (via `git show`) " + "instead of the working tree, e.g. 2d5d8c2 (pre-M12).") + args = ap.parse_args() + + sources = _ref_sources(args.ref) if args.ref else _working_tree_sources() + names: set[str] = set() + for filename, text in sources.items(): + try: + tree = ast.parse(text, filename=filename) + except SyntaxError as e: + print(f"skipping {filename}: {e}", file=sys.stderr) + continue + names |= _strings_from(tree) + + for name in sorted(names): + print(json.dumps(name, ensure_ascii=False)) + print(f"{len(names)} names", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tools/differential/compare.py b/tools/differential/compare.py new file mode 100644 index 00000000..f6edef8f --- /dev/null +++ b/tools/differential/compare.py @@ -0,0 +1,109 @@ +"""Differential harness (migration spec S5): 1.4-on-PyPI vs the working +tree over the corpus. Every diff must classify against +expected_changes.toml or the run fails. + + uv run python tools/differential/compare.py [--corpus corpus.jsonl] +""" +import argparse +import json +import re +import subprocess +import tomllib +from pathlib import Path + +HERE = Path(__file__).resolve().parent +FIELDS = ("title", "first", "middle", "last", "suffix", "nickname", + "maiden") + + +def validate_rules(rules: list[dict[str, object]]) -> None: + """Reject malformed allowlist rules LOUDLY at startup. A rule with + neither name_regex nor fields would match every diff and shadow + every later rule -- the harness would report false confidence, + the exact failure it exists to prevent.""" + for i, rule in enumerate(rules): + issue = rule.get("issue") + if not isinstance(issue, str) or not issue: + raise SystemExit( + f"expected_changes.toml rule #{i + 1} has no string " + f"'issue': {rule!r}") + if not isinstance(rule.get("name_regex"), str) \ + and not isinstance(rule.get("fields"), list): + raise SystemExit( + f"expected_changes.toml rule #{i + 1} ({issue!r}) has " + f"neither 'name_regex' nor 'fields' -- it would match " + f"every diff and shadow every later rule") + + +def classify(name: str, diff_fields: set[str], + rules: list[dict[str, object]]) -> str | None: + for rule in rules: + name_regex = rule.get("name_regex") + if isinstance(name_regex, str) and not re.search(name_regex, name): + continue + fields = rule.get("fields") + if isinstance(fields, list) and not diff_fields <= set(fields): + continue + return rule["issue"] # type: ignore[return-value] + return None + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--corpus", default=str(HERE / "corpus.jsonl")) + args = ap.parse_args() + rules = tomllib.loads( + (HERE / "expected_changes.toml").read_text()).get("change", []) + validate_rules(rules) + corpus = [json.loads(line) for line in + Path(args.corpus).read_text().splitlines() if line.strip()] + + proc = subprocess.Popen( + ["uv", "run", "--no-project", str(HERE / "worker_v1.py")], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + v1_input = "".join(json.dumps(n, ensure_ascii=False) + "\n" + for n in corpus) + v1_lines, _ = proc.communicate(v1_input) + v1_results = [json.loads(line) for line in v1_lines.splitlines()] + # hard checks, not asserts: -O must not turn a crashed worker into + # a truncated-but-green comparison + if proc.returncode != 0: + raise SystemExit( + f"worker_v1.py exited {proc.returncode}; comparison aborted") + if len(v1_results) != len(corpus): + raise SystemExit( + f"worker returned {len(v1_results)} results for " + f"{len(corpus)} corpus names; comparison aborted") + + from nameparser import HumanName # the working tree (2.0 facade) + by_issue: dict[str, list[str]] = {} + unexplained: list[tuple[str, dict[str, str], dict[str, str]]] = [] + for name, old in zip(corpus, v1_results): + new = {k: v or "" for k, v in HumanName(name).as_dict().items()} + diff = {f for f in FIELDS if old.get(f, "") != new.get(f, "")} + if not diff: + continue + issue = classify(name, diff, rules) + if issue is None: + unexplained.append((name, old, new)) + else: + by_issue.setdefault(issue, []).append(name) + + print(f"corpus: {len(corpus)} names; " + f"intentional diffs: {sum(map(len, by_issue.values()))}; " + f"unexplained: {len(unexplained)}\n") + for issue, names in sorted(by_issue.items()): + print(f"## {issue} ({len(names)})") + for n in names[:10]: + print(f" {n!r}") + print() + for name, old, new in unexplained: + print(f"UNEXPLAINED {name!r}") + for f in FIELDS: + if old.get(f, "") != new.get(f, ""): + print(f" {f}: {old.get(f, '')!r} -> {new.get(f, '')!r}") + return 1 if unexplained else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/differential/corpus.jsonl b/tools/differential/corpus.jsonl new file mode 100644 index 00000000..b057779b --- /dev/null +++ b/tools/differential/corpus.jsonl @@ -0,0 +1,486 @@ +"" +"\"Rick\" Edmonds" +"()" +"," +", John" +"A.B. Vajpayee" +"Abdul Salam Ahmed Salem, MD" +"Abdul Salam Hassan, MD" +"Abdul Salam, MD" +"Abu Bakr Al Baghdadi, MD" +"Adolph D" +"Ahmad ben Husain" +"Al Arnold Gore, Jr." +"Alex Ben Johnson" +"Alex van Johnson" +"Aliyev Vusal Said oglu" +"Alois von und zu Liechtenstein" +"Alois von und zu und von Liechtenstein" +"Amy E Maid" +"Amy E. Maid" +"Andrew" +"Andrew Boris Petersen" +"Andrew Perkins \"MBA\"" +"Andrew Perkins 'MBA'" +"Andrew Perkins (JD)" +"Andrew Perkins (M.D)" +"Andrew Perkins (MBA)" +"Andrew Perkins (Mgr.)" +"Andrew Perkins (XYZ)" +"Andrew Perkins, Jr., Col. (Ret)" +"Andrews, M.D." +"Anh Do" +"Annette Charlotte Freiherrin von und zu der Tann-Rathsamhausen" +"Assoc Dean of Chemistry Robert Johns" +"Baker (Johnson), Jenny" +"Baker (Jr.), Jenny" +"Ben Alex Johnson" +"Ben Johnson" +"Benjamin \"Ben\" Franklin" +"Benjamin \"Big Ben\" Franklin" +"Benjamin 'Ben' Franklin" +"Benjamin (Ben) Franklin" +"Benjamin (Big Ben) Franklin" +"Benjamin [Ben] Franklin" +"Bob Dole" +"Brian Andrew O'connor" +"Brian O'connor" +"Buca di Beppo" +"Cardinal Secretary of State Hillary Clinton" +"Chancellor Jane Smith" +"Chang, Andy C I" +"Charles van der van der Berg" +"Chemistry Jane Smith" +"Chief Judge J. Leon Holmes" +"Chief Judge Sharon Lovelace Blackburn" +"Clarke, Kenneth, Q.C. M.P." +"Coach" +"Col. (Ret.) Smith" +"DE MESNIL" +"DR DOE" +"Dame Mary" +"Dean Ms Hon Solo" +"Del Toro" +"Della Reese" +"Designated Judge David A. Ezra" +"Di Caprio" +"Doctor, Jane E." +"Doe, Dr. John" +"Doe, Dr. John A." +"Doe, Dr. John A. III" +"Doe, Dr. John A. Jr." +"Doe, Dr. John A. Kenneth" +"Doe, Dr. John A. Kenneth III" +"Doe, Dr. John A. Kenneth Jr." +"Doe, Dr. John III" +"Doe, Dr. John P., CLU, CFP, LUTC" +"Doe, Dr. John, Jr." +"Doe, John" +"Doe, John A." +"Doe, John A. III" +"Doe, John A. Kenneth" +"Doe, John A. Kenneth III" +"Doe, John A. Kenneth, Jr." +"Doe, John A., III" +"Doe, John A., Jr." +"Doe, John III" +"Doe, John Msc.Ed." +"Doe, John jr., MD" +"Doe, John, Jr." +"Doe, John, MD PhD - FACS Fellow" +"Doe, John,, Jr." +"Doe, John,, Jr.,, III" +"Doe, John. A. Kenneth" +"Doe, John. A. Kenneth III" +"Doe, John. A. Kenneth, Jr." +"Doe, Lt. Gen. John A. Kenneth IV" +"Doe, Lt.Gov. John" +"Doe, Mary - Kate, RN" +"Doe, Rev. John A. Jr." +"Doe, Rev. John A., V, Jr." +"Doe, Rev. John V, Jr." +"Doe,, Jr." +"Doe-Ray, Dr. John P., CLU, CFP, LUTC" +"Doe-Ray, Hon. Barrington P. Jr." +"Doe-Ray, Hon. Barrington P. Jr., CFP, LUTC" +"Donovan McNabb-Smith" +"Dr King Jr" +"Dr Martin Luther King, Jr." +"Dr. Abdul Salam Hassan, MD" +"Dr. John A. Doe" +"Dr. John A. Doe III" +"Dr. John A. Doe, Jr." +"Dr. John A. Kenneth Doe" +"Dr. John A. Kenneth Doe III" +"Dr. John A. Kenneth Doe, Jr." +"Dr. John Doe" +"Dr. John Doe III" +"Dr. John Doe, Jr." +"Dr. John P. Doe-Ray, CLU" +"Dr. John P. Doe-Ray, CLU, CFP, LUTC" +"Dr. John Smith" +"Dr. Juan Q. Velasquez y Garcia" +"Dr. Juan Q. Velasquez y Garcia III" +"Dr. Juan Q. Velasquez y Garcia, Jr." +"Dr. Juan Q. Xavier Velasquez y Garcia" +"Dr. Juan Q. Xavier Velasquez y Garcia III" +"Dr. Juan Q. Xavier Velasquez y Garcia, Jr." +"Dr. Juan Q. Xavier de la Vega" +"Dr. Juan Q. Xavier de la Vega III" +"Dr. Juan Q. Xavier de la Vega, Jr." +"Dr. Juan Q. Xavier de la dos Vega III" +"Dr. Juan Q. Xavier de la dos Vega, III" +"Dr. Juan Q. de la Vega" +"Dr. Juan Q. de la Vega III" +"Dr. Juan Q. de la Vega, Jr." +"Dr. Juan Velasquez y Garcia" +"Dr. Juan Velasquez y Garcia III" +"Dr. Juan Velasquez y Garcia, Jr." +"Dr. Juan de la Vega" +"Dr. Juan de la Vega III" +"Dr. Juan de la Vega, Jr." +"Dr. Martin Luther King Jr." +"Dr. Williams" +"Dr. abdul salam ahmed salem" +"Dra. Andréia da Silva" +"E.T. Smith" +"E.T. Smith, II" +"Esq Jane Smith" +"Foo. John Smith" +"Foo. Xyz. John Smith" +"Franklin Washington, Jr. MD" +"Franklin, Benjamin (Ben)" +"Franklin, Benjamin (Ben), Jr." +"Frau Anna Müller" +"Fritz Freiherr und von Bar" +"Frøken Jensen" +"GREGORY HOUSE M.D." +"Gunny de Mesnil" +"Harietta Keopuolani Nahi'ena'ena" +"Harrieta Keōpūolani Nāhiʻenaʻena" +"Her Majesty Queen Elizabeth" +"Herr Klaus Schmidt" +"Herr Schmidt" +"His Excellency Lord Duncan" +"Hon Solo" +"Hon. Barrington P. Doe-Ray, Jr." +"Hon. Charles J. Siragusa" +"Hon. Marian W. Payson" +"Honorable Judge Susan Russ Walker" +"Honorable Judge Terry F. Moorer" +"Honorable Judge W. Harold Albritton, III" +"Honorable Terry F. Moorer" +"Honorable W. Harold Albritton, III" +"Ivanov Ivan Ivanovich" +"J. Smith" +"JEFFREY (JD) BRICKEN" +"JOHN DOE" +"JOHN DOE PHD" +"JOHN DOE PHD MD" +"JOHN SMITH" +"JOSÉ GARCÍA" +"Jack Ma" +"Jack Ma Jr" +"Jane Doctor" +"Jane Doe" +"Jane Mac Beth" +"Jane Smith" +"Jean de Mesnil" +"Jenny \"JJ\" Baker (Johnson)" +"Jenny (Johnson) Baker" +"Jenny Baker" +"Jenny Baker (Johnson)" +"Jill St. John" +"Joao da Silva do Amaral de Souza" +"Joe Dentist D.D.S." +"Joe Franklin Jr" +"John & Jane" +"John A. Doe" +"John A. Doe III" +"John A. Doe, Jr" +"John A. Doe, Jr." +"John A. Kenneth Doe" +"John A. Kenneth Doe III" +"John A. Kenneth Doe, Jr." +"John Doe" +"John Doe III" +"John Doe MD PhD" +"John Doe Msc.Ed." +"John Doe jr., MD" +"John Doe, CLU, CFP, LUTC" +"John Doe, Jr." +"John Doe, Jr.,," +"John Doe, MD - PhD - FACS" +"John Doe, MD - PhD, FACS" +"John Doe, MD, PhD" +"John Doe, MD-PhD-" +"John Doe, Msc.Ed." +"John E Smith" +"John Edgar Casey Williams III" +"John Jones (Google Docs)" +"John Jones (Google Docs), Jr. (Unknown)" +"John Jones (Unknown)" +"John King" +"John Major. Smith" +"John P. Doe, CLU, CFP, LUTC" +"John Q. Smith" +"John Smith" +"John Smith Jr" +"John Smith Ph. D." +"John Smith VI" +"John Smith, Ph. D." +"John Smith, V Jr." +"John Smith, V MD" +"John W. Ingram, V" +"John W. Smith, I" +"John Williams" +"John and Jane Aznar y Lopez" +"John and Jane Smith" +"John e Smith" +"John e Smith III" +"John e Smith, III" +"John y Jane" +"Jon Dough and" +"Jon Dough and of" +"Jose Aznar y Lopez" +"Juan Q. Velasquez y Garcia" +"Juan Q. Velasquez y Garcia III" +"Juan Q. Velasquez y Garcia, Jr." +"Juan Q. Xavier Velasquez y Garcia" +"Juan Q. Xavier Velasquez y Garcia III" +"Juan Q. Xavier Velasquez y Garcia, Jr." +"Juan Q. Xavier de la Vega" +"Juan Q. Xavier de la Vega III" +"Juan Q. Xavier de la Vega, Jr." +"Juan Q. de la Vega" +"Juan Q. de la Vega III" +"Juan Q. de la Vega, Jr." +"Juan Velasquez y Garcia" +"Juan Velasquez y Garcia III" +"Juan Velasquez y Garcia, Jr." +"Juan de la Vega" +"Juan de la Vega III" +"Juan de la Vega, Jr." +"Juan de la de la Vega" +"Juan de la de la Vega Jr." +"Juan de la de la de la Vega" +"Juan del Sur" +"Judge C Lynwood Smith, Jr" +"Judge G. Thomas Eisele" +"Judge James M. Moody" +"Kenneth Clarke Q.C." +"Kenneth Clarke Q.C., M.P." +"Kenneth Clarke QC MP" +"King Henry" +"King John Alexander V" +"King John V." +"LT. GEN. JOHN A. KENNETH DOE IV" +"La'tanya O'connor" +"Larry James Johnson I" +"Larry V I" +"Lon (Jr.) Williams" +"Lord God Almighty" +"Lord and of the Universe" +"Lord of the Universe" +"Lord of the Universe and Associate Supreme Queen of the World Lisa Simpson" +"Lord of the Universe and Supreme King of the World Lisa Simpson" +"Lt. Gen. John A. Kenneth Doe IV" +"Lt. Gen. John A. Kenneth Doe, Jr." +"Lt.Gen. John A. Kenneth Doe IV" +"Lt.Gov. John Doe" +"Lt.Gov. juan e garcia" +"Ma III, Jack Jr" +"Ma, Jack" +"Mac Miller" +"Mag-Judge Harwell G Davis, III" +"Mag. Judge Byron G. Cudmore" +"Magistrate Judge John F. Forster, Jr" +"Magistrate-Judge Elizabeth Todd Campbell" +"Maid Marion" +"Maier, Amy I, Jr." +"Maier, Amy Lauren I" +"Major. Dona Smith" +"Major. John Smith, Jr." +"Mari' Aube'" +"Mevrouw Anna de Vries" +"Mike van der Velt" +"Mohamad Ahmad Ali Hassan" +"Mohamad Ali Khalil" +"Monsieur Jean Dupont" +"Mr. & Mrs. John Smith" +"Mr. Van Nguyen" +"Mr. and Mrs. John Smith" +"Mr. and Mrs. John and Jane Smith" +"Ms Hon Solo" +"Naomi Wambui Ng'ang'a" +"Nguyen, Van" +"No1. John Smith" +"None Smith" +"Nonez Smith" +"O'B. John Smith" +"Q R" +"Queen Elizabeth" +"RONALD MACDONALD" +"RONALD MCDONALD" +"Rafael Sousa dos Anjos" +"Rev Andrews" +"Rev John A. Kenneth Doe" +"Rev John A. Kenneth Doe III (Kenny)" +"Rev. John A. Kenneth Doe" +"Rt. Hon. Paul E. Mary" +"Sam Smith 😊" +"Secretary of State Hillary Clinton" +"Senator \"Rick\" Edmonds" +"Senior Judge Charles R. Butler, Jr" +"Senior Judge Harold D. Vietor" +"Senior Judge Virgil Pittman" +"Señor Carlos García" +"Señora María García" +"Shirley Maclaine" +"Signor Marco Rossi" +"Sir Gerald" +"Smith Jr., John" +"Smith van der" +"Smith, Dr. John" +"Smith, E.T., Jr." +"Smith, J.R." +"Smith, John" +"Smith, John I" +"Smith, John V" +"Smith, John e, III, Jr" +"Smith, MD - PhD - FACS" +"Smith, Major. John" +"Smith,John" +"Sr US District Judge Richard G Kopf" +"Srta. Andréia da Silva" +"Steven Hardman, RN - CRNA" +"Te Awanui-a-Rangi Black" +"The Lord of the Universe" +"The Right Hon. the President of the Queen's Bench Division" +"The Rt Hon John Jones" +"Title First Middle Middle Last, Jr." +"U. S. Grant" +"U.S. District Judge Marc Thomas Treadwell" +"US Magistrate Judge T Michael Putnam" +"US Magistrate-Judge Elizabeth E Campbell" +"VINCENT VAN GOGH" +"Va'apu'u Vitale" +"Van Jeremy Johnson" +"Van Johnson" +"Van Nguyen" +"Vega, Juan de la" +"Velasquez y Garcia, Dr. Juan" +"Velasquez y Garcia, Dr. Juan III" +"Velasquez y Garcia, Dr. Juan Q." +"Velasquez y Garcia, Dr. Juan Q. III" +"Velasquez y Garcia, Dr. Juan Q. Xavier" +"Velasquez y Garcia, Dr. Juan Q. Xavier III" +"Velasquez y Garcia, Dr. Juan Q. Xavier, Jr." +"Velasquez y Garcia, Dr. Juan Q., Jr." +"Velasquez y Garcia, Dr. Juan, Jr." +"Velasquez y Garcia, Juan" +"Velasquez y Garcia, Juan III" +"Velasquez y Garcia, Juan Q." +"Velasquez y Garcia, Juan Q. III" +"Velasquez y Garcia, Juan Q. Xavier" +"Velasquez y Garcia, Juan Q. Xavier III" +"Velasquez y Garcia, Juan Q. Xavier, Jr." +"Velasquez y Garcia, Juan Q., Jr." +"Velasquez y Garcia, Juan, Jr." +"Vincent van Gogh" +"Vincent van Gogh van Beethoven" +"Washington Jr. MD, Franklin" +"Xyz. (Bud) Smith" +"Yin Le" +"Yin a Le" +"Zephyrmark Jane Smith" +"abdul" +"abdul salam" +"abdul salam ahmed salem" +"abdul salam ahmed salem jr" +"abdul salam jr" +"abdul salam salem" +"abdulsalam ahmed salem" +"abu bakr al baghdadi" +"ahmed abu bakr" +"and Jon Dough" +"and van Buren" +"bob v. de la macdole-eisenhower phd" +"de" +"de Mesnil" +"de Mesnil Garcia" +"de Mesnil Jr." +"de la Vega" +"de la Vega, Dr. Juan" +"de la Vega, Dr. Juan III" +"de la Vega, Dr. Juan Q." +"de la Vega, Dr. Juan Q. III" +"de la Vega, Dr. Juan Q. Xavier" +"de la Vega, Dr. Juan Q. Xavier III" +"de la Vega, Dr. Juan Q. Xavier, Jr." +"de la Vega, Dr. Juan Q., Jr." +"de la Vega, Dr. Juan, Jr." +"de la Vega, Juan" +"de la Vega, Juan III" +"de la Vega, Juan Q." +"de la Vega, Juan Q. III" +"de la Vega, Juan Q. Xavier" +"de la Vega, Juan Q. Xavier III" +"de la Vega, Juan Q. Xavier, Jr." +"de la Vega, Juan Q., Jr." +"de la Vega, Juan, Jr." +"de la Véña, Jüan" +"de la dos Vega, Dr. Juan Q. Xavier III" +"de la vega, dr. juan Q. xavier III" +"donovan mcnabb-smith" +"dos Santos" +"dr Vincent James van Gogh dr" +"dr Vincent van Gogh dr" +"dr Vincent van der Gogh dr" +"dr. ben alex johnson III" +"dr. john p. doe-Ray, CLU, CFP, LUTC" +"dr. juan de la vega jr." +"e and e" +"e j smith" +"joao da silva do amaral de souza" +"john doe" +"john e jones" +"john e jones, III" +"john e. smith" +"john smith" +"johnny y" +"jones, john e" +"juan garcia III" +"juan q. xavier velasquez y garcia iii" +"larry james edward johnson v" +"lt. gen. john a. kenneth doe iv" +"mack johnson" +"matthëus schmidt" +"part1 of The part2 of the part3 and part4" +"part1 of and The part2 of the part3 And part4" +"pennie von bergen wessels" +"pennie von bergen wessels III" +"pennie von bergen wessels M.D." +"pennie von bergen wessels MD, III" +"pennie von bergen wessels, III" +"salem, abdul" +"salem, abdul salam" +"salem, abdul salam ahmed" +"scott e. werner" +"señora María García" +"test" +"the and Jon Dough" +"vai la" +"van nguyen" +"von Braun" +"von bergen wessels MD, pennie" +"von bergen wessels MD, pennie III" +"von bergen wessels, pennie III" +"von bergen wessels, pennie MD" +"xyz. John Smith" +"سلمان،" +"سلمان، محمد" +"‏John‏ Smith" +"‏محمد بن سلمان‏" +"∫≜⩕ Smith 😊" +"∫≜⩕ Smith😊" diff --git a/tools/differential/expected_changes.toml b/tools/differential/expected_changes.toml new file mode 100644 index 00000000..6feddfe0 --- /dev/null +++ b/tools/differential/expected_changes.toml @@ -0,0 +1,72 @@ +# Every rule needs `issue`; optional `name_regex` and `fields` narrow +# it. An unexplained diff is a release blocker until classified (spec +# S5). Rules are seeded from docs/superpowers/plans/notes-m12-diffs.md +# and the `classification="fix(...)"` rows in tests/v2/cases.py; keep +# each entry's `name_regex`/`fields` as tight as the diff allows. + +[[change]] +issue = "fix(#274) maiden markers consumed" +name_regex = "(?i)\\b(n[ée]e|born|geb\\.?|roz\\.?)\\b" +fields = ["maiden", "middle", "last"] + +[[change]] +issue = "fix(comma-family) lone post-comma piece routes to suffix/title, not first" +# 'Smith, Dr.' / 'Andrews, M.D.': v1 put the lone strict-suffix-or-title +# post-comma piece in `first`; 2.0 routes it to `suffix`/`title` instead +# (family/`last` is unchanged either way -- "pre-comma is definitionally +# family"). +name_regex = "," +fields = ["first", "title", "suffix"] + +[[change]] +issue = "fix(suffix-routing) two-token name with unambiguous trailing suffix stays suffix" +# 'Johnson PhD' / 'Mr. Johnson PhD': v1 routed a lone trailing suffix +# to family/first (no comma present); 2.0 keeps recognized suffixes in +# `suffix`. +fields = ["first", "last", "suffix"] + +[[change]] +issue = "fix(suffix-delimiter-rendering) no-space delimiter core token kept whole" +# Only fires when a custom suffix delimiter is configured (Policy / +# Constants.suffix_delimiters); the corpus runs with default policy, so +# this rule is expected to match nothing here. Kept for documentation +# parity with tests/v2/cases.py's 'suffix_delimiter_no_space_core' row +# (anti-#100, migration plan deviation 5). +name_regex = "/" +fields = ["suffix"] + +[[change]] +issue = "ambiguous-surname-acronym data change: parenthesized (MA)/(DO) now stays nickname" +# suffix_acronyms_ambiguous gained 'ma'/'do' so bare 'Jack Ma' keeps its +# surname (v1 parity restored); side effect: parenthesized/quoted "MA" +# or "DO" no longer escape to suffix (v1 did, since v1 treated them as +# unambiguous there) -- they now fall through to nickname parsing. +# Not expected to fire against this corpus (no such strings survived +# into the v1 banks); kept for documentation completeness. +name_regex = "(?i)[(\"'](m\\.?a\\.?|d\\.?o\\.?)[)\"']" +fields = ["suffix", "nickname"] + +[[change]] +issue = "feat(#269) Arabic بن prefix chains onto family (non-Latin new-recognition)" +# '‏محمد بن سلمان‏': #269 adds the native-script Arabic +# patronymic particle بن ("bin"/"son of") to PREFIXES/ +# NON_FIRST_NAME_PREFIXES. v1 had no such entry, so it left بن a plain +# middle-name token ('سلمان' alone as last); 2.0 now chains it onto the +# family the same way 'von'/'bin' (Latin) do, giving family 'بن سلمان'. +# This is new-recognition on non-Latin input -- the exact behavior +# #269 exists to add -- not a Latin-corpus regression, so it is +# classified rather than reverted. +# Word-bounded: a bare "بن" would also match the substring inside e.g. +# لبنان ("Lebanon") and silently absorb unrelated middle/last diffs. +name_regex = "\\bبن\\b" +fields = ["middle", "last"] + +# Deliberately NOT a [[change]] rule: 'Ph. D.' split-token healing +# ('John Ph. D.', 'John Smith, Ph. D.') is PARITY, not a 2.0 behavior +# change -- v1's fix_phd healing of the adjacent 'Ph.'/'D.' pair into +# one suffix unit is replicated exactly by 2.0's 'joined' vocab tag +# (tests/v2/cases.py classification="parity" on both 'phd_split' and +# 'suffix_comma_split_phd'; verified empirically, zero diff against the +# 1.4 worker). Adding a suppression rule for it would risk masking a +# real regression in this exact shape, so it is intentionally left +# unclassified: if it ever starts diffing, the harness must fail. diff --git a/tools/differential/worker_v1.py b/tools/differential/worker_v1.py new file mode 100644 index 00000000..f854bd0e --- /dev/null +++ b/tools/differential/worker_v1.py @@ -0,0 +1,25 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = ["nameparser==1.4.*"] +# /// +"""v1 worker: reads JSON name strings on stdin (one per line), writes +the 1.4 component dict per line. Run ONLY via: + + uv run --no-project tools/differential/worker_v1.py + +--no-project is load-bearing: without it uv installs the working tree +and shadows the 1.4 pin from PyPI. +""" +import json +import sys + +from nameparser import HumanName + +for line in sys.stdin: + line = line.strip() + if not line: + continue + name = json.loads(line) + n = HumanName(name) + print(json.dumps({k: v or "" for k, v in n.as_dict().items()}, + ensure_ascii=False), flush=True) diff --git a/uv.lock b/uv.lock index 64369b69..7ac65e14 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,10 @@ version = 1 revision = 3 -requires-python = ">=3.10" +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.11.*'", - "python_full_version < '3.11'", + "python_full_version < '3.12'", ] [[package]] @@ -106,22 +105,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, @@ -220,20 +203,6 @@ version = "7.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/97/c52dc440c390b6cfa87be9432b141a956e2d56d9b9f5fc8bd71c5f471722/coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a", size = 220539, upload-time = "2026-07-02T13:08:19.252Z" }, - { url = "https://files.pythonhosted.org/packages/3f/26/602de8c2aec7e2e3e99ebfb8e04ba65598f746275396eea5f6794ff4673f/coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc", size = 221058, upload-time = "2026-07-02T13:08:21.013Z" }, - { url = "https://files.pythonhosted.org/packages/fc/13/ebab0743138891c1d646d61e247ec29639afcbb6c4e1905e6a0f0c75291a/coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf", size = 247797, upload-time = "2026-07-02T13:08:22.474Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b7/b6ffb9e042aa48dc4144a8a65529affaec8dca0685309353614a2a7386ad/coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628", size = 249626, upload-time = "2026-07-02T13:08:23.803Z" }, - { url = "https://files.pythonhosted.org/packages/9c/06/243ff05b652333d8e3d060c11223efc2723b19cacf6605e433fa686ab5d4/coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6", size = 251493, upload-time = "2026-07-02T13:08:25.397Z" }, - { url = "https://files.pythonhosted.org/packages/d3/2b/867faa17030a806114dae388b32a3fa929d8cd4bf39226fbc11f6e6bb705/coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91", size = 253406, upload-time = "2026-07-02T13:08:26.842Z" }, - { url = "https://files.pythonhosted.org/packages/94/c0/d789ce18f6605afc4895db75723424be2ef494282f77f61d8e5832923183/coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4", size = 248512, upload-time = "2026-07-02T13:08:28.398Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b6/b2673c30739f4a2e06649a0a38ad8b093c4d865462dc7bab0e9524a2c3b1/coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20", size = 249532, upload-time = "2026-07-02T13:08:29.731Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/acd79e9a41beabee92b623afe4f30b549916f48566271475f2907e752828/coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa", size = 247537, upload-time = "2026-07-02T13:08:31.173Z" }, - { url = "https://files.pythonhosted.org/packages/12/d4/2d301c4d1b3238d7c88b70ab9d13fd53ed9505662a7ff1b46ba1e2e4e3c3/coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1", size = 251348, upload-time = "2026-07-02T13:08:32.63Z" }, - { url = "https://files.pythonhosted.org/packages/35/bb/c67708b2bc00f32e12805ec23d5fa677a0a51652f449341a89f9d6b1b715/coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd", size = 247806, upload-time = "2026-07-02T13:08:33.931Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6c/57c4f653c47a6e917748f8938e389e72fbcae44e3643cd906664f0477a13/coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90", size = 248410, upload-time = "2026-07-02T13:08:35.189Z" }, - { url = "https://files.pythonhosted.org/packages/6c/94/bb083041aef828903668f134273f319f2bd49224962875359c52faa5497f/coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0", size = 222588, upload-time = "2026-07-02T13:08:36.486Z" }, - { url = "https://files.pythonhosted.org/packages/ef/94/a09d8ee618956f626741b0734854bac4425a00e10c0565f5abca64e7e751/coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f", size = 223214, upload-time = "2026-07-02T13:08:37.885Z" }, { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, @@ -326,44 +295,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] -[[package]] -name = "docutils" -version = "0.21.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, -] - [[package]] name = "docutils" version = "0.22.4" 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.11.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "furo" version = "2025.12.19" @@ -372,8 +312,7 @@ dependencies = [ { name = "accessible-pygments" }, { name = "beautifulsoup4" }, { name = "pygments" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-basic-ng" }, ] @@ -382,6 +321,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] +[[package]] +name = "hypothesis" +version = "6.156.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/83/8dbe89bdb8c6f25a7a52e7898af6d82fe35dfef08e5c702f6e33231ce6c6/hypothesis-6.156.6.tar.gz", hash = "sha256:96de02faefa3ce079873541da96f42595583bb001e8e4219294ed7d4501cc4cc", size = 476304, upload-time = "2026-07-10T20:56:49.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/dc/0c2a851f06c91d5ac9ef0f3b9615efc1ed650411d2eee23b6334f491c85e/hypothesis-6.156.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:caf6a93d011c10972da111c38ceb34ced20feaa8581e2b350c0655b022e27875", size = 747998, upload-time = "2026-07-10T20:56:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f8/59203ca978ab51595d12d6bc7e7a63300d7373431ab42ca3f1742e45db68/hypothesis-6.156.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:07f2bc9df1aeba80e12029c1618e2ee54abc440068c305d7075ffd6b85251843", size = 743073, upload-time = "2026-07-10T20:55:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/68/d8/86a0023740434098d1b187a62bd5f99b198f098fb43e7fc58342283a8270/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baca17f4803ad4aa151732326f3990baf54c3127df44aa872ac5bdf8a98a9a6", size = 1070169, upload-time = "2026-07-10T20:55:49.47Z" }, + { url = "https://files.pythonhosted.org/packages/9b/82/673453915fd0c67673f35a4876ba88f48c621335f293f3537d77b27d4286/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8083806645f84243aade727f4978185caaa0b7190af4318673999ee15fdbf424", size = 1121760, upload-time = "2026-07-10T20:55:53.502Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c3/3a5557f52912f2fecc6ed59642dcf80dd8e89d0d9664502b68e23d66bf3d/hypothesis-6.156.6-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a922eedcd8618f9c2e17b79fa7b3f3f0b2df34e201958611cc3f0f46cca33c10", size = 1111440, upload-time = "2026-07-10T20:55:43.054Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/ae636d4ca7f996a1ccb4b3d5997d949f1718fba52b01559b3ab53b237b3f/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5291bd33c4704d274d7c214d5c200e77f372a06644f5cbbe96dcbe53cb2fbf10", size = 1244944, upload-time = "2026-07-10T20:55:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/1e/79/c425d22d734be0268ca60d120c6296299e4220a1783cb1a4cc76232807bb/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55f3ec50161b4a95bae63bff2b5166e45935b493013d3be30ede279bf6192318", size = 1288808, upload-time = "2026-07-10T20:56:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/cc9f479d22cbdd36ddfc55a978378eddadd183b09339ebdb81be33bb18e7/hypothesis-6.156.6-cp310-abi3-win32.whl", hash = "sha256:e96570ca5cdd9a5f2ff9e80a6fb2fd5420ebf33b833d7de5b09b6ebb26a3eb6c", size = 634868, upload-time = "2026-07-10T20:55:37.959Z" }, + { url = "https://files.pythonhosted.org/packages/d6/89/2008d287289841a936456cb13443ca89d88da6e4527d611d482e9544164d/hypothesis-6.156.6-cp310-abi3-win_amd64.whl", hash = "sha256:32710718c22fe8c5571464e898bb87d282837b02617d6ad68130abf7cb4843cb", size = 640382, upload-time = "2026-07-10T20:55:30.634Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/e4a0796190d8089e85f06731e21fdddd7e8edd3a4e562101527a048e21c4/hypothesis-6.156.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5baec7943a14d106e982121dd4f74cfc5ef45e37c17f94fe49338d3d1377f38c", size = 748988, upload-time = "2026-07-10T20:56:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a2/4a789b286cd2cced31992e1f683036b51dd6909b934ea007ffb43aa3a32f/hypothesis-6.156.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b5f519905ddeb10e23b8ba2c254541a5b1a8f146fe0551be94d972f4a77226f4", size = 743754, upload-time = "2026-07-10T20:56:09.113Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f7/3dd36c1c03d24ae3ffc3c5b0eca8cc4ae90c07abc320f76509eceb37019a/hypothesis-6.156.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c108655960b58ded3ca71b2dc5c69fb2ba7e9c723aeb6106facec3892d09087", size = 1070732, upload-time = "2026-07-10T20:56:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/57/51/befc4b816b471078034a875eb1ef69e0411ab84bcce582b4be173258785a/hypothesis-6.156.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c8d37bebb6924729bc0bbf5852689df568842948abe4d93dd0ad3377adf76fa", size = 1121988, upload-time = "2026-07-10T20:56:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/b7/a796f5e3e4b7cb911ff346008d49720296d1f4073490b8bc1cce6b3fbb07/hypothesis-6.156.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:74137bef6d502305c3648b2ed1a9bb4bc05fb1025e96b30a2c092204c40fe097", size = 1245596, upload-time = "2026-07-10T20:55:50.662Z" }, + { url = "https://files.pythonhosted.org/packages/37/65/849c4cba44a6f6cc888fd931124429b24180234ccc4883abab8cad5fcfcb/hypothesis-6.156.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5e0afdf79cceed20fcf0a9fb80d4064a9b2b53d4d4eecbac0e21208a13f5a31b", size = 1289172, upload-time = "2026-07-10T20:55:58.915Z" }, + { url = "https://files.pythonhosted.org/packages/13/03/7106a110df29eb631d66776e8aa8128f82f04a9dd2b6b22b612e6025e3a2/hypothesis-6.156.6-cp311-cp311-win_amd64.whl", hash = "sha256:84dc89caaf741a02f904ca7bd02b1af99650c75552868162290208aeecb70858", size = 640222, upload-time = "2026-07-10T20:56:10.396Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/9f009005b9c796f4a40424484ac7e70847bc088456fd940a937f96bb4b6d/hypothesis-6.156.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a2a728b514fceb81e3f0464508911d5220fd74dadc3270f859427a686b60c4cf", size = 748844, upload-time = "2026-07-10T20:56:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/4d852bb8a9c73a68b18eca9b5b085285282122166e158f4d2a477639bfee/hypothesis-6.156.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7489b9a8f9df8227edd6c7cd8b9ccfab2483bab24da6a474c175973ca2294f58", size = 741936, upload-time = "2026-07-10T20:55:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/b9968070ae042f9bf3149bb6ba6399d5f28f452e0fb7f638cafc69ff0b9a/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42760873d6db1069d6edbaa355a61b9078a9950259efcfc72fc695741d7db7cd", size = 1069749, upload-time = "2026-07-10T20:56:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/00/a9/753806f5292b40aeab1d269e408e3a7e85be3c0d88828fb78ab4a34d6626/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e66aaa7385538a5d617174d47c198ee807f06de99e282a67c6cb724c69340d", size = 1120983, upload-time = "2026-07-10T20:56:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/85/88/8386d064d680be27e936eba94f1448bc93ef6fa05473ee5034139f1c4284/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:08796b674c0b31a5dd4119b2173823390055921588d13eb77324e861b00fd7f8", size = 1243911, upload-time = "2026-07-10T20:55:54.799Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8c/7524c1e5279e7728eb47c99f2357cbc5f08ae92e9bce49bf50118b53f9c9/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4ca8cc26ea2d31d22cf7710e92951cfaa921f0f8aa1b6db33a5176335f583a4f", size = 1287806, upload-time = "2026-07-10T20:56:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b3/c347ad913e1c5f2988956fe17826c0400b4ce470b973e6c248e97b6a0acf/hypothesis-6.156.6-cp312-cp312-win_amd64.whl", hash = "sha256:c3363d3fb8015594636689572510bb6090602d8e8e838a5693c2d52d3b5b09d8", size = 637679, upload-time = "2026-07-10T20:55:39.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/5d/9583fe153573523dac27226c89e041a86ad4aeeae08c868160cbb93d39d2/hypothesis-6.156.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:59a8def90d9a5a9b67e1ac529e903a2363ceb6cf873c209da6b4284c5daab671", size = 749264, upload-time = "2026-07-10T20:56:46.118Z" }, + { url = "https://files.pythonhosted.org/packages/86/35/e4113d06769b544f0fb77ffea9195b598b4c56a298905c21fd47c4eed388/hypothesis-6.156.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c574c3224563d730848bc5d1ef1683c4f83993400c0167899fe328f4bfcd4725", size = 742095, upload-time = "2026-07-10T20:56:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/a47666ede10384e8978722cade7ab96a42df71d2ab577317092d0fed7c8a/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01bb8270c46b3ef53b0c2d23ff613ea506d609d06f936d823ea57c58b66b05f7", size = 1069917, upload-time = "2026-07-10T20:56:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/79/93/75f6057dadd9dc0134f37c08d5d14d04d3cd7374debbcb0cc4569c6712f1/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4ea6559c13606e13b645927f2e0906e52b5ac5d99b40d3abaaeb2e8c7ceeb75", size = 1121204, upload-time = "2026-07-10T20:55:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/62/87/308efef08bc60d1e673d035e8ca8e9663f4b6b3ba519c3cdebf6583c2b76/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2d47054d0230f0dd9b6868fc030126c7a6c25527144272ff376cc4e9c39f7540", size = 1244168, upload-time = "2026-07-10T20:55:40.288Z" }, + { url = "https://files.pythonhosted.org/packages/3b/66/de8fff5bd9a40a4056dafbe7f904887ef12632282bbbac90f1977c30dd3b/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:050c8c0815f88d47dd0875a92698d20d61639b7b721ee043a6d687c7f14ff7d8", size = 1288127, upload-time = "2026-07-10T20:56:00.541Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/794fb26e1fd3ff004978f8f18b7aa7e1c2270ba72e1f977b987a812064f8/hypothesis-6.156.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0d73edab7b8a0051b3634f2d04d62b7e7282f8f274963b11188ee4957d672ef", size = 637954, upload-time = "2026-07-10T20:56:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/5b4b27984cb43c60e95f570b069660335dad34cb67f7d226017c5d35d31e/hypothesis-6.156.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:34a70a7b8226e34d658072d8fb81d03f97f0a75ceb536329a321b94ce2232fd6", size = 749312, upload-time = "2026-07-10T20:55:46.902Z" }, + { url = "https://files.pythonhosted.org/packages/31/11/709cceffc28666c9d4cb75ffc6df5ce30db8c7dd5cc2c8b38a2fd837427f/hypothesis-6.156.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f1969646beead7d8cf6a2537d2765af89d73056e2cb218e7fae92b83802250a3", size = 742332, upload-time = "2026-07-10T20:56:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/cfc79b13d8dd3cd6de6b9df921c557efe8528a9c90a3a7cd93b37188d57e/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbc2ec7b7d905e6b6ec1635f6340bfa52aaab718101c59f052bc012a6b486cd8", size = 1070109, upload-time = "2026-07-10T20:55:48.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/1da4def1f006b5ad01187ff96379e24c37439d659ec10c3e944c03436c0f/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9367ae25dfa6dc1af37904785e43c4f8fe1c4118cafdc2f06514154fbdd90992", size = 1121528, upload-time = "2026-07-10T20:56:39.665Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/744e4f5e3d635dea20dbedf3fa486e2a6fa5210e0a52a0d5c4da56babd84/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:455f09107ec07c78f2a83cb8fc19e23879c9d51cdc831de6f9cb6ec4059cb9af", size = 1244690, upload-time = "2026-07-10T20:56:31.854Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/42252fcd5e521d140dac532f29c2a13ca8f22908cb545ffdd64b5e225680/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c76634c45a3ceee4c4fdfed39aebd08b8b822ec8b0c556877ef82846fd777730", size = 1288519, upload-time = "2026-07-10T20:56:03.429Z" }, + { url = "https://files.pythonhosted.org/packages/44/e7/176df9e47cf583d2b8d234b78c0aac3a47075ad5d147e60b2c21a1338bb1/hypothesis-6.156.6-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:eb7e9f8343bc6b948937e6ec12e6879ed25a17b53ceccbd2b84adadd3d511698", size = 586452, upload-time = "2026-07-10T20:56:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/2c8a0411bbe76429f3ae738ef9a00107201bf6146d9534350014ce369d98/hypothesis-6.156.6-cp314-cp314-win_amd64.whl", hash = "sha256:f9631cd604ae6032c3edf99160dc1b9e33873f2e52762246b24f07fb758652ae", size = 637774, upload-time = "2026-07-10T20:56:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/2a/22/8115005e9aa72c8d63d90e9db5e0b8425fd8950fbc5d6e332805d4d32c9e/hypothesis-6.156.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1f81163d36d3763b09ffaef7c3a71e88174ca3e6816201fca9d1d159f448fdb5", size = 747428, upload-time = "2026-07-10T20:56:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c2/66bfe9337f4a4b1f7754ee6d01d950280152a81d0d797e6c1d9eb0909750/hypothesis-6.156.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:556b905767e36147918634a64356aa05d8c956576f00aee01eb351678f193908", size = 740889, upload-time = "2026-07-10T20:55:57.656Z" }, + { url = "https://files.pythonhosted.org/packages/95/3b/69f45af2d4f0950b7d1af3cdbdd800b88a6c2370331481eda79d6171fbe3/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f2604b28d16d696aaaf4954d20f907b27e54034df98e64746a20c74c319f03", size = 1069270, upload-time = "2026-07-10T20:56:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/f8/43/6b2549885da08f5e50ba34fb8e0d0a60b2f190ffd516fac220f8db5b5869/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe012ad66dbe7b8e8ddef6f6992ab1b36719ea64430c2bf1ff7135521052a15", size = 1120409, upload-time = "2026-07-10T20:55:34.551Z" }, + { url = "https://files.pythonhosted.org/packages/70/97/745c778c3eb29befa2367b1ded8437eecfbbe6932359d0f831275bc32170/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5bfa3c7b758f7278081c6bfec5f89b43c4eb075c0c9eb095323f7a9eb019b513", size = 1243111, upload-time = "2026-07-10T20:56:17.83Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/c5ec6a442dc9b822f47064bda4b6d3e739dccdd1c5bf44c9a57fb6136830/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0db1f4573800c618773622f03cb6533bb3377430ef938c9476ba10c39d22591", size = 1287262, upload-time = "2026-07-10T20:56:23.749Z" }, + { url = "https://files.pythonhosted.org/packages/11/0c/c134d61710e14b68b010215dcf6bd57d2ec05cd169dff8bfab8fefc2d410/hypothesis-6.156.6-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd0c4a7b9f809f1e23a4d15adfa9c5d99869b9afc327350a5e563350b78e48", size = 637862, upload-time = "2026-07-10T20:56:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/59/9c/b94f3a31665527b6181616b72990fcf8d6d5fa82b4187aab104ab5f548f0/hypothesis-6.156.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8893b4da90e06828846c1b100c3414a7729d047a020d854c0899ae9339df0e70", size = 749575, upload-time = "2026-07-10T20:55:29.371Z" }, + { url = "https://files.pythonhosted.org/packages/21/74/dcf695f79f526543ae5d0f8c1325508e9fe990a996c0e0853129a9a5d81d/hypothesis-6.156.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7fc0b7df9b28d028e4cc295b2ac8fbbbc22e090a23382c92fff5e37696be74f", size = 744351, upload-time = "2026-07-10T20:56:36.46Z" }, + { url = "https://files.pythonhosted.org/packages/11/d3/5bff4c55c6995a6c43f66ec8e5866b56e34f03837fd0be0e4922f3bab168/hypothesis-6.156.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38ed3178526382d392d04ad699ad7a2e53845e521a09d40f1cbbc1e1ff63ba48", size = 1070916, upload-time = "2026-07-10T20:56:19.256Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/5ed42117a69221ea118caaff933d8212039a0ac0bc15afa915635f13984c/hypothesis-6.156.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ad94e28aabf4db0d479297d43b8a2a01e7caaa9bdfccfdac7a4a3717e05b993", size = 1122625, upload-time = "2026-07-10T20:56:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/02499badc6e3f3e980941021edf5fd780c895d8d08c9015e78516340ed83/hypothesis-6.156.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:983a5cfd955994bffc7eb02976241f7a1f3c2d94dbc3389430c45858fa5c1ae0", size = 640823, upload-time = "2026-07-10T20:55:45.461Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -427,18 +427,6 @@ version = "0.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, - { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, - { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, - { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, - { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, - { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, - { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, @@ -512,17 +500,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -600,18 +577,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, - { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, - { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, - { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, - { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, - { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, - { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, @@ -662,31 +631,28 @@ wheels = [ [[package]] name = "nameparser" source = { editable = "." } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] [package.dev-dependencies] dev = [ { name = "dill" }, { name = "furo" }, + { name = "hypothesis" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, { name = "ruff" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] [package.metadata] -requires-dist = [{ name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.5.0" }] [package.metadata.requires-dev] dev = [ { name = "dill", specifier = ">=0.2.5" }, { name = "furo" }, + { name = "hypothesis" }, { name = "mypy", specifier = ">=2.1" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov", specifier = ">=6" }, @@ -737,12 +703,10 @@ version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ @@ -834,43 +798,21 @@ wheels = [ ] [[package]] -name = "soupsieve" -version = "2.8.4" +name = "sortedcontainers" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] -name = "sphinx" -version = "8.1.3" +name = "soupsieve" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] @@ -878,13 +820,13 @@ name = "sphinx" version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.11.*'", + "python_full_version < '3.12'", ] dependencies = [ { name = "alabaster" }, { name = "babel" }, { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils" }, { name = "imagesize" }, { name = "jinja2" }, { name = "packaging" }, @@ -916,7 +858,7 @@ dependencies = [ { name = "alabaster" }, { name = "babel" }, { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils" }, { name = "imagesize" }, { name = "jinja2" }, { name = "packaging" }, @@ -941,8 +883,7 @@ name = "sphinx-basic-ng" version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" }