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
8 changes: 7 additions & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down Expand Up @@ -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
15 changes: 10 additions & 5 deletions AGENTS.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
12 changes: 11 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ dev = [
"ruff (>=0.15)",
"pytest-timeout>=2.4.0",
"pytest-cov>=6",
"pytest-xdist>=3.6",
"hypothesis",
]

Expand Down Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions tests/v2/test_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions tests/v2/test_vocab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 5 additions & 2 deletions tools/vocab/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading