From 02f4457354c570960bb7e9a41134af5718d01f7e Mon Sep 17 00:00:00 2001 From: Panos Vagenas Date: Fri, 30 Oct 2026 13:08:11 +0100 Subject: [PATCH 1/3] retarget tests to released migration steps Signed-off-by: Panos Vagenas --- tests/test_prepare_release.py | 22 +++++++++++++++++++++- utils/rotate_migration_steps.py | 19 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py index 6187565..da4e55b 100644 --- a/tests/test_prepare_release.py +++ b/tests/test_prepare_release.py @@ -7,7 +7,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from utils.rotate_migration_steps import rotate_migration_steps # noqa: E402 +from utils.rotate_migration_steps import _FRESH_TO_NEXT, rotate_migration_steps # noqa: E402 from utils.snapshot_schemas import snapshot_schemas # noqa: E402 @@ -128,3 +128,23 @@ def migrated(source_file, target, steps): for case in cases: target = case.parent.name.split("_to_")[1] assert migrated(case, target, after) == migrated(case, target, before), case.name + + +def test_rotate_retargets_tests_importing_the_development_steps(tmp_path): + migrate_dir = _project_with_steps(tmp_path, ["0.7"]) + (migrate_dir / "to_next.py").write_text(_FRESH_TO_NEXT, encoding="utf-8") + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + (tests_dir / "test_steps.py").write_text( + "from doclang.migration._steps import from_next, to_next\n\nto_next.migrate(root, from_next._X)\n", + encoding="utf-8", + ) + other = 'path = steps_dir / "to_next.py"\n' # no import of a step: left alone + (tests_dir / "test_other.py").write_text(other, encoding="utf-8") + + rotate_migration_steps("0.8.0", tmp_path) + + assert (tests_dir / "test_steps.py").read_text(encoding="utf-8") == ( + "from doclang.migration._steps import v0_8_to_0_7, v0_7_to_0_8\n\nv0_7_to_0_8.migrate(root, v0_8_to_0_7._X)\n" + ) + assert (tests_dir / "test_other.py").read_text(encoding="utf-8") == other diff --git a/utils/rotate_migration_steps.py b/utils/rotate_migration_steps.py index f4d9f69..6270567 100644 --- a/utils/rotate_migration_steps.py +++ b/utils/rotate_migration_steps.py @@ -7,6 +7,8 @@ - doclang/migration/_steps/from_next.py -> v_to_.py (e.g. v0_8_to_0_7.py), down - doclang/migration/_steps/to_next.py -> v_to_.py (e.g. v0_8_to_0_9.py), up is the newest minor with schemas in doclang/schemas/. +Tests that import a development step (``from doclang.migration._steps import to_next``) are retargeted to the +released one, so they keep testing the rules they were written for. Nothing happens for a patch release of an existing minor, or if there is no previous minor. Run this before utils/snapshot_schemas.py, which creates doclang/schemas//. @@ -87,9 +89,11 @@ def rotate_migration_steps(version: str, project_root: Path | None = None) -> Pa previous = max(previous_versions, key=_key) released = _rotate(from_next_path, new, previous, _FRESH_FROM_NEXT) + renames = {"from_next": released.stem} to_next_path = from_next_path.with_name("to_next.py") if to_next_path.exists(): - _rotate(to_next_path, previous, new, _FRESH_TO_NEXT) + renames["to_next"] = _rotate(to_next_path, previous, new, _FRESH_TO_NEXT).stem + _retarget_tests(project_root / "tests", renames) return released @@ -110,6 +114,19 @@ def _rotate(path: Path, source: str, target: str, fresh: str) -> Path: return released +def _retarget_tests(tests_dir: Path, renames: dict[str, str]) -> None: + """In the tests importing a development step, refer to the released step instead.""" + if not tests_dir.is_dir(): + return + imports = re.compile(r"_steps import (" + "|".join(renames) + r")\b") + names = re.compile(r"\b(" + "|".join(renames) + r")\b") + for path in sorted(tests_dir.rglob("*.py")): + content = path.read_text(encoding="utf-8") + if imports.search(content): + path.write_text(names.sub(lambda m: renames[m.group(1)], content), encoding="utf-8") + print(f"Tests retargeted to the released migration steps: {path.name}") + + def main(): parser = argparse.ArgumentParser(description="Rotate the development migration steps for a release") parser.add_argument("version", help="Release version (e.g. 0.8.0)") From 6c8fc2a50b80dfa9e3cdb0e3f290fd8d201368ae Mon Sep 17 00:00:00 2001 From: Panos Vagenas Date: Fri, 30 Oct 2026 13:11:51 +0100 Subject: [PATCH 2/3] compute released and head versions in tests Signed-off-by: Panos Vagenas --- tests/test_migration.py | 29 +++++++++++++++++++---------- tests/test_validation.py | 21 +++++++++++++-------- utils/rotate_migration_steps.py | 2 +- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/tests/test_migration.py b/tests/test_migration.py index bc069ad..f30d8d3 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -1,5 +1,6 @@ """Tests for migrating documents to another spec version.""" +import re import sys import zipfile from pathlib import Path @@ -7,6 +8,7 @@ import pytest from doclang import MigrationError, SchematronBackendNotFound, UnsupportedVersionError, migrate +from doclang._schemas import _available_versions, _version_key from doclang.migration._api import _head_version from doclang.migration._plan import NoMigrationPath, Step, _plan @@ -16,6 +18,8 @@ CASES_DIR = Path(__file__).parent / "data" / "migration" NS = "https://www.doclang.ai/ns/v0" +NEWEST = max(_available_versions(), key=_version_key) # the newest released version +NEXT = f"{_version_key(NEWEST)[0]}.{_version_key(NEWEST)[1] + 1}" # the head's version, when it has changes TRACK = 'Hello.' @@ -185,16 +189,21 @@ def mark(root, changes): root[0].text = "marked" _with_unreleased_head(monkeypatch, mark) - result = migrate(_doc(tmp_path, "x"), to="0.9") - assert (result.source_version, result.target_version) == ("0.8", "0.9") - assert "marked" in result.xml and 'version="0.9"' in result.xml + result = migrate(_doc(tmp_path, "x", version=NEWEST), to=NEXT) + assert (result.source_version, result.target_version) == (NEWEST, NEXT) + assert "marked" in result.xml and f'version="{NEXT}"' in result.xml -HEAD = _head_version("0.8") +HEAD = "0.9" # the version with the whitespace rules -def test_head_has_a_version_of_its_own(): - assert HEAD == "0.9" +def test_head_has_a_version_of_its_own_only_with_changes_of_its_own(monkeypatch): + from doclang.migration import _api + + monkeypatch.setattr(_api, "_head_description", lambda: "unreleased development version") + assert _head_version(NEWEST) == NEXT + monkeypatch.setattr(_api, "_head_description", lambda: NEWEST) # as right after a release + assert _head_version(NEWEST) is None def test_texts_are_stripped_and_unneeded_content_unwrapped(tmp_path): @@ -286,9 +295,9 @@ def test_head_as_source_of_an_undeclared_document(tmp_path, recwarn): def test_head_is_no_target_without_changes_of_its_own(tmp_path, monkeypatch): from doclang.migration import _api - monkeypatch.setattr(_api, "_head_description", lambda: "0.8") # as right after a release - with pytest.raises(UnsupportedVersionError, match=r"No schema for DocLang spec version '0\.9'"): - migrate(_doc(tmp_path, "x"), to="0.9") + monkeypatch.setattr(_api, "_head_description", lambda: NEWEST) # as right after a release + with pytest.raises(UnsupportedVersionError, match=rf"No schema for DocLang spec version '{re.escape(NEXT)}'"): + migrate(_doc(tmp_path, "x", version=NEWEST), to=NEXT) # --- validation of input and output ------------------------------------------------------------------ @@ -502,7 +511,7 @@ def drop_first_track(root, changes): _with_unreleased_head(monkeypatch, drop_first_track) members = {"audio/1.mp3": b"a1", "audio/2.ogg": b"a2", "video/3.mp4": b"v3", "assets/frames/2.jpg": b"f"} out = tmp_path / "out.dclx" - result = migrate(_archive(tmp_path, f"{TRACK}{FRAME_TRACK}{TRACK}", members), to=HEAD, output=out) + result = migrate(_archive(tmp_path, f"{TRACK}{FRAME_TRACK}{TRACK}", members, version=NEWEST), to=NEXT, output=out) assert [(c["kind"], c["location"]) for c in result.changes] == [ ("dropped", "/doclang/track[1]"), diff --git a/tests/test_validation.py b/tests/test_validation.py index e524a51..e69d6c8 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -6,6 +6,7 @@ """ import builtins +import re import sys from pathlib import Path from unittest.mock import patch @@ -296,16 +297,20 @@ def test_08_document_validates_against_the_released_08_schema(tmp_path): assert validate(track_08, spec_version="0.8") is None -def test_newer_declared_version_falls_back_to_the_development_head(tmp_path): - """The head differs from the released 0.8 schema, so the fallback message names it as unreleased.""" - newer = _versioned_doc(tmp_path, "0.9", _TRACK_BODY) +def test_newer_declared_version_falls_back_to_the_head(tmp_path): + """The fallback message names the head by its version if it is identical to that released schema, else as + unreleased.""" + newest = max(_available_versions(), key=lambda v: tuple(map(int, v.split(".")))) + major, minor = map(int, newest.split(".")) + newer = f"{major}.{minor + 1}" + doc = _versioned_doc(tmp_path, newer, _TRACK_BODY) + head = rf"({re.escape(newest)}|unreleased development version)" with pytest.warns( - UserWarning, - match=r"No schema for declared DocLang version 0\.9; .*latest schema \(unreleased development version\)\.", + UserWarning, match=rf"No schema for declared DocLang version {re.escape(newer)}; .*latest schema \({head}\)\." ): - assert validate(newer) is None - with pytest.raises(ValidationError, match=r"No schema for declared DocLang version '0\.9'"): - validate(newer, strict_declared_version=True) + assert validate(doc) is None + with pytest.raises(ValidationError, match=rf"No schema for declared DocLang version '{re.escape(newer)}'"): + validate(doc, strict_declared_version=True) def test_version_attribute_only_constrained_to_format(tmp_path): diff --git a/utils/rotate_migration_steps.py b/utils/rotate_migration_steps.py index 6270567..7d23ada 100644 --- a/utils/rotate_migration_steps.py +++ b/utils/rotate_migration_steps.py @@ -118,7 +118,7 @@ def _retarget_tests(tests_dir: Path, renames: dict[str, str]) -> None: """In the tests importing a development step, refer to the released step instead.""" if not tests_dir.is_dir(): return - imports = re.compile(r"_steps import (" + "|".join(renames) + r")\b") + imports = re.compile(r"^\s*from doclang\.migration\._steps import (" + "|".join(renames) + r")\b", re.MULTILINE) names = re.compile(r"\b(" + "|".join(renames) + r")\b") for path in sorted(tests_dir.rglob("*.py")): content = path.read_text(encoding="utf-8") From 6380404f60a0601f75cf1a80b08562146cf0b2f1 Mon Sep 17 00:00:00 2001 From: Panos Vagenas Date: Fri, 30 Oct 2026 13:15:12 +0100 Subject: [PATCH 3/3] inline the whitespace rules version in tests Signed-off-by: Panos Vagenas --- tests/test_migration.py | 49 +++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/tests/test_migration.py b/tests/test_migration.py index f30d8d3..7b18754 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -194,9 +194,6 @@ def mark(root, changes): assert "marked" in result.xml and f'version="{NEXT}"' in result.xml -HEAD = "0.9" # the version with the whitespace rules - - def test_head_has_a_version_of_its_own_only_with_changes_of_its_own(monkeypatch): from doclang.migration import _api @@ -212,14 +209,14 @@ def test_texts_are_stripped_and_unneeded_content_unwrapped(tmp_path): " of June, see \n the agenda\n.\n " "\n x y z\n" ) - result = migrate(_doc(tmp_path, body), to=HEAD) + result = migrate(_doc(tmp_path, body), to="0.9") - assert (result.source_version, result.target_version, result.changes) == ("0.8", HEAD, []) + assert (result.source_version, result.target_version, result.changes) == ("0.8", "0.9", []) assert ( # the target ignores the whitespace at the start and end of the body, so it stays "\n Held on the 21st of June, see the agenda.\n " ) in result.xml assert "xyz" in result.xml # whitespace next to tags was not guaranteed - assert f'\n ' in result.xml # root layout is kept + assert f'\n ' in result.xml # root layout is kept def test_whitespace_the_target_ignores_is_kept(tmp_path): @@ -230,7 +227,7 @@ def test_whitespace_the_target_ignores_is_kept(tmp_path): "\n \n \n c\n \n d\n " "\n \n \n e\n \n \n" ) - result = migrate(_doc(tmp_path, body), to=HEAD) + result = migrate(_doc(tmp_path, body), to="0.9") head = "\n ".join(f'' for v in (1, 2, 3, 4)) assert f"\n {head}\n ab\n " in result.xml assert "\n \n c\n \n d\n " in result.xml @@ -241,54 +238,54 @@ def test_whitespace_between_paragraphs_of_a_list_item_is_kept(tmp_path): body = ( "\n \n First.\n Second.\n Fruit: apples\n" ) - result = migrate(_doc(tmp_path, body), to=HEAD) + result = migrate(_doc(tmp_path, body), to="0.9") assert "\n First.\n Second.\n Fruit:apples" in result.xml def test_content_comments_and_interior_whitespace_are_kept(tmp_path): body = "\n def f():\n pass\na b\n c d" - result = migrate(_doc(tmp_path, body), to=HEAD) + result = migrate(_doc(tmp_path, body), to="0.9") assert "\n def f():\n pass\n" in result.xml assert "a b\n cd" in result.xml # interior whitespace reads as one space anyway def test_cdata_sections_the_step_does_not_change_are_kept(tmp_path): - result = migrate(_doc(tmp_path, "x"), to=HEAD) + result = migrate(_doc(tmp_path, "x"), to="0.9") assert "" in result.xml def test_whitespace_other_than_space_tab_and_line_breaks_is_kept(tmp_path): - result = migrate(_doc(tmp_path, "\u00a0x\u2003"), to=HEAD) + result = migrate(_doc(tmp_path, "\u00a0x\u2003"), to="0.9") assert "\u00a0x\u2003" in result.xml def test_older_documents_get_the_head_steps_too(tmp_path): - result = migrate(_doc(tmp_path, "x y", version="0.7"), to=HEAD) - assert "xy" in result.xml and f'version="{HEAD}"' in result.xml + result = migrate(_doc(tmp_path, "x y", version="0.7"), to="0.9") + assert "xy" in result.xml and 'version="0.9"' in result.xml def test_head_documents_migrate_down_without_a_fallback_warning(tmp_path, recwarn): - result = migrate(_doc(tmp_path, "x", version=HEAD), to="0.8") - assert result.source_version == HEAD + result = migrate(_doc(tmp_path, "x", version="0.9"), to="0.8") + assert result.source_version == "0.9" assert not [w for w in recwarn if "No schema" in str(w.message)] def test_undeclared_version_to_the_head_only_gets_declared(tmp_path): with pytest.warns(UserWarning, match="No DocLang version declared"): - result = migrate(_doc(tmp_path, " x ", version=None), to=HEAD) - assert " x " in result.xml and f'version="{HEAD}"' in result.xml + result = migrate(_doc(tmp_path, " x ", version=None), to="0.9") + assert " x " in result.xml and 'version="0.9"' in result.xml def test_undeclared_document_from_an_older_source_gets_the_head_steps(tmp_path, recwarn): - result = migrate(_doc(tmp_path, "a b", version=None), source="0.8", to=HEAD) - assert (result.source_version, result.target_version) == ("0.8", HEAD) - assert "a b" in result.xml and f'version="{HEAD}"' in result.xml + result = migrate(_doc(tmp_path, "a b", version=None), source="0.8", to="0.9") + assert (result.source_version, result.target_version) == ("0.8", "0.9") + assert "a b" in result.xml and 'version="0.9"' in result.xml assert not [w for w in recwarn if "No DocLang version declared" in str(w.message)] def test_head_as_source_of_an_undeclared_document(tmp_path, recwarn): - result = migrate(_doc(tmp_path, " x ", version=None), source=HEAD, to=HEAD) - assert result.source_version == HEAD and " x " in result.xml + result = migrate(_doc(tmp_path, " x ", version=None), source="0.9", to="0.9") + assert result.source_version == "0.9" and " x " in result.xml assert not [w for w in recwarn if "No DocLang version declared" in str(w.message)] @@ -577,7 +574,7 @@ def test_every_valid_fixture_migrates_from_the_newest_release_to_the_head(fixtur validate(doc, spec_version="0.8", allow_empty_namespace=True) except ValidationError: pytest.skip("uses what the newest release cannot express") - assert migrate(doc, to=HEAD).target_version == HEAD + assert migrate(doc, to="0.9").target_version == "0.9" # --- file-based cases ---------------------------------------------------------------------------- @@ -656,7 +653,7 @@ def document(out: Path): def test_text_with_text_level_content_in_a_marker_is_unwrapped(tmp_path): body = "a)x" - result = migrate(_doc(tmp_path, body, version=HEAD), to="0.8") + result = migrate(_doc(tmp_path, body, version="0.9"), to="0.8") assert "a)" in result.xml assert [(c["kind"], c["location"]) for c in result.changes] == [("mapped", "/doclang/list/ldiv/marker/text")] @@ -667,7 +664,7 @@ def test_other_semantic_elements_in_a_marker_are_dropped(tmp_path): f"{head}1xb y" "x" ) - result = migrate(_doc(tmp_path, body, version=HEAD), to="0.8") + result = migrate(_doc(tmp_path, body, version="0.9"), to="0.8") assert f"{head}1" in result.xml # the marker's head stays assert [(c["kind"], c["location"]) for c in result.changes] == [ ("dropped", "/doclang/list/ldiv/marker/picture"), @@ -678,7 +675,7 @@ def test_other_semantic_elements_in_a_marker_are_dropped(tmp_path): def test_nested_markers_are_unwrapped_from_the_inside(tmp_path): body = "ix" - result = migrate(_doc(tmp_path, body, version=HEAD), to="0.8") + result = migrate(_doc(tmp_path, body, version="0.9"), to="0.8") assert "i" in result.xml assert [(c["kind"], c["location"]) for c in result.changes] == [ ("mapped", "/doclang/list/ldiv/marker/marker"),