diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index a9a34f12a..a0aec9782 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -54,10 +54,15 @@ jobs: run: uv run mypy - name: Run Tests run: | + # Two passes: the suite in parallel (pyproject's addopts), which + # deselects the clock-timed `serial` tests, then those alone, since + # other workers' load fails their timing ratios (tests/conftest.py). if [ "$COVERAGE_ON" = "true" ]; then - uv run pytest --cov=nameparser --cov-report=xml + uv run pytest --cov=nameparser --cov-report= + uv run pytest -n0 -m serial --cov=nameparser --cov-append --cov-report=xml else uv run pytest + uv run pytest -n0 -m serial fi uv run --with build python -m build --sdist uv run --with twine twine check dist/* @@ -128,3 +133,4 @@ jobs: run: | uv run python -c "import namedivider" uv run pytest tests/v2/ -q + uv run pytest tests/v2/ -q -n0 -m serial diff --git a/AGENTS.md b/AGENTS.md index d07287dc9..d55f7d011 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,9 +50,12 @@ Three committed contributor docs carry the parser's normative rules and their re # Preferred: use uv run (works without activating the venv) # Alternative: .venv/bin/ if the venv is already active -# Run all tests (includes --doctest-modules, so doctests in nameparser/ are also run; -# the v1 tests' dual-parametrize fixture runs each of them twice) -uv run pytest # --doctest-modules is set in pyproject.toml, so doctests run automatically +# Run all tests (includes --doctest-modules, so doctests in nameparser/ are also run). +# Parallel by default (pytest-xdist, `-n auto --dist worksteal` in pyproject's addopts), +# which DESELECTS the clock-timed `serial` tests (tests/v2/test_benchmark.py) -- run +# them in the second command. `-n0` runs everything serially in one process. +uv run pytest +uv run pytest -n0 -m serial # Run a single test file / class / method uv run pytest tests/test_python_api.py @@ -279,6 +282,8 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ ## Gotchas +**The suite runs in parallel, so a test that passes only after another one has warmed a cache fails at random** — `--dist worksteal` hands each worker a different slice of the tests on every run, splitting modules too. Three `tests/v2/test_vocab.py` tests repointed `tools/vocab/build.py`'s `ROOT` at a fixture tree while its lazily loaded `_lexicon.py` was still read from `ROOT`, and passed for as long as the suite ran serially, because an earlier test always loaded it first; each failed when run alone. Run a new test on its own (`uv run pytest -n0 path::test`) before trusting it, and bind a path at import, as `build.py`'s `VOCAB`, `OUTPUT` and `_LEXICON_PATH` are, when tests repoint the root it is derived from. A `functools.cache` is per process too, so tests sharing a grid walk (below) take the walk once per worker that runs one of them. + **Code branching on an optional extra takes the untested branch only in CI** — `namedivider` sits in the local `.venv` from earlier work but is absent from every build job, so `if _JA_AVAILABLE else None` runs the PRESENT path locally and the ABSENT path in CI. That broke #337 after a locally-green suite. Before pushing anything that touches the ja extra, run the suite without it: `uv pip uninstall namedivider-python && uv run pytest -q`, then reinstall. **A local run on 3.11 cannot see a deprecation newer interpreters raise** — `.python-version` pins 3.11, pytest's `filterwarnings = ["error"]` turns every warning into a failure, and CI runs 3.11 through 3.15, so a stdlib call deprecated in a later release passes here and fails the newer jobs. #795 passed `maxsplit` to `re.split` positionally (deprecated in 3.13) and failed four jobs while 3.11 and 3.12 passed. Before pushing new code, run it on the newest interpreter CI uses too: `uv run --python 3.15 pytest -q` (uv fetches it; the next plain `uv run` rebuilds the 3.11 venv). Pass `maxsplit`, `count` and `flags` to `re.split`/`re.sub` by keyword. @@ -309,7 +314,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **`_normalize` must reach a fixed point** — storage and match-time share the one fold, and `Lexicon.__setstate__` re-validates, so a value that changes on re-normalization changes under its owner. `strip().strip(".")` alone is not idempotent (`'. a .'` → `' a '` → `'a'`). The loop is the fix; keep any new stripping inside it. **Anything built on `_normalize` must converge too** — `_fold_words` runs `_normalize` per word and DROPS the words that fold away (`_title_key` is that list space-joined, and `_run_addresses_by_given` reads the list itself, so its last-word arm is the last word of the FOLDED key by construction); keeping the empty slot stored `'lt .'` as `'lt '`, a key match-time can never rebuild (so the entry is silently inert) and `__setstate__` rejects on the next round-trip as "not written by this version". -**Perf regressions are caught by the scaling test, not the absolute-time ones** — `tests/v2/test_benchmark.py::test_parse_cost_grows_no_worse_than_linearly` times a repeated unit at n vs 4n over a table of shapes (one per pipeline inner loop) and bounds the ratio; the `_thousand_names` tests use constant-size, delimiter-free input and are structurally blind to a complexity regression. Two rules when touching it: calibrate `_MAX_RATIO` against the WEAKEST quadratic's signal (a mixed quadratic surfaces far below the textbook 16×, so the operating point `_BASE` matters more than the bound), and confirm a planted regression fails it across REPEATED runs — one failure is a coin-flip on a timing test. The shapes cover different dimensions (segment count only via `commas`, intra-piece accumulation only via `particles`/`conjunctions`, non-ASCII input only via `honorifics` — every other unit is pure ASCII, so `script_segment` returns at its bail and the CJK stages go unmeasured, M2's clause view only via `maiden_clause`, the one unit holding a marker (until #601 its order mattered, `MA nee ` reaching nothing; since #601 either order reaches the take) — and connective RUN LENGTH only via `link_run`, whose unit has to be MIXED CASE and hold a connective of the generational class: `i und ` reads the letter as an initial and reaches nothing where `i Und ` reaches everything, and S7's anchor pass only via `credential_run`, whose unit has to hold a Title-case member behind an unambiguous credential: `PhD MA ` reads the same fields and never asks the pass, the capitals deciding each member first); measure before pruning one. **A shape whose input needs a PREFIX cannot be a `_SHAPES` row at all**, since that table repeats a unit and nothing else — a maiden clause needs a name word and a marker before the run it is about, and `"nee i Und " * n` reaches the clause rule not at all, measuring the identical ratio on a broken tree and a fixed one. That one is `test_a_clause_link_run_does_not_cost_quadratically`, which builds its own input and counts FRAMES. A prefixed shape whose cost is Python-level joins `_REREAD_SHAPES` beside it — (input builder, the one function the defect re-runs, reachability probe) rows counted in that function's frames at 8 units against 32 — which holds #558's trailing fixed point, its maiden-clause twin, and #559's particle chain, a shape that grows at both ends and so has no unit to repeat. A prefixed shape whose cost is C-level work — a list scanned by `in`, a tuple re-hashed as a dict key — emits no frame for that count to see, so it goes in `_PREFIXED_SHAPES` instead: (prefix, unit, reachability probe) rows timed on the clock like `_SHAPES`, at a base of their own recorded beside the table (#553, whose run shape read 5.97× once at base 800 on the pre-fix tree, under the bound; py3.11, 2026-09-28). **That table skips under a line tracer** (`sys.settrace`, coverage's core below py3.14), which slows every Python line and not the C-level scan, diluting the quadratic below the bound (5.46–5.52× broken at base 1600 under `coverage run` on py3.11, same date). The `sys.monitoring` core coverage uses from 3.14 does not dilute it (8.87–10.91× broken), and CI collects coverage in its 3.14 build job alone, so every CI job runs the table; `ja-extra` also sets `NAMEPARSER_REQUIRE_CLOCK_GUARDS` so that a line tracer there fails the rows instead of skipping them. **Coverage is collected on ONE interpreter, the one that uploads it** (`COVERAGE_ON` in `.github/workflows/python-package.yml`): the package has no version-conditional code, so one report stands for all of them, and under a line tracer the property grids in `tests/v2/test_properties.py` had cost the 3.12 job most of its ~12 minutes (measured 2026-09-29: the three #397 grids take 139s under coverage's settrace core on 3.12, 24s under its sys.monitoring core, 17s with none). **A shape the CLOCK cannot reach needs a FRAME-count guard instead**, which is the second scaling test in that file (`test_a_trailing_credential_run_does_not_cost_exponentially`, #531): where the defect is an exponential rather than a quadratic, the input length that separates the curves on a timing test does not finish, so the guard counts frames over 8 units against 16 and bounds THAT ratio. One pair does not see every curve, and the fix round for #531 measured why: at 2× the input the per-member LINEAR work swamps a quadratic (2.08× for a genuine one against 1.73× clean), so that pair guards the exponential alone and a second, longer pair — 16 against 64, where the same quadratic reads 7.42× against 3.53× clean — is what can see one. Assert them in that order: an exponential never returns from the longer run, so the cheap pair has to have failed first. **A frame guard counts ONE function, so it goes blind when the code it watches stops calling that function.** #563's paired-initials guard counted `_normalize`, which segment's scan reached; #613 moved the scan into `_comma.decide`, where it calls `is_suffix_piece`, and the quadratic came back while the guard read 3.6× and passed (review caught it: 120 → 1,620 calls for 4× the input). When code under a frame guard moves, re-take the guard's negative control on the moved code before trusting a pass. Frame counts do not move under load, so this shape needs no repeated-run calibration — but it does need the same reachability assertion `_POLICY_SHAPES` rows carry, since the walk under measurement runs only while every unit still reads as a credential. A stage gated on an opt-in `Policy` field needs a `_POLICY_SHAPES` entry instead, since bare `parse()` never enters it — and that table's rows carry a **reachability probe** run before the measurement, because a precedence change can quietly stop the shape reaching the stage and leave a green test measuring a no-op (`_POLICY_SHAPES` is also asserted non-empty: an empty `parametrize` is a skip, not a failure, so deleting its last row would retire the guard silently). +**Perf regressions are caught by the scaling test, not the absolute-time ones** — `tests/v2/test_benchmark.py::test_parse_cost_grows_no_worse_than_linearly` times a repeated unit at n vs 4n over a table of shapes (one per pipeline inner loop) and bounds the ratio; the `_thousand_names` tests use constant-size, delimiter-free input and are structurally blind to a complexity regression. Two rules when touching it: calibrate `_MAX_RATIO` against the WEAKEST quadratic's signal (a mixed quadratic surfaces far below the textbook 16×, so the operating point `_BASE` matters more than the bound), and confirm a planted regression fails it across REPEATED runs — one failure is a coin-flip on a timing test. The shapes cover different dimensions (segment count only via `commas`, intra-piece accumulation only via `particles`/`conjunctions`, non-ASCII input only via `honorifics` — every other unit is pure ASCII, so `script_segment` returns at its bail and the CJK stages go unmeasured, M2's clause view only via `maiden_clause`, the one unit holding a marker (until #601 its order mattered, `MA nee ` reaching nothing; since #601 either order reaches the take) — and connective RUN LENGTH only via `link_run`, whose unit has to be MIXED CASE and hold a connective of the generational class: `i und ` reads the letter as an initial and reaches nothing where `i Und ` reaches everything, and S7's anchor pass only via `credential_run`, whose unit has to hold a Title-case member behind an unambiguous credential: `PhD MA ` reads the same fields and never asks the pass, the capitals deciding each member first); measure before pruning one. **A shape whose input needs a PREFIX cannot be a `_SHAPES` row at all**, since that table repeats a unit and nothing else — a maiden clause needs a name word and a marker before the run it is about, and `"nee i Und " * n` reaches the clause rule not at all, measuring the identical ratio on a broken tree and a fixed one. That one is `test_a_clause_link_run_does_not_cost_quadratically`, which builds its own input and counts FRAMES. A prefixed shape whose cost is Python-level joins `_REREAD_SHAPES` beside it — (input builder, the one function the defect re-runs, reachability probe) rows counted in that function's frames at 8 units against 32 — which holds #558's trailing fixed point, its maiden-clause twin, and #559's particle chain, a shape that grows at both ends and so has no unit to repeat. A prefixed shape whose cost is C-level work — a list scanned by `in`, a tuple re-hashed as a dict key — emits no frame for that count to see, so it goes in `_PREFIXED_SHAPES` instead: (prefix, unit, reachability probe) rows timed on the clock like `_SHAPES`, at a base of their own recorded beside the table (#553, whose run shape read 5.97× once at base 800 on the pre-fix tree, under the bound; py3.11, 2026-09-28). **That table skips under a line tracer** (`sys.settrace`, coverage's core below py3.14), which slows every Python line and not the C-level scan, diluting the quadratic below the bound (5.46–5.52× broken at base 1600 under `coverage run` on py3.11, same date). The `sys.monitoring` core coverage uses from 3.14 does not dilute it (8.87–10.91× broken), and CI collects coverage in its 3.14 build job alone, so every CI job runs the table (in its own `-n0 -m serial` pass: the module is marked `serial`, since a parallel run's load stretches the larger timing more than the smaller -- `maiden_clause` read 6.4× against 6.0 under `-n auto` on a tree that passes serially, 2026-10-23 -- so a new clock-timed test elsewhere takes the mark too); `ja-extra` also sets `NAMEPARSER_REQUIRE_CLOCK_GUARDS` so that a line tracer there fails the rows instead of skipping them. **Coverage is collected on ONE interpreter, the one that uploads it** (`COVERAGE_ON` in `.github/workflows/python-package.yml`): the package has no version-conditional code, so one report stands for all of them, and under a line tracer the property grids in `tests/v2/test_properties.py` had cost the 3.12 job most of its ~12 minutes (measured 2026-09-29: the three #397 grids take 139s under coverage's settrace core on 3.12, 24s under its sys.monitoring core, 17s with none). **A shape the CLOCK cannot reach needs a FRAME-count guard instead**, which is the second scaling test in that file (`test_a_trailing_credential_run_does_not_cost_exponentially`, #531): where the defect is an exponential rather than a quadratic, the input length that separates the curves on a timing test does not finish, so the guard counts frames over 8 units against 16 and bounds THAT ratio. One pair does not see every curve, and the fix round for #531 measured why: at 2× the input the per-member LINEAR work swamps a quadratic (2.08× for a genuine one against 1.73× clean), so that pair guards the exponential alone and a second, longer pair — 16 against 64, where the same quadratic reads 7.42× against 3.53× clean — is what can see one. Assert them in that order: an exponential never returns from the longer run, so the cheap pair has to have failed first. **A frame guard counts ONE function, so it goes blind when the code it watches stops calling that function.** #563's paired-initials guard counted `_normalize`, which segment's scan reached; #613 moved the scan into `_comma.decide`, where it calls `is_suffix_piece`, and the quadratic came back while the guard read 3.6× and passed (review caught it: 120 → 1,620 calls for 4× the input). When code under a frame guard moves, re-take the guard's negative control on the moved code before trusting a pass. Frame counts do not move under load, so this shape needs no repeated-run calibration — but it does need the same reachability assertion `_POLICY_SHAPES` rows carry, since the walk under measurement runs only while every unit still reads as a credential. A stage gated on an opt-in `Policy` field needs a `_POLICY_SHAPES` entry instead, since bare `parse()` never enters it — and that table's rows carry a **reachability probe** run before the measurement, because a precedence change can quietly stop the shape reaching the stage and leave a green test measuring a no-op (`_POLICY_SHAPES` is also asserted non-empty: an empty `parametrize` is a skip, not a failure, so deleting its last row would retire the guard silently). **A JSONL file written with `json.dumps(..., ensure_ascii=False)` is cut by `str.splitlines()`** — `json.dumps` leaves U+2028, U+2029 and U+0085 unescaped, and `splitlines()` splits at all three, so one name pasted from a document holding a line separator turns its line into two halves of invalid JSON, and the file into one no reader can load. #801's review found it in the local corpora (a single official among 536,981 would have made the cache unloadable for good), and the review of that fix found it again one file over, in `snapshot.py`'s `dump`/`load`, which the fix had not touched. So it is fixed at the shared source now: `corpus_build.jsonl_line` escapes the three and `corpus_build.jsonl_lines` cuts at `"\n"` only, and `corpus_build`'s corpus and fields writers, `snapshot.py`, `cldr_agreement.py`, `local_corpora.py`, `local_psc.py`'s extracts and `local_agreement.py` go through them. A new JSONL writer or reader in tools/ goes through the same two. Still outside them: the builders that write their own lines (`build_cjk_corpus.py`, `_conventions`, `_shapes`, `_rules`, `_issues`), `compare.py`'s worker stream, and the tests' corpus reads (`_differential_fixtures.py`, `test_corpora.py`); none of the 16 committed JSONL files held one on 2026-10-21. @@ -359,6 +364,6 @@ A parse-behavior test can still be a disguised constant-content test if it only **`python_classes = ["*Tests", "*TestCase"]`** in `pyproject.toml` — suffix style (`FooTests`), NOT prefix (`TestFoo`); wrong style silently skips discovery. -Tests run under **pytest** (via `uv run pytest`) and are split one file per concern (`tests/test_titles.py`, `tests/test_suffixes.py`, etc.). `tests/base.py` holds `HumanNameTestBase` — a plain (non-`unittest`) base whose `m()` helper is a custom assert that prints the original name string on failure (plus thin `assert*` shims so the moved test bodies are unchanged). `tests/conftest.py` defines an autouse fixture that runs **every test twice** — once with `empty_attribute_default = ''` and once with `None` — so reported counts are doubled (e.g. 11 methods → 22 results); it also snapshots/restores the scalar `CONSTANTS` config around each test to keep tests order-independent. `TEST_NAMES` (in `tests/test_variations.py`) is a list of name strings permuted into comma-separated variants as a regression check. Tests that should fail use `@pytest.mark.xfail`. When adding a parsing case, add it to the relevant `tests/test_*.py` file and consider adding the base form to `TEST_NAMES`. +Tests run under **pytest** (via `uv run pytest`) and are split one file per concern (`tests/test_titles.py`, `tests/test_suffixes.py`, etc.). `tests/base.py` holds `HumanNameTestBase` — a plain (non-`unittest`) base whose `m()` helper is a custom assert that prints the original name string on failure (plus thin `assert*` shims so the moved test bodies are unchanged). `tests/conftest.py` defines an autouse fixture that snapshots/restores the scalar and collection `CONSTANTS` config around each test to keep tests order-independent (it also ran every test twice over `empty_attribute_default` until 2.0 removed that setting, #255). `TEST_NAMES` (in `tests/test_variations.py`) is a list of name strings permuted into comma-separated variants as a regression check. Tests that should fail use `@pytest.mark.xfail`. When adding a parsing case, add it to the relevant `tests/test_*.py` file and consider adding the base form to `TEST_NAMES`. Test classes that parse with a dedicated flagged `Constants` (e.g. `patronymic_name_order=True`) subclass `FlaggedConstantsTestBase` (`tests/base.py`) and set `constants_kwargs = {...}` — don't hand-write per-class `setup_method`/`hn()` fixture pairs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19130db08..ca154e905 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,10 @@ Install dev dependencies: Running Tests --------------- - uv run pytest # --doctest-modules is set in pyproject.toml, so doctests run automatically + uv run pytest # parallel; --doctest-modules is set in pyproject.toml, so doctests run too + uv run pytest -n0 -m serial # the clock-timed benchmarks, which a parallel run skips + +`-n0` runs the whole suite serially in one process. Run a single test file or test: diff --git a/pyproject.toml b/pyproject.toml index ad94b7ae7..3d4436f90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "ruff (>=0.15)", "pytest-timeout>=2.4.0", "pytest-cov>=6", + "pytest-xdist>=3.6", "hypothesis", ] @@ -103,7 +104,16 @@ check_untyped_defs = true [tool.pytest.ini_options] testpaths = ["tests", "nameparser"] -addopts = "--doctest-modules" +# Parallel by default: the suite is CPU-bound and took 110s serial +# against 35s on 8 workers (measured 2026-10-23). worksteal because the +# property grids are a few long tests among 23,000 short ones, and +# fixed chunks left one worker running them alone. -n0 runs serially. +addopts = "--doctest-modules -n auto --dist worksteal" +markers = [ + # deselected in a parallel run by tests/conftest.py; run them with + # `uv run pytest -n0 -m serial` + "serial: measures the clock, so other workers' load can fail it", +] python_classes = ["*Tests", "*TestCase"] # Fail if an xfail-marked test unexpectedly passes, so a fixed/regressed # expectation surfaces instead of silently turning into an xpass. diff --git a/tests/conftest.py b/tests/conftest.py index f6e5e4873..23c21bd58 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,6 +52,34 @@ ) +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Deselect the ``serial`` tests in a parallel run's workers. + + The clock-timed scaling guards in tests/v2/test_benchmark.py bound a + ratio of two timings, and the other workers' load stretches the + larger input's more than the smaller's: under -n auto the + maiden_clause row read 6.4x against its 6.0 bound on a tree that + passes serially (measured 2026-10-23). They run in their own -n0 + pass instead, which CI makes and the summary line below asks for. + """ + if not hasattr(config, "workerinput"): + return + serial = [item for item in items if item.get_closest_marker("serial")] + if serial: + items[:] = [item for item in items if not item.get_closest_marker("serial")] + config.hook.pytest_deselected(items=serial) + + +def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter, + config: pytest.Config) -> None: + # a deselection nobody is told about is a guard that stopped running + if hasattr(config, "workerinput") or not getattr(config.option, "numprocesses", None): + return + terminalreporter.write_line( + "serial tests (tests/v2/test_benchmark.py) do not run in parallel; " + "run them with: uv run pytest -n0 -m serial", yellow=True) + + @pytest.fixture(autouse=True) def _isolate_constants() -> Iterator[None]: """Snapshot and restore the shared CONSTANTS singleton around every test. diff --git a/tests/v2/test_benchmark.py b/tests/v2/test_benchmark.py index 313f3c8ce..f78dba56b 100644 --- a/tests/v2/test_benchmark.py +++ b/tests/v2/test_benchmark.py @@ -34,6 +34,12 @@ from nameparser import ParsedName, Parser, parse from nameparser._policy import Policy +#: The whole module, though only the clock-timed tests need it: a +#: parallel run's load fails their ratios (tests/conftest.py), and the +#: frame-count tests beside them cost too little to be worth splitting +#: the module's one serial pass. +pytestmark = pytest.mark.serial + #: What one parse of the reference name is allowed to cost, counted in #: Python frame entries rather than seconds (#475). A wall-clock bound diff --git a/tests/v2/test_vocab.py b/tests/v2/test_vocab.py index 36923a5be..d8fb67062 100644 --- a/tests/v2/test_vocab.py +++ b/tests/v2/test_vocab.py @@ -695,6 +695,16 @@ def test_a_warning_raised_as_an_error_exits_2(tmp_path: Path, monkeypatch: pytes assert build.main(["--check"]) == 2 +def test_a_repointed_root_does_not_move_the_lexicon_module(tmp_path: Path, + monkeypatch: pytest.MonkeyPatch) -> None: + # the tests above repoint ROOT at a fixture tree holding no + # _lexicon.py; a cold cache must still load the parser's own, or + # each of them passes only after another test has warmed it + monkeypatch.setattr(build, "_LEXICON", []) + monkeypatch.setattr(build, "ROOT", tmp_path) + assert build._normalize(" Dr. ") == "dr" + + def test_a_broken_lexicon_module_exits_3(monkeypatch: pytest.MonkeyPatch) -> None: def broken() -> ModuleType: raise SyntaxError("conflict markers") diff --git a/tools/vocab/build.py b/tools/vocab/build.py index b8ee5ebf9..f29847cbd 100644 --- a/tools/vocab/build.py +++ b/tools/vocab/build.py @@ -87,6 +87,10 @@ from typing import Any, TypedDict ROOT = Path(__file__).resolve().parents[2] +# bound at import like VOCAB and OUTPUT below, so a test repointing ROOT +# at a fixture tree does not move the parser's own module out from under +# a cold _LEXICON cache +_LEXICON_PATH = ROOT / "nameparser" / "_lexicon.py" def _load_lexicon_module() -> ModuleType: @@ -101,8 +105,7 @@ def _load_lexicon_module() -> ModuleType: own; tests/v2/test_vocab.py's test_the_build_does_not_import_the_package_it_writes holds that.""" import importlib.util - path = ROOT / "nameparser" / "_lexicon.py" - spec = importlib.util.spec_from_file_location("_vocab_build_lexicon", path) + spec = importlib.util.spec_from_file_location("_vocab_build_lexicon", _LEXICON_PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) # registered before it runs: its dataclasses look their module up diff --git a/uv.lock b/uv.lock index ca8f9f621..f90ce8460 100644 --- a/uv.lock +++ b/uv.lock @@ -322,6 +322,15 @@ 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 = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "furo" version = "2025.12.19" @@ -722,6 +731,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, + { name = "pytest-xdist" }, { name = "ruff" }, { 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'" }, @@ -740,6 +750,7 @@ dev = [ { name = "pytest", specifier = ">=8" }, { name = "pytest-cov", specifier = ">=6" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, + { name = "pytest-xdist", specifier = ">=3.6" }, { name = "ruff", specifier = ">=0.15" }, { name = "sphinx", specifier = ">=8" }, ] @@ -1070,6 +1081,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"