diff --git a/nameparser/__main__.py b/nameparser/__main__.py index 4b16217b4..93073b623 100644 --- a/nameparser/__main__.py +++ b/nameparser/__main__.py @@ -53,15 +53,16 @@ "unlisted_dotted_suffixes": "read an unlisted X.Y.Z. ending a name " "as a suffix", } -_CHOICES = {"unlisted_caps_suffixes": CapsSuffixes} +# each an enum field, with the help its flag offers +_CHOICES = { + "unlisted_caps_suffixes": (CapsSuffixes, + "where an unlisted all-caps word reads as " + "a credential: nowhere, after a comma (the " + "default), or ending a name too"), +} # the two fields Policy.lone_name_word accepts, a flag of their own as # --order is, since a choice of every Role would offer the ones it refuses _LONE_NAME_WORD = {"given": Role.GIVEN, "family": Role.FAMILY} -_CHOICE_HELP = { - "unlisted_caps_suffixes": "where an unlisted all-caps word reads as a " - "credential: nowhere, after a comma (the " - "default), or ending a name too", -} _NOT_ON_THE_COMMAND_LINE = { # each needs a mapping, a set of scripts or rules, bracket pairs or # separator strings @@ -119,10 +120,10 @@ def main(argv: list[str] | None = None) -> int: rules.add_argument("--" + field.replace("_", "-"), action=argparse.BooleanOptionalAction, help=what) - for field, enum in _CHOICES.items(): + for field, (enum, what) in _CHOICES.items(): rules.add_argument("--" + field.replace("_", "-"), choices=[member.value for member in enum], - help=_CHOICE_HELP[field]) + help=what) args = ap.parse_args(argv) if args.strict and args.locale is None: ap.error("--strict needs --locale: it keeps only the chosen " @@ -184,13 +185,13 @@ def main(argv: list[str] | None = None) -> int: # so each replaces a pack's setting without a two-pack conflict # warning, and the parser is built once, parser_for re-emitting its # warnings (#337) + chosen = vars(args) updates: dict[str, Any] = { - field: getattr(args, field) for field in _SWITCHES - if getattr(args, field) is not None} + field: chosen[field] for field in _SWITCHES + if chosen[field] is not None} updates.update({ - field: enum(getattr(args, field)) - for field, enum in _CHOICES.items() - if getattr(args, field) is not None}) + field: enum(chosen[field]) for field, (enum, _) in _CHOICES.items() + if chosen[field] is not None}) if args.order is not None: updates["name_order"] = _ORDERS[args.order] if args.lone_name_word is not None: diff --git a/nameparser/_parser.py b/nameparser/_parser.py index 6333da812..ca5fcd63e 100644 --- a/nameparser/_parser.py +++ b/nameparser/_parser.py @@ -405,11 +405,11 @@ def parser_for(*locales: Locale, base: Parser | None = None, language writes alike (``Dr``, ``Prof``, ``PhD``, ``MSc``, the numerals), and a native-script word's readings (one with a letter and no Latin letter), name readings included, as a plain choice of - the same languages keeps them; every other - file's readings leave, und.toml's - among them, and with them the markers they derive. It is for single-origin data - (``parser_for(Locale(code="ko", lexicon=Lexicon.empty(), - languages=frozenset({"ko"})), strict=True)`` reads ``Kim Do Hyun`` + the same languages keeps them; every other file's readings leave, + und.toml's among them, and with them the markers they derive. It is + for single-origin data (``parser_for(Locale(code="ko", + lexicon=Lexicon.empty(), languages=frozenset({"ko"})), + strict=True)`` reads ``Kim Do Hyun`` with no Portuguese particle ``do``). A caller's own readings and the packs' fragments apply after it, as after a plain choice, and the policy patches unchanged. ``strict`` has three states, as diff --git a/nameparser/_policy.py b/nameparser/_policy.py index ce39118b7..0e92e355f 100644 --- a/nameparser/_policy.py +++ b/nameparser/_policy.py @@ -783,8 +783,8 @@ class Policy: #: ``Johnson``). The reading is reported as a ``given-or-family`` #: ambiguity either way, save a word its own reading claimed (a #: particle, a bound given name, an initial), which the switch - #: places too and which reports nothing. Added in 3.0; through 2.x the field followed - #: name_order. + #: places too and which reports nothing. Added in 3.0; through 2.x + #: the field followed name_order. lone_name_word: Role = field( default=Role.GIVEN, metadata={ADDED_IN: "3.0.0"}) diff --git a/nameparser/_reading_index.py b/nameparser/_reading_index.py index 52d525879..8eb70f5d9 100644 --- a/nameparser/_reading_index.py +++ b/nameparser/_reading_index.py @@ -264,8 +264,8 @@ def readings_of(text: str) -> tuple[IndexReading, ...]: #: choose. A plain choice never removes a reading of either (choose); a #: strict one, which drops every file not chosen, keeps mul.toml's all #: the same, as it keeps native-script words' readings, and drops -#: und.toml's -#: (:func:`strict_pool`; decisions/vocabulary-model, 2026-11-02 #1005). +#: und.toml's (:func:`strict_pool`; decisions/vocabulary-model, +#: 2026-11-02 #1005). UNCHOOSABLE = frozenset({"mul", "und"}) diff --git a/nameparser/_types.py b/nameparser/_types.py index 1e8216082..fcfdf291d 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -401,11 +401,12 @@ class Token: #: the field it landed in (always this token's :attr:`role`), and #: why -- a listed word, a declared name, a shape, or position #: alone -- and the shipped files whose reading of it is on in the - #: parse's lexicon (:attr:`Reading.languages`). The twin of :attr:`Ambiguity.alternative`, which holds - #: the reading NOT taken. None for a token the parse did not read: - #: one :meth:`ParsedName.replace` or :meth:`Parser.revise` builds, - #: or one loaded from a pickle written before 3.0. Not compared: - #: two tokens equal without it, as before 3.0, and + #: parse's lexicon (:attr:`Reading.languages`). The twin of + #: :attr:`Ambiguity.alternative`, which holds the reading NOT taken. + #: None for a token the parse did not read: one + #: :meth:`ParsedName.replace` or :meth:`Parser.revise` builds, or one + #: loaded from a pickle written before 3.0. Not compared: two tokens + #: equal without it, as before 3.0, and #: :meth:`ParsedName.comparison_key` ignores it. Added in 3.0. reading: Reading | None = field( default=None, compare=False, metadata={ADDED_IN: "3.0.0"}) @@ -1389,18 +1390,19 @@ def _text_for(self, *roles: Role, tag: str | None = None, # view's ", " join does not split one credential in two if suffix_join and "joined" in tok.tags and parts: parts[-1] += " " + tok.text - # rules/R1: "words the writer wrote together join with - # nothing between them" -- the two halves of any word a - # division cut (rules/W1, W5) that land in one field; the - # suffix keeps its entries apart (decisions/W5, #845) - elif (not suffix_join and parts and tok.span is not None - and tok.span.start == end - and FOLDED_TAG not in tok.tags): - 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) + # rules/R1: "words the writer wrote together join with + # nothing between them" -- the two halves of any word a + # division cut (rules/W1, W5) that land in one field; the + # suffix keeps its entries apart (decisions/W5, #845). `end` + # is -1 unless the last word kept went to `parts`, so a match + # always has a `parts[-1]` to join + elif (not suffix_join and tok.span is not None + and tok.span.start == end): + parts[-1] += tok.text else: parts.append(tok.text) # a folded word went to `folded`, not to the end of `parts` @@ -1411,20 +1413,23 @@ def _text_for(self, *roles: Role, tag: str | None = None, @property def title(self) -> str: """The words read as titles ("Dr.", "Sir"), wherever in the - name they stand, space-joined in written order, save words written with nothing between them, which stay so; ``''`` for a + name they stand, space-joined in written order, save words + written with nothing between them, which stay so; ``''`` for a name with none.""" return self._text_for(Role.TITLE) @property def given(self) -> str: - """The given name's words, space-joined in written order, save words written with nothing between them, which stay so; - ``''`` for a name with none ("de la Vega").""" + """The given name's words, space-joined in written order, save + words written with nothing between them, which stay so; ``''`` + for a name with none ("de la Vega").""" return self._text_for(Role.GIVEN) @property def middle(self) -> str: - """The middle names' words, space-joined in written order, save words written with nothing between them, which stay so; - ``''`` for a name with none.""" + """The middle names' words, space-joined in written order, save + words written with nothing between them, which stay so; ``''`` + for a name with none.""" return self._text_for(Role.MIDDLE) @property @@ -1496,7 +1501,8 @@ def family_base(self) -> str: def surnames(self) -> str: """The middle names and the family name together ("Q. Xavier de la Vega" in "Juan Q. Xavier de la Vega"), space-joined in - written order, save words written with nothing between them, which stay so; ``''`` for a name with neither ("Williams").""" + written order, save words written with nothing between them, + which stay so; ``''`` for a name with neither ("Williams").""" return self._text_for(Role.MIDDLE, Role.FAMILY) @property @@ -1529,8 +1535,8 @@ def honorifics(self) -> str: def given_names(self) -> str: """The given name and the middle names together ("Juan Q. Xavier" in "Juan Q. Xavier de la Vega"), space-joined in - written order, save words written with nothing between them, which stay so; ``''`` for a name with neither ("de la - Vega").""" + written order, save words written with nothing between them, + which stay so; ``''`` for a name with neither ("de la Vega").""" return self._text_for(Role.GIVEN, Role.MIDDLE) @property @@ -1590,9 +1596,9 @@ def surname2(self) -> str: # makes the first-surname view" (history: decisions/R11, #1022) def _first_unit(self) -> bool: """Whether the parse marked a first unit: where O6 read two - units, W5 divided a Han surname pair or O3 folded middles in. A family whose words carry no - reading (replace(), Parser.revise, a 2.x pickle) is unmarked - and reads whole.""" + units, W5 divided a Han surname pair or O3 folded middles in. A + family whose words carry no reading (replace(), Parser.revise, + a 2.x pickle) is unmarked and reads whole.""" return any(tok.role is Role.FAMILY and tok.reading is not None and tok.reading.first_surname for tok in self.tokens) @@ -1601,11 +1607,12 @@ def surname1(self) -> str: """The family's first name unit where the parse read the family as two (``García`` in ``Gabriel García Márquez`` under ``two_family_names``, ``de la Vega`` in ``Juan de la Vega del - Valle`` under it too), :attr:`surname2` being the other; elsewhere the whole - :attr:`family`. A middle name ``middle_as_family`` folds into - the family is in neither (``María García`` in ``Ana María García - López`` under ``middle_as_family``, whose surname1 is - ``López``). Added in 3.0.""" + Valle`` under it too), :attr:`surname2` being the other; + elsewhere the whole :attr:`family`. A middle name + ``middle_as_family`` folds into the family is in neither + (``María García`` in ``Ana María García López`` under + ``middle_as_family``, whose surname1 is ``López``). Added in + 3.0.""" return self._text_for(Role.FAMILY, first_unit=self._first_unit()) @property @@ -1811,9 +1818,10 @@ def initials(self, spec: str = "{given} {middle} {family}", each initial is followed by `delimiter`, and the initials of one group are joined by `separator` (so ``initials(spec="{given}{middle}{family}", separator="")`` - runs them together). Each group is ordered the way its own field is ordered, so a - name whose middle words fold into the family initials them - before the family's own words, as `family` renders them. + runs them together). Each group is ordered the way its own + field is ordered, so a name whose middle words fold into the + family initials them before the family's own words, as + `family` renders them. Valid spec keys: given, middle, family.""" import nameparser._render as _render return _render.initials(self, spec, delimiter, separator) diff --git a/nameparser/locales/vi.py b/nameparser/locales/vi.py index 46da7d628..0650d713f 100644 --- a/nameparser/locales/vi.py +++ b/nameparser/locales/vi.py @@ -31,7 +31,7 @@ decisions/vocabulary-model. Declared deviations (mechanisms.md#LOCALE-PACKS-PURE-DATA): the order -moves every name of two name words or more that a family comma does +can move any name of two name words or more that a family comma does not fix (a one-word name reads as ``Policy.lone_name_word`` names under every order, given by default, rules/O5), and the fragment every name holding one of its syllables, and the choice's titles and the @@ -74,7 +74,8 @@ 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): any name holding a letter or a - digit: the order moves every longer name a family comma does not fix, and - the choice's titles and the fragment's credentials reach comma names - too, so no narrower predicate is worth stating by hand.""" + digit, since the order can move any name of two name words or more + that a family comma does not fix, and the choice's titles and the + fragment's credentials reach comma names too, so no narrower + predicate is worth stating by hand.""" return any(c.isalnum() for c in name) diff --git a/tests/v2/test_cli.py b/tests/v2/test_cli.py index 8b36c52b8..05155b8da 100644 --- a/tests/v2/test_cli.py +++ b/tests/v2/test_cli.py @@ -198,7 +198,7 @@ def test_cli_switch_tables_name_fields_of_their_kind() -> None: assert cli._SWITCHES for field in cli._SWITCHES: assert isinstance(getattr(defaults, field), bool), field - for field, enum in cli._CHOICES.items(): + for field, (enum, _) in cli._CHOICES.items(): assert isinstance(getattr(defaults, field), enum), field