Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ else does.
This is the whole parser in two sentences, and it explains its
character. A word nameparser has never seen still gets a sensible role,
because the positional layer does not need to recognize anything. The
same word can play different parts in different places — ``Dr.`` is a
title before a name and a suffix after it, which is why the field names
``title`` and ``suffix`` are really "pre-nominal" and "post-nominal".
same word can play different parts in different places — ``Lt.`` is a
title before a name and a post-nominal after it, which is why the field
names ``title`` and ``suffix`` are really "pre-nominal" and
"post-nominal".
And nothing is statistical: there is no model and no training data, so
the same input always parses the same way, and a parse that is wrong is
wrong reproducibly, which is what makes it fixable by configuration.
Expand Down
20 changes: 20 additions & 0 deletions docs/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@ it matches and the word matches everywhere else. No warning is raised
for a multi-word entry in either of these two fields, since there it is
not a mistake.

The limit is on *storage*, not on the shape a name can have. Adjacent
suffix words are reassembled after they match, so a multi-word
credential is reachable as its component words even though the phrase
itself cannot be stored:

.. doctest::

>>> from nameparser import parse
>>> parse("John Smith, MD PhD").suffix
'MD PhD'

That has held since 1.4.0. A credential whose words are not in the
default vocabulary is reached by adding those words, not the phrase:

.. doctest::

>>> lex = Lexicon.default().add(suffix_acronyms={"leed", "ap"})
>>> Parser(lexicon=lex).parse("John Smith, LEED AP").suffix
'LEED AP'

Removing works the same way, and drops the word from recognition:

.. doctest::
Expand Down
2 changes: 1 addition & 1 deletion docs/design/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ P6. Rationale: a particle ending the name has nothing to link

## Suffixes: generational & credentials (S)

Background: what follows a name is one of two different things — generational suffixes (Jr., III), which attach to the name itself, and credentials (PhD, MD, MBA), which are earned attachments. The suffix sets match one written word at a time: a multi-word entry there can never match anything and is warned about at configuration. Only two sets are exempt from that rule -- given_name_titles and, since #434, maiden_markers -- and neither is a suffix set, so within the suffix vocabulary the one-word rule is absolute. The eight multi-word entries that shipped dead for years span the suffix sets and the titles alike and are the Excluded story in decisions.md. CLDR personNames keeps them as separate fields (`generation`, `credentials`) and formats them differently; this library currently reports both in one `suffix` field, a merge #326 examines. The vocabulary is largely split already: a generational word list and a credential acronym list, plus a short list of acronyms that are also ordinary names (MA, BA) and so are AMBIGUOUS as bare words.
Background: what follows a name is one of two different things — generational suffixes (Jr., III), which attach to the name itself, and credentials (PhD, MD, MBA), which are earned attachments. The suffix sets match one written word at a time: a multi-word entry there can never match anything and is warned about at configuration. Only two sets are exempt from that rule -- given_name_titles and, since #434, maiden_markers -- and neither is a suffix set, so within the suffix vocabulary the one-word rule is absolute. That limit is on STORAGE, not on the shape a name may have: adjacent suffix tokens are reassembled after matching (`_vocab.is_wholly_suffix`), so a multi-word credential is reachable as its component words -- `John Smith, MD PhD` has read suffix `MD PhD` since 1.4.0 -- and a caller reaches an unshipped one by adding the words it is made of rather than the phrase (#433). The eight multi-word entries that shipped dead for years span the suffix sets and the titles alike and are the Excluded story in decisions.md. CLDR personNames keeps them as separate fields (`generation`, `credentials`) and formats them differently; this library currently reports both in one `suffix` field, a merge #326 examines. The vocabulary is largely split already: a generational word list and a credential acronym list, plus a short list of acronyms that are also ordinary names (MA, BA) and so are AMBIGUOUS as bare words.

S1. Rationale: brackets set off more than nicknames — credentials
are routinely written parenthesized after a name, and a
Expand Down
302 changes: 150 additions & 152 deletions docs/release_log.rst

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,8 @@ discard the only one there is.
So ``田中さん, V.`` and ``田中さん, Ph. D.`` give さん up exactly as
``田中さん, PhD`` does. Where the credential itself lands is the
comma's business rather than the peel's, and still differs by
spelling: ``title`` for ``PhD``, ``given`` for ``V.``, and ``Ph. D.``
in ``suffix`` beside さん.
spelling: ``given`` for ``V.``, while ``PhD`` and ``Ph. D.`` join さん
in ``suffix``.

Spacing, and where the name divides
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand Down
8 changes: 5 additions & 3 deletions nameparser/_pipeline/_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
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), Lexicon.given_name_titles (the
P5 licence, #369), and Policy.extra_suffix_delimiters, whose
delimiter-core tokens tail segments drop (v1 suffix_delimiter parity)
-- no other Policy field. The v1 "derived titles/prefixes"
P5 licence, #369), Policy.extra_suffix_delimiters, whose
delimiter-core tokens tail segments drop (v1 suffix_delimiter parity),
and Policy.lenient_comma_suffixes, which segment_suffix_reading takes
to pick the strict or lenient token test (#430/#432) -- 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).

Expand Down
10 changes: 9 additions & 1 deletion nameparser/config/_deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,15 @@ def __dir__() -> list[str]:
# of tab completion and every getattr-free member scan --
# autodoc's included. Pinned by test_config_aliases
# ::test_dir_lists_the_live_names_as_well_as_the_retired_ones.
return sorted(set(vars(sys.modules[module])) | set(aliases))
#
# .get, not [...]: a module dropped from sys.modules -- a test
# that reloads the package, a plugin teardown -- would otherwise
# make dir() raise KeyError, which is not among the things dir()
# may do to a caller. The aliases are held in the closure and
# are still nameable, so they are what is left to list (#356).
live = sys.modules.get(module)
names = set(vars(live)) if live is not None else set()
return sorted(names | set(aliases))

# The table itself, reachable without tripping a warning. __all__ is
# hand-written per module (it must stay in SOURCE order for autodoc,
Expand Down
6 changes: 6 additions & 0 deletions nameparser/config/bound_given_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,9 @@


assert_normalized("BOUND_GIVEN_NAMES", BOUND_GIVEN_NAMES)

# Star imports read __all__ and never the module __getattr__ -- see the
# note in prefixes.py. Without it `assert_normalized`, imported only for
# the invariant above, is bound by a star import as though it were
# vocabulary (#356).
__all__ = ["BOUND_GIVEN_NAMES"]
11 changes: 11 additions & 0 deletions nameparser/config/particles.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,14 @@
assert not (NON_GIVEN_NAME_PARTICLES & BOUND_GIVEN_NAMES), \
"NON_GIVEN_NAME_PARTICLES must stay disjoint from BOUND_GIVEN_NAMES"
assert_normalized("PARTICLES", PARTICLES)

# Star imports read __all__ and never the module __getattr__ -- see the
# note in prefixes.py. This module has no retired name of its own to
# serve; what __all__ buys here is the other half of that note's point,
# which the shim modules got in #354 and the destinations did not:
# without it a star import binds every module-level name, so
# `assert_normalized` and BOUND_GIVEN_NAMES -- imported only for the
# invariants below, and owned by another module -- landed in the
# caller's namespace as though they were vocabulary (#356).
# Source order, not alphabetical -- see the note in suffixes.py.
__all__ = ["NON_GIVEN_NAME_PARTICLES", "PARTICLES"]
20 changes: 20 additions & 0 deletions tests/test_capitalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,26 @@ def test_a_conjunction_inside_a_longer_token_is_a_name_word(self) -> None:
uppered.capitalize()
self.m(str(uppered), 'Juan E-F Smith', uppered)

# The same shape on a real name, which is what the release note
# cites: Ortega y Gasset is routinely hyphenated in catalogues, and
# `y` is conjunction vocabulary. Before #458 the two spellings
# repaired to 'Jose Ortega-y-Gasset' and 'Jose Ortega-Y-Gasset'
# (measured on the pre-#458 tree). The SPACED form is the contrast
# and is untouched -- there `y` is a token of its own and IS the
# conjunction, so it keeps the lowercase Spanish convention.
def test_a_hyphenated_compound_surname_capitalizes_its_conjunction(
self,
) -> None:
lowered = HumanName('jose ortega-y-gasset')
lowered.capitalize(force=True)
self.m(str(lowered), 'Jose Ortega-Y-Gasset', lowered)
uppered = HumanName('JOSE ORTEGA-Y-GASSET')
uppered.capitalize()
self.m(str(uppered), 'Jose Ortega-Y-Gasset', uppered)
spaced = HumanName('jose ortega y gasset')
spaced.capitalize(force=True)
self.m(str(spaced), 'Jose Ortega y Gasset', spaced)

# The third producer of never-classified text, and the one that is
# not an assignment: __getstate__ pickles the *_list STRINGS and
# nothing else (mechanisms.md#FACADE-CONTRACT -- components come
Expand Down
Loading