aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-16 14:40:39 +0530
committernatsuoto <[email protected]>2026-07-16 14:40:39 +0530
commitb7ae2f3885d5ff84237c9da8bc0c64fe1a509465 (patch)
tree27d8e35406931aad0f26c89bb4679e7c38c0e24c /tests
parent9d308c262788252cac26f5821cade327e18da7aa (diff)
downloadedify-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.py66
-rw-r--r--tests/discipline/deprecations.test.py72
-rw-r--r--tests/docs/snapshots.test.py8
-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.regex0
-rw-r--r--tests/tools/changes.test.py116
-rw-r--r--tests/tools/surface.test.py89
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