diff options
| author | natsuoto <[email protected]> | 2026-07-16 14:40:39 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-16 14:40:39 +0530 |
| commit | b7ae2f3885d5ff84237c9da8bc0c64fe1a509465 (patch) | |
| tree | 27d8e35406931aad0f26c89bb4679e7c38c0e24c /tests | |
| parent | 9d308c262788252cac26f5821cade327e18da7aa (diff) | |
| download | edify-b7ae2f3885d5ff84237c9da8bc0c64fe1a509465.tar.xz edify-b7ae2f3885d5ff84237c9da8bc0c64fe1a509465.zip | |
feat(release): changes/ fragment system + generator, .public-surface snapshot + CI migration gate, deprecation-warning + anchor harnesses, Trusted Publishing, relicense CHANGELOG entry
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/discipline/anchors.test.py | 66 | ||||
| -rw-r--r-- | tests/discipline/deprecations.test.py | 72 | ||||
| -rw-r--r-- | tests/docs/snapshots.test.py | 8 | ||||
| -rw-r--r-- | tests/snapshots/docs/deprecation-policy/0020.regex (renamed from tests/snapshots/docs/upgrading/0.3-to-1.0/0018.regex) | 0 | ||||
| -rw-r--r-- | tests/snapshots/docs/upgrading/0.3-to-1.0/0046.regex (renamed from tests/snapshots/docs/upgrading/0.3-to-1.0/0037.regex) | 0 | ||||
| -rw-r--r-- | tests/snapshots/docs/upgrading/0.3-to-1.0/0071.regex | 0 | ||||
| -rw-r--r-- | tests/tools/changes.test.py | 116 | ||||
| -rw-r--r-- | tests/tools/surface.test.py | 89 |
8 files changed, 351 insertions, 0 deletions
diff --git a/tests/discipline/anchors.test.py b/tests/discipline/anchors.test.py new file mode 100644 index 0000000..0e5e8d7 --- /dev/null +++ b/tests/discipline/anchors.test.py @@ -0,0 +1,66 @@ +"""Deprecation-URL anchor contract. + +A deprecation warning points users at ``docs/upgrading/...#anchor``. Shape-checking +the URL alone passes even when the anchor 404s. This test extracts every anchor +referenced by a deprecation stub, parses the upgrade guides for their actual +``.. _label:`` targets, and asserts each referenced anchor exists. + +Edify 1.0 ships stub-free, so the deprecation-URL registry is empty today; the +test still verifies that every anchor the upgrade guides *declare* is well-formed +and unique, so the contract is enforced the moment the first stub lands. +""" + +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).parent.parent.parent +_UPGRADING_DIR = _REPO_ROOT / "docs" / "upgrading" +_ANCHOR_PATTERN = re.compile(r"^\.\.\s+_([a-zA-Z0-9][a-zA-Z0-9\-]*):\s*$") + +# Anchors referenced by deprecation-warning URLs. Each entry is (guide-stem, anchor). +# Empty in 1.0 (stub-free); every future deprecation stub adds its (guide, anchor) row. +_DEPRECATION_URL_ANCHORS: frozenset[tuple[str, str]] = frozenset() + + +def _declared_anchors(rst_path: Path) -> list[str]: + anchors: list[str] = [] + for line in rst_path.read_text().splitlines(): + matched = _ANCHOR_PATTERN.match(line) + if matched is not None: + anchors.append(matched.group(1)) + return anchors + + +def _anchors_by_guide() -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for rst_path in sorted(_UPGRADING_DIR.glob("*.rst")): + result[rst_path.stem] = _declared_anchors(rst_path) + return result + + +def test_upgrade_guide_anchors_are_unique_within_each_guide(): + for guide_stem, anchors in _anchors_by_guide().items(): + assert len(anchors) == len(set(anchors)), ( + f"duplicate anchor(s) in docs/upgrading/{guide_stem}.rst: " + f"{sorted({a for a in anchors if anchors.count(a) > 1})}" + ) + + +def test_every_deprecation_url_anchor_exists_in_its_guide(): + anchors_by_guide = _anchors_by_guide() + for guide_stem, anchor in _DEPRECATION_URL_ANCHORS: + declared = anchors_by_guide.get(guide_stem, []) + assert anchor in declared, ( + f"deprecation URL references #{anchor} in docs/upgrading/{guide_stem}.rst, " + f"but no `.. _{anchor}:` target exists there." + ) + + +def test_the_0_3_to_1_0_guide_declares_the_license_anchor(): + anchors = _anchors_by_guide()["0.3-to-1.0"] + assert "license-mit" in anchors + + +def test_the_0_3_to_1_0_guide_declares_the_python_floor_anchor(): + anchors = _anchors_by_guide()["0.3-to-1.0"] + assert "python-floor" in anchors diff --git a/tests/discipline/deprecations.test.py b/tests/discipline/deprecations.test.py new file mode 100644 index 0000000..fe3416e --- /dev/null +++ b/tests/discipline/deprecations.test.py @@ -0,0 +1,72 @@ +"""Deprecation-stub contract harness. + +Every deprecated public symbol must fire exactly one :class:`DeprecationWarning` +whose message matches the policy format:: + + <name> is deprecated since <version>; use <replacement>. See <docs-url> + +with ``stacklevel=2`` so the warning points at the caller. Edify 1.0 ships +stub-free, so the registry below is empty; this harness parametrizes over it, so +the moment a stub lands its row here pins the category, message shape, single-fire +behavior, and documentation URL. +""" + +import importlib +import re +import warnings + +import pytest + +_MESSAGE_PATTERN = re.compile( + r"^(?P<name>\S+) is deprecated since (?P<version>\S+); " + r"use (?P<replacement>.+)\. See (?P<url>https://\S+#\S+)$" +) + +# One row per deprecated stub: (import_path, symbol_name, call_args). +# Empty in 1.0 (stub-free). Each future deprecation adds its row here. +_DEPRECATED_STUBS: list[tuple[str, str, tuple[object, ...]]] = [] + + + ("import_path", "symbol_name", "call_args"), + _DEPRECATED_STUBS, + ids=[f"{path}.{name}" for path, name, _ in _DEPRECATED_STUBS], +) +def test_deprecated_stub_fires_one_well_formed_warning(import_path, symbol_name, call_args): + module = importlib.import_module(import_path) + stub = getattr(module, symbol_name) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + stub(*call_args) + deprecation_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 + message_text = str(deprecation_warnings[0].message) + assert _MESSAGE_PATTERN.match(message_text), ( + f"{import_path}.{symbol_name} warning does not match the policy format: {message_text!r}" + ) + + +def test_message_pattern_accepts_a_policy_conformant_message(): + conformant = ( + "old_name is deprecated since 1.1; use new_name. " + "See https://edify.readthedocs.io/en/latest/upgrading/1.0-to-1.1.html#new-name" + ) + assert _MESSAGE_PATTERN.match(conformant) + + +def test_message_pattern_rejects_a_message_missing_the_docs_url(): + non_conformant = "old_name is deprecated since 1.1; use new_name." + assert _MESSAGE_PATTERN.match(non_conformant) is None + + +def test_message_pattern_rejects_a_url_without_an_anchor(): + non_conformant = ( + "old_name is deprecated since 1.1; use new_name. " + "See https://edify.readthedocs.io/en/latest/upgrading/1.0-to-1.1.html" + ) + assert _MESSAGE_PATTERN.match(non_conformant) is None + + +def test_registry_is_empty_or_every_row_has_three_fields(): + for row in _DEPRECATED_STUBS: + assert len(row) == 3 diff --git a/tests/docs/snapshots.test.py b/tests/docs/snapshots.test.py index b1c223b..f335373 100644 --- a/tests/docs/snapshots.test.py +++ b/tests/docs/snapshots.test.py @@ -103,6 +103,12 @@ _BLOCKS_DEFERRED_TO_DOCS_REWRITE = frozenset( } ) +_ILLUSTRATIVE_NON_EXECUTABLE_BLOCKS = frozenset( + { + ("upgrading/0.3-to-1.0", 140), + } +) + _BLOCKS_SKIPPED_ON_PYPY = frozenset( { ("regex-builder/flags/index", 38), @@ -123,6 +129,8 @@ def test_doc_code_block_produces_the_snapshotted_regex( stem_string = str(relative_stem) if (stem_string, block_start) in _BLOCKS_DEFERRED_TO_DOCS_REWRITE: pytest.skip("doc block references validators / kwargs slated for the docs rewrite") + if (stem_string, block_start) in _ILLUSTRATIVE_NON_EXECUTABLE_BLOCKS: + pytest.skip("doc block shows pre/post-migration code that is intentionally not executable") if _ON_PYPY and (stem_string, block_start) in _BLOCKS_SKIPPED_ON_PYPY: pytest.skip("doc block hits PyPy's re.DEBUG upstream disassembler bug") namespace = _prepared_exec_namespace() diff --git a/tests/snapshots/docs/upgrading/0.3-to-1.0/0018.regex b/tests/snapshots/docs/deprecation-policy/0020.regex index e69de29..e69de29 100644 --- a/tests/snapshots/docs/upgrading/0.3-to-1.0/0018.regex +++ b/tests/snapshots/docs/deprecation-policy/0020.regex diff --git a/tests/snapshots/docs/upgrading/0.3-to-1.0/0037.regex b/tests/snapshots/docs/upgrading/0.3-to-1.0/0046.regex index e69de29..e69de29 100644 --- a/tests/snapshots/docs/upgrading/0.3-to-1.0/0037.regex +++ b/tests/snapshots/docs/upgrading/0.3-to-1.0/0046.regex diff --git a/tests/snapshots/docs/upgrading/0.3-to-1.0/0071.regex b/tests/snapshots/docs/upgrading/0.3-to-1.0/0071.regex new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/snapshots/docs/upgrading/0.3-to-1.0/0071.regex diff --git a/tests/tools/changes.test.py b/tests/tools/changes.test.py new file mode 100644 index 0000000..7eeb777 --- /dev/null +++ b/tests/tools/changes.test.py @@ -0,0 +1,116 @@ +"""Tests for the ``changes/`` fragment generator in ``tools/changes.py``.""" + +import importlib.util +from pathlib import Path + +_REPO_ROOT = Path(__file__).parent.parent.parent +_GENERATOR_PATH = _REPO_ROOT / "tools" / "changes.py" + + +def _load_generator(): + spec = importlib.util.spec_from_file_location("edify_changes_tool", _GENERATOR_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_GENERATOR = _load_generator() + + +def _write_fragment(directory: Path, name: str, body: str) -> Path: + fragment_path = directory / name + fragment_path.write_text(body, encoding="utf-8") + return fragment_path + + +def test_collect_fragments_returns_files_in_fragment_id_order(tmp_path): + _write_fragment(tmp_path, "0020-second.rst", "[change]\nanchor=b\nheading=B\ncontext=b") + _write_fragment(tmp_path, "0010-first.rst", "[change]\nanchor=a\nheading=A\ncontext=a") + collected = _GENERATOR.collect_fragments(tmp_path) + assert [path.name for path in collected] == ["0010-first.rst", "0020-second.rst"] + + +def test_collect_fragments_skips_the_readme(tmp_path): + _write_fragment(tmp_path, "README.rst", "not a fragment") + _write_fragment(tmp_path, "0010-only.rst", "[change]\nanchor=a\nheading=A\ncontext=a") + collected = _GENERATOR.collect_fragments(tmp_path) + assert [path.name for path in collected] == ["0010-only.rst"] + + +def test_render_fragment_emits_anchor_heading_and_context(tmp_path): + fragment_path = _write_fragment( + tmp_path, + "0010-example.rst", + "[change]\nanchor = my-anchor\nheading = My Heading\ncontext = A single sentence.", + ) + rendered = _GENERATOR.render_fragment(fragment_path) + assert ".. _my-anchor:" in rendered + assert "My Heading" in rendered + assert "-" * len("My Heading") in rendered + assert "A single sentence." in rendered + + +def test_render_fragment_collapses_multiline_context_into_one_paragraph(tmp_path): + fragment_path = _write_fragment( + tmp_path, + "0010-multiline.rst", + "[change]\nanchor = a\nheading = H\ncontext = first line\n second line", + ) + rendered = _GENERATOR.render_fragment(fragment_path) + assert "first line second line" in rendered + + +def test_render_fragment_includes_before_after_block_when_both_present(tmp_path): + fragment_path = _write_fragment( + tmp_path, + "0010-beforeafter.rst", + "[change]\nanchor = a\nheading = H\nbefore = old\nafter = new\ncontext = changed", + ) + rendered = _GENERATOR.render_fragment(fragment_path) + assert ".. code-block:: text" in rendered + assert " old" in rendered + assert " new" in rendered + + +def test_render_fragment_omits_before_after_block_when_absent(tmp_path): + fragment_path = _write_fragment( + tmp_path, + "0010-nofix.rst", + "[change]\nanchor = a\nheading = H\ncontext = no before/after here", + ) + rendered = _GENERATOR.render_fragment(fragment_path) + assert ".. code-block:: text" not in rendered + + +def test_render_all_concatenates_every_fragment_in_order(tmp_path): + _write_fragment(tmp_path, "0020-b.rst", "[change]\nanchor=b\nheading=Bravo\ncontext=b") + _write_fragment(tmp_path, "0010-a.rst", "[change]\nanchor=a\nheading=Alpha\ncontext=a") + rendered = _GENERATOR.render_all(tmp_path) + assert rendered.index("Alpha") < rendered.index("Bravo") + + +def test_main_release_deletes_consumed_fragments(tmp_path, monkeypatch): + _write_fragment(tmp_path, "0010-a.rst", "[change]\nanchor=a\nheading=A\ncontext=a") + monkeypatch.setattr(_GENERATOR, "_CHANGES_DIR", tmp_path) + exit_code = _GENERATOR.main(["--release"]) + assert exit_code == 0 + assert _GENERATOR.collect_fragments(tmp_path) == [] + + +def test_main_without_release_leaves_fragments_in_place(tmp_path, monkeypatch, capsys): + _write_fragment(tmp_path, "0010-a.rst", "[change]\nanchor=a\nheading=A\ncontext=a") + monkeypatch.setattr(_GENERATOR, "_CHANGES_DIR", tmp_path) + exit_code = _GENERATOR.main([]) + captured = capsys.readouterr() + assert exit_code == 0 + assert "A" in captured.out + assert len(_GENERATOR.collect_fragments(tmp_path)) == 1 + + +def test_committed_changes_directory_fragments_all_parse(): + changes_dir = _REPO_ROOT / "changes" + for fragment_path in _GENERATOR.collect_fragments(changes_dir): + rendered = _GENERATOR.render_fragment(fragment_path) + assert rendered.strip() != "" diff --git a/tests/tools/surface.test.py b/tests/tools/surface.test.py new file mode 100644 index 0000000..0b8daf0 --- /dev/null +++ b/tests/tools/surface.test.py @@ -0,0 +1,89 @@ +"""Tests for the public-surface snapshot tool in ``tools/surface.py``.""" + +import importlib.util +from pathlib import Path + +_REPO_ROOT = Path(__file__).parent.parent.parent +_TOOL_PATH = _REPO_ROOT / "tools" / "surface.py" +_SURFACE_PATH = _REPO_ROOT / ".public-surface" + + +def _load_tool(): + spec = importlib.util.spec_from_file_location("edify_surface_tool", _TOOL_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_TOOL = _load_tool() + + +def test_committed_surface_file_exists_and_is_non_empty(): + assert _SURFACE_PATH.exists() + assert _SURFACE_PATH.read_text(encoding="utf-8").strip() != "" + + +def test_computed_surface_matches_the_committed_snapshot(): + computed = _TOOL.compute_surface() + committed = _SURFACE_PATH.read_text(encoding="utf-8") + assert computed == committed, ( + "public surface drift: run `python tools/surface.py --write` and commit " + ".public-surface with a changes/ fragment describing the change." + ) + + +def test_surface_lists_the_core_public_symbols(): + computed = _TOOL.compute_surface() + assert "edify.Pattern" in computed + assert "edify.RegexBuilder" in computed + assert "edify.Regex" in computed + assert "edify.EdifyError" in computed + + +def test_surface_excludes_private_module_paths(): + computed = _TOOL.compute_surface() + for line in computed.splitlines(): + dotted = line.split("(")[0] + parts = dotted.split(".") + assert not any(part.startswith("_") for part in parts[:-1]), ( + f"surface line leaks a private module path: {line!r}" + ) + + +def test_surface_is_sorted_and_deduplicated(): + computed = _TOOL.compute_surface() + lines = computed.splitlines() + assert lines == sorted(lines) + assert len(lines) == len(set(lines)) + + +def test_check_returns_zero_when_surface_matches(capsys): + exit_code = _TOOL.main(["--check"]) + assert exit_code == 0 + + +def test_check_returns_one_when_surface_drifts(tmp_path, monkeypatch, capsys): + drifted_snapshot = tmp_path / ".public-surface" + drifted_snapshot.write_text("edify.SomethingRemoved\n", encoding="utf-8") + monkeypatch.setattr(_TOOL, "_SURFACE_PATH", drifted_snapshot) + exit_code = _TOOL.main(["--check"]) + captured = capsys.readouterr() + assert exit_code == 1 + assert "public surface drift" in captured.err + + +def test_write_overwrites_the_snapshot_file(tmp_path, monkeypatch): + target = tmp_path / ".public-surface" + monkeypatch.setattr(_TOOL, "_SURFACE_PATH", target) + exit_code = _TOOL.main(["--write"]) + assert exit_code == 0 + assert target.read_text(encoding="utf-8") == _TOOL.compute_surface() + + +def test_bare_invocation_prints_the_surface_to_stdout(capsys): + exit_code = _TOOL.main([]) + captured = capsys.readouterr() + assert exit_code == 0 + assert "edify.Pattern" in captured.out |
