From afde56094a92c29a3e7a8f7379291bf146c8a290 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 5 Aug 2026 19:46:58 -0700 Subject: [PATCH] Show the spellings that type-check The package ships py.typed, and every Policy configuration example in the guide needed a # type: ignore[arg-type]. Five spellings failed, not the two first noticed: script_orders={}, segment_scripts=(), nickname_delimiters={...}, maiden_delimiters={...} and the dict(DEFAULT_SCRIPT_ORDERS) | {...} override. A bare SET literal for a delimiter field is the one most likely to bite, being the spelling anyone reaches for first. The cause is not sloppiness and is not fixed here: a dataclass field annotation types both the __init__ parameter and the attribute, and Policy normalizes in __post_init__, so those two types genuinely differ. The annotation stays the storage type, because reading the attribute is the commoner operation and a union there would cascade to every reader -- the same reasoning that already kept empty_attribute_default narrow. What changes is which spelling the docs teach. Each one has a type-clean equivalent that means exactly the same thing, verified under mypy. Union spellings are untouched: frozenset | set is already frozenset, so DEFAULT_NICKNAME_DELIMITERS | {...} was always clean. The script_orders override becomes (*DEFAULT_SCRIPT_ORDERS, (Script.HAN, GIVEN_FIRST)), which depends on a later entry replacing an earlier one. That was emergent from _validated_script_orders building a dict before sorting rather than a stated contract, so prose telling readers to append needed it pinned: a test now asserts the override, the survival of the other entries, one entry per script, and equality with the mapping spelling it replaces. The note in customize.rst said the opposite of what is true now; it explains the storage-vs-constructor split instead. --- docs/concepts.rst | 3 ++- docs/customize.rst | 31 ++++++++++++++++++++----------- docs/migrate.rst | 2 +- docs/modules.rst | 7 ++++--- tests/v2/test_policy.py | 25 +++++++++++++++++++++++++ 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/docs/concepts.rst b/docs/concepts.rst index 0da1d14c..566fc672 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -104,7 +104,8 @@ reading is a fact about the domain the names come from: that makes it a square brackets — ``John [Johnny] Smith`` — instead of quotes is a fact about that one data source's export format, not about the language of the names in it — that's a -:class:`~nameparser.Policy` (``nickname_delimiters={('[', ']')}``). +:class:`~nameparser.Policy` +(``nickname_delimiters=frozenset({('[', ']')})``). 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 diff --git a/docs/customize.rst b/docs/customize.rst index 6a9a7b4a..a34341b3 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -335,10 +335,10 @@ this section is how to switch them off, which you can do separately: >>> parse("김민준").family # both defaults on '김' - >>> positional = Parser(policy=Policy(script_orders={})) + >>> positional = Parser(policy=Policy(script_orders=())) >>> positional.parse("김민준").family # still split '민준' - >>> unsplit = Parser(policy=Policy(segment_scripts=())) + >>> unsplit = Parser(policy=Policy(segment_scripts=frozenset())) >>> unsplit.parse("김민준").family # one token, not split '김민준' @@ -363,8 +363,9 @@ because splitting Han text requires knowing Chinese from Japanese; :doc:`locales` covers the opt-in ``zh`` pack that supplies them. The Japanese behaviors ride these same two fields, so they need no -switches of their own: ``script_orders={}`` clears the kana-licensed -entry along with the Han and Hangul ones, and ``segment_scripts=()`` +switches of their own: ``script_orders=()`` clears the kana-licensed +entry along with the Han and Hangul ones, and +``segment_scripts=frozenset()`` deactivates every script at once, which also stops a parser consulting whatever segmenter it was given. The segmenter has an off-switch as well — ``Parser(segmenter=None)``, which is the default; see @@ -389,13 +390,21 @@ off. .. note:: - Both fields are annotated with their canonical *storage* type + Every field here is annotated with its canonical *storage* type rather than with everything the constructor accepts — the same as - ``capitalization_exceptions``. Under mypy the readable spellings - above (``script_orders={...}``, ``segment_scripts=(...)``) need a - ``# type: ignore[arg-type]``; ``script_orders=()`` and - ``segment_scripts=frozenset(...)`` check clean and mean the same - thing. + ``capitalization_exceptions``, and for the same reason: the + annotation is what you get back when you READ the attribute, which + is the commoner operation. + + The constructor is deliberately wider. It takes any mapping for + ``script_orders``, any iterable of ``Script`` for + ``segment_scripts``, and plain strings wherever a ``Role`` is + wanted (``Role`` is a ``StrEnum`` precisely so that works). A + dataclass cannot express those two types separately, so the + examples in this guide use the spellings that check clean under + mypy — ``()`` and ``frozenset(...)`` rather than ``{}`` and a bare + set literal. The wider spellings parse identically; they just need + a ``# type: ignore[arg-type]`` if you run a type checker. Nicknames, maiden names, and brackets ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -407,7 +416,7 @@ one-liner is the whole recipe: .. doctest:: - >>> policy = Policy(maiden_delimiters={("(", ")")}) + >>> policy = Policy(maiden_delimiters=frozenset({("(", ")")})) >>> Parser(policy=policy).parse("Jane (Jones) Smith").maiden 'Jones' diff --git a/docs/migrate.rst b/docs/migrate.rst index 5474858e..b24f13b5 100644 --- a/docs/migrate.rst +++ b/docs/migrate.rst @@ -427,7 +427,7 @@ now. ``Constants`` has no switch for any of this — the v1 configuration surface is frozen for 2.x — so the way out is the 2.0 API: -``Parser(policy=Policy(script_orders={}, segment_scripts=()))`` +``Parser(policy=Policy(script_orders=(), segment_scripts=frozenset()))`` restores 1.4's reading of every shape above that turns on order or splitting. The middle dots are the exception: both the katakana dot and the Chinese interpunct ``·`` (U+00B7, dividing a transcription diff --git a/docs/modules.rst b/docs/modules.rst index 41755f32..b19b918d 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -140,9 +140,10 @@ because only these three orders have defined assignment semantics. the names no entry matches. The values are drawn from the same three constants above, and the same restriction applies. Build on it for additive customization — - ``script_orders=dict(DEFAULT_SCRIPT_ORDERS) | {Script.HAN: - GIVEN_FIRST}`` — and pass ``script_orders={}`` to opt out entirely - and get the purely positional read back. Latin-script and + ``script_orders=(*DEFAULT_SCRIPT_ORDERS, (Script.HAN, GIVEN_FIRST))``, + where a later entry for a script REPLACES an earlier one, so + appending is how you override — and pass ``script_orders=()`` to + opt out entirely and get the purely positional read back. Latin-script and mixed-script names are never affected either way. Delimiter defaults diff --git a/tests/v2/test_policy.py b/tests/v2/test_policy.py index b37aef67..a0c8ec65 100644 --- a/tests/v2/test_policy.py +++ b/tests/v2/test_policy.py @@ -565,6 +565,31 @@ def test_script_orders_default_and_canonical_storage() -> None: assert Policy(script_orders={}).script_orders == () # type: ignore[arg-type] +def test_a_later_script_orders_entry_replaces_an_earlier_one() -> None: + """Appending is how you override a single script, and the docs now + say so (modules.rst, the script_orders entry). + + The behavior falls out of _validated_script_orders building a dict + before sorting, so it was emergent rather than contracted -- and + prose that tells readers to append is prose that depends on it. + The type-clean spelling for "everything as shipped, but Han reads + given-first" is exactly this, which is why it is worth pinning. + """ + overridden = Policy( + script_orders=(*DEFAULT_SCRIPT_ORDERS, (Script.HAN, GIVEN_FIRST))) + assert dict(overridden.script_orders)[Script.HAN] == GIVEN_FIRST + # the other shipped entries survive untouched + assert dict(overridden.script_orders)[Script.HANGUL] == FAMILY_FIRST + assert dict(overridden.script_orders)[Script.HIRAGANA] == FAMILY_FIRST + # one entry per script, whatever the input carried + keys = [s for s, _ in overridden.script_orders] + assert len(keys) == len(set(keys)) == len(DEFAULT_SCRIPT_ORDERS) + # and the mapping spelling it replaces still means the same thing + assert overridden == Policy( + script_orders=dict(DEFAULT_SCRIPT_ORDERS) # type: ignore[arg-type] + | {Script.HAN: GIVEN_FIRST}) + + def test_script_orders_validates_keys_and_values() -> None: with pytest.raises(ValueError, match="han, hangul"): Policy(script_orders={"klingon": FAMILY_FIRST}) # type: ignore[arg-type]