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
74 changes: 40 additions & 34 deletions tests/test_migration.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Tests for migrating documents to another spec version."""

import re
import sys
import zipfile
from pathlib import Path

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

Expand All @@ -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 = '<track><bdiv/><minutes value="0"/><seconds value="2"/>Hello.</track>'


Expand Down Expand Up @@ -185,16 +189,18 @@ def mark(root, changes):
root[0].text = "marked"

_with_unreleased_head(monkeypatch, mark)
result = migrate(_doc(tmp_path, "<text>x</text>"), to="0.9")
assert (result.source_version, result.target_version) == ("0.8", "0.9")
assert "<text>marked</text>" in result.xml and 'version="0.9"' in result.xml

result = migrate(_doc(tmp_path, "<text>x</text>", version=NEWEST), to=NEXT)
assert (result.source_version, result.target_version) == (NEWEST, NEXT)
assert "<text>marked</text>" in result.xml and f'version="{NEXT}"' in result.xml

HEAD = _head_version("0.8")

def test_head_has_a_version_of_its_own_only_with_changes_of_its_own(monkeypatch):
from doclang.migration import _api

def test_head_has_a_version_of_its_own():
assert HEAD == "0.9"
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):
Expand All @@ -203,14 +209,14 @@ def test_texts_are_stripped_and_unneeded_content_unwrapped(tmp_path):
" <content> of June, see </content>\n <italic>the agenda</italic>\n.\n </text>"
"\n <text>x <bold>y</bold> z</text>\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
"<text>\n Held on the 21<superscript>st</superscript> of June, see <italic>the agenda</italic>.\n </text>"
) in result.xml
assert "<text>x<bold>y</bold>z</text>" in result.xml # whitespace next to tags was not guaranteed
assert f'<doclang xmlns="{NS}" version="{HEAD}">\n <text>' in result.xml # root layout is kept
assert f'<doclang xmlns="{NS}" version="0.9">\n <text>' in result.xml # root layout is kept


def test_whitespace_the_target_ignores_is_kept(tmp_path):
Expand All @@ -221,7 +227,7 @@ def test_whitespace_the_target_ignores_is_kept(tmp_path):
"\n <list>\n <ldiv/>\n c\n <ldiv/>\n <italic>d</italic>\n </list>"
"\n <field_region>\n <field_item>\n <key>e</key>\n </field_item>\n </field_region>\n"
)
result = migrate(_doc(tmp_path, body), to=HEAD)
result = migrate(_doc(tmp_path, body), to="0.9")
head = "\n ".join(f'<location value="{v}"/>' for v in (1, 2, 3, 4))
assert f"<text>\n {head}\n a<bold>b</bold>\n </text>" in result.xml
assert "<list>\n <ldiv/>\n c\n <ldiv/>\n <italic>d</italic>\n </list>" in result.xml
Expand All @@ -232,63 +238,63 @@ def test_whitespace_between_paragraphs_of_a_list_item_is_kept(tmp_path):
body = (
"<list>\n <ldiv/>\n <text>First.</text>\n <text>Second.</text>\n <ldiv/>Fruit: <text>apples</text>\n</list>"
)
result = migrate(_doc(tmp_path, body), to=HEAD)
result = migrate(_doc(tmp_path, body), to="0.9")
assert "<ldiv/>\n <text>First.</text>\n <text>Second.</text>\n <ldiv/>Fruit:<text>apples</text>" in result.xml


def test_content_comments_and_interior_whitespace_are_kept(tmp_path):
body = "<code><content>\n def f():\n pass\n</content></code><text>a b\n c<!-- note --> d</text>"
result = migrate(_doc(tmp_path, body), to=HEAD)
result = migrate(_doc(tmp_path, body), to="0.9")
assert "<content>\n def f():\n pass\n</content>" in result.xml
assert "<text>a b\n c<!-- note -->d</text>" 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, "<text>x<formula><![CDATA[p<0.001]]></formula></text>"), to=HEAD)
result = migrate(_doc(tmp_path, "<text>x<formula><![CDATA[p<0.001]]></formula></text>"), to="0.9")
assert "<formula><![CDATA[p<0.001]]></formula>" in result.xml


def test_whitespace_other_than_space_tab_and_line_breaks_is_kept(tmp_path):
result = migrate(_doc(tmp_path, "<text>\u00a0x\u2003</text>"), to=HEAD)
result = migrate(_doc(tmp_path, "<text>\u00a0x\u2003</text>"), to="0.9")
assert "<text>\u00a0x\u2003</text>" in result.xml


def test_older_documents_get_the_head_steps_too(tmp_path):
result = migrate(_doc(tmp_path, "<text>x <bold>y</bold></text>", version="0.7"), to=HEAD)
assert "<text>x<bold>y</bold></text>" in result.xml and f'version="{HEAD}"' in result.xml
result = migrate(_doc(tmp_path, "<text>x <bold>y</bold></text>", version="0.7"), to="0.9")
assert "<text>x<bold>y</bold></text>" 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, "<text>x</text>", version=HEAD), to="0.8")
assert result.source_version == HEAD
result = migrate(_doc(tmp_path, "<text>x</text>", 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, "<text> x </text>", version=None), to=HEAD)
assert "<text> x </text>" in result.xml and f'version="{HEAD}"' in result.xml
result = migrate(_doc(tmp_path, "<text> x </text>", version=None), to="0.9")
assert "<text> x </text>" 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, "<text>a<content> b</content></text>", version=None), source="0.8", to=HEAD)
assert (result.source_version, result.target_version) == ("0.8", HEAD)
assert "<text>a b</text>" in result.xml and f'version="{HEAD}"' in result.xml
result = migrate(_doc(tmp_path, "<text>a<content> b</content></text>", version=None), source="0.8", to="0.9")
assert (result.source_version, result.target_version) == ("0.8", "0.9")
assert "<text>a b</text>" 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, "<text> x </text>", version=None), source=HEAD, to=HEAD)
assert result.source_version == HEAD and "<text> x </text>" in result.xml
result = migrate(_doc(tmp_path, "<text> x </text>", version=None), source="0.9", to="0.9")
assert result.source_version == "0.9" and "<text> x </text>" in result.xml
assert not [w for w in recwarn if "No DocLang version declared" in str(w.message)]


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, "<text>x</text>"), 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, "<text>x</text>", version=NEWEST), to=NEXT)


# --- validation of input and output ------------------------------------------------------------------
Expand Down Expand Up @@ -502,7 +508,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]"),
Expand Down Expand Up @@ -568,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 ----------------------------------------------------------------------------
Expand Down Expand Up @@ -647,7 +653,7 @@ def document(out: Path):

def test_text_with_text_level_content_in_a_marker_is_unwrapped(tmp_path):
body = "<list><ldiv><marker><text>a<bold>)</bold></text></marker></ldiv>x</list>"
result = migrate(_doc(tmp_path, body, version=HEAD), to="0.8")
result = migrate(_doc(tmp_path, body, version="0.9"), to="0.8")
assert "<marker>a<bold>)</bold></marker>" in result.xml
assert [(c["kind"], c["location"]) for c in result.changes] == [("mapped", "/doclang/list/ldiv/marker/text")]

Expand All @@ -658,7 +664,7 @@ def test_other_semantic_elements_in_a_marker_are_dropped(tmp_path):
f"<list><ldiv><marker>{head}<picture/>1<formula>x</formula><text>b <formula>y</formula></text>"
"</marker></ldiv>x</list>"
)
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"<marker>{head}1</marker>" in result.xml # the marker's head stays
assert [(c["kind"], c["location"]) for c in result.changes] == [
("dropped", "/doclang/list/ldiv/marker/picture"),
Expand All @@ -669,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 = "<list><ldiv><marker><marker><picture/><hint><italic>i</italic></hint></marker></marker></ldiv>x</list>"
result = migrate(_doc(tmp_path, body, version=HEAD), to="0.8")
result = migrate(_doc(tmp_path, body, version="0.9"), to="0.8")
assert "<marker><italic>i</italic></marker>" in result.xml
assert [(c["kind"], c["location"]) for c in result.changes] == [
("mapped", "/doclang/list/ldiv/marker/marker"),
Expand Down
22 changes: 21 additions & 1 deletion tests/test_prepare_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
21 changes: 13 additions & 8 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import builtins
import re
import sys
from pathlib import Path
from unittest.mock import patch
Expand Down Expand Up @@ -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):
Expand Down
19 changes: 18 additions & 1 deletion utils/rotate_migration_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- doclang/migration/_steps/from_next.py -> v<NEW>_to_<PREVIOUS>.py (e.g. v0_8_to_0_7.py), down
- doclang/migration/_steps/to_next.py -> v<PREVIOUS>_to_<NEW>.py (e.g. v0_8_to_0_9.py), up
<PREVIOUS> 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/<NEW>/.
Expand Down Expand Up @@ -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


Expand All @@ -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"^\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")
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)")
Expand Down
Loading