From c0e154b6dd80c6823f3264461ed3b067ca328c14 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Wed, 1 Jul 2026 20:19:34 -0700 Subject: [PATCH] test: make Constants attribute docstring doctests actually run Constants class attributes document their default with a bare string literal after the assignment (e.g. patronymic_name_order = False / """..."""), Sphinx's attribute-docstring convention. That string is never a real __doc__, so doctest.DocTestFinder (and pytest's --doctest-modules) never discovers the .. doctest:: examples inside it -- they can silently go stale, which already happened once for middle_name_as_last. Add tests/test_config_attribute_docstrings.py, which parses the source with ast to recover those literals (the same info Sphinx's static analysis reads) and runs any doctest examples through doctest.DocTestParser/DocTestRunner, so pytest -q now exercises them. Discovery also caught a second dormant docstring (empty_attribute_default) with its own bugs: a missing space after >>> and an expectation of literal None output where the REPL prints nothing for None. --- nameparser/config/__init__.py | 4 +- tests/test_config_attribute_docstrings.py | 79 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/test_config_attribute_docstrings.py diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index 39a6051..5d6464a 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -323,9 +323,9 @@ class Constants: >>> from nameparser.config import CONSTANTS >>> CONSTANTS.empty_attribute_default = None >>> name = HumanName("John Doe") - >>> name.title + >>> print(name.title) None - >>>name.first + >>> name.first 'John' """ diff --git a/tests/test_config_attribute_docstrings.py b/tests/test_config_attribute_docstrings.py new file mode 100644 index 0000000..4b1f4c7 --- /dev/null +++ b/tests/test_config_attribute_docstrings.py @@ -0,0 +1,79 @@ +"""Exercise the doctest examples embedded in Constants attribute docstrings. + +nameparser.config.Constants documents several scalar attributes (e.g. +patronymic_name_order, middle_name_as_last) with a bare string literal placed +right after the class-attribute assignment -- Sphinx's "attribute docstring" +convention, picked up by autodoc's source-level analysis for the built docs. + +That convention is invisible to Python at runtime: only module/class/function/ +method docstrings become a real __doc__, so a bare string following +`attr = value` is evaluated and discarded. doctest.DocTestFinder walks __doc__ +attributes, so pytest's --doctest-modules (see pyproject.toml addopts) never +finds the `.. doctest::` examples inside them -- they can go stale silently. + +This module parses the source with `ast` to recover those literals (the same +information Sphinx's static analysis relies on) and runs any doctest examples +found inside them explicitly, so a stale example fails the suite. +""" +import ast +import doctest +import io +from pathlib import Path + +import pytest + +import nameparser.config as config_module +from nameparser import HumanName +from nameparser.config import CONSTANTS, Constants + +CONFIG_SOURCE_PATH = Path(config_module.__file__) + + +def _constants_attribute_docstrings() -> dict[str, str]: + """Map attribute name -> bare-string-literal docstring, Constants class body only.""" + tree = ast.parse(CONFIG_SOURCE_PATH.read_text(), filename=str(CONFIG_SOURCE_PATH)) + (class_node,) = ( + node for node in ast.walk(tree) + if isinstance(node, ast.ClassDef) and node.name == 'Constants' + ) + docstrings = {} + body = class_node.body + for stmt, following in zip(body, body[1:]): + if not (isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 + and isinstance(stmt.targets[0], ast.Name)): + continue + if isinstance(following, ast.Expr) and isinstance(following.value, ast.Constant) \ + and isinstance(following.value.value, str): + docstrings[stmt.targets[0].id] = following.value.value + return docstrings + + +ATTRIBUTE_DOCSTRINGS = _constants_attribute_docstrings() + +DOCTEST_GLOBS = {'HumanName': HumanName, 'CONSTANTS': CONSTANTS, 'Constants': Constants} + + +def _attrs_with_doctest_examples() -> list[str]: + parser = doctest.DocTestParser() + return sorted( + attr for attr, docstring in ATTRIBUTE_DOCSTRINGS.items() + if parser.get_examples(docstring) + ) + + +@pytest.mark.parametrize("attr", _attrs_with_doctest_examples()) +def test_constants_attribute_docstring_examples(attr: str) -> None: + docstring = ATTRIBUTE_DOCSTRINGS[attr] + test = doctest.DocTestParser().get_doctest( + docstring, DOCTEST_GLOBS, attr, str(CONFIG_SOURCE_PATH), 0, + ) + output = io.StringIO() + failures, _ = doctest.DocTestRunner().run(test, out=output.write) + assert failures == 0, output.getvalue() + + +def test_found_expected_attributes_with_doctest_examples() -> None: + """Guard the discovery mechanism itself: if this drops to zero, the AST + walk above stopped matching Constants's attribute-docstring pattern and + every parametrized case above is silently skipped rather than run.""" + assert _attrs_with_doctest_examples()