Files
chemenu/tools/chemenu/tests/test_version_cmd.py
T
torben d29d400dd3
CI / verify (push) Successful in 47s
Release / release (push) Successful in 35s
feat: Versionskandidat statt Bump-pro-Release - VERSION traegt -beta.N, version release fixiert (4.4.0, #42)
Files changed:
- .gitea/workflows/release.yml
- AGENTS.md
- CHANGES.md
- DEVELOPMENT.md
- README.md
- VERSION
- docs/version-model.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/kb_state.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-03 22:19:41 +02:00

681 lines
26 KiB
Python

"""Tests for the stack version: parsing and the compatibility rule, reading
`VERSION`/the release stamp, `version bump`'s two writes, changelog extraction,
and `version check` against a stubbed feed (never a real network)."""
from __future__ import annotations
import json
import urllib.error
from pathlib import Path
import pytest
import typer
from chemenu import config, version as version_mod
from chemenu.commands import version_cmd
from chemenu.version import Version, VersionError
CHANGES_HEADER = "# Changelog\n\nPreamble.\n\n---\n\n"
@pytest.fixture
def tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A tree with the files the version machinery reads, plus an empty
migrations directory so the boundary gate has somewhere to look."""
(tmp_path / "VERSION").write_text("1.0.0\n", encoding="utf-8")
(tmp_path / "CHANGES.md").write_text(
CHANGES_HEADER + "## 1.0.0 - 2026-08-30 - First\n\n**Author:** Someone\n\nBody.\n",
encoding="utf-8",
)
instructions = tmp_path / "instructions"
(instructions / "migrations").mkdir(parents=True)
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "INSTRUCTIONS_DIR", instructions)
monkeypatch.setenv("WIKI_AUTHOR", "Test Author")
return tmp_path
# --- the version itself ----------------------------------------------------
@pytest.mark.parametrize("text", ["1.2.3", " 1.2.3 ", "v1.2.3", "1.2.3\n"])
def test_parse_accepts_the_forms_a_tag_or_a_file_produces(text):
assert Version.parse(text) == Version(1, 2, 3)
@pytest.mark.parametrize("text", ["1.2", "1.2.3.4", "x.y.z", "", "1.2.3-rc1"])
def test_parse_rejects_anything_else(text):
with pytest.raises(VersionError):
Version.parse(text)
@pytest.mark.parametrize(
"part,expected",
[("major", "2.0.0"), ("minor", "1.3.0"), ("patch", "1.2.4")],
)
def test_bump_resets_everything_to_the_right_of_it(part, expected):
assert str(Version(1, 2, 3).bumped(part)) == expected
def test_compat_key_is_the_leftmost_nonzero_prefix():
"""This stack starts at 1.0.0, so in practice the boundary is MAJOR. The
rule is stated generally anyway - one uniform comparison rather than a
version-range special case - and the 0.x rows pin that generality down."""
assert Version(0, 1, 3).compat_key == (0, 1)
assert Version(0, 1, 9).compat_key == (0, 1)
assert Version(0, 2, 0).compat_key == (0, 2)
assert Version(1, 2, 3).compat_key == (1,)
assert Version(2, 0, 0).compat_key == (2,)
assert Version(0, 0, 4).compat_key == (0, 0, 4)
@pytest.mark.parametrize(
"local,latest,state",
[
("0.1.0", "0.1.0", "current"),
("0.1.0", "0.1.4", "update"),
("0.1.0", "0.2.0", "migration"),
("1.4.0", "1.9.2", "update"),
("1.4.0", "2.0.0", "migration"),
("0.2.0", "0.1.0", "ahead"),
],
)
def test_compare_separates_a_compatible_update_from_a_migration(local, latest, state):
assert version_mod.compare(Version.parse(local), Version.parse(latest)) == state
# --- candidates: parsing and ordering ---------------------------------------
@pytest.mark.parametrize("text", ["4.4.0-beta.1", "4.4.0-beta.10", "v4.4.0-beta.2"])
def test_parse_accepts_a_candidate_suffix(text):
version = Version.parse(text)
assert version.is_prerelease
assert version.beta == int(text.rsplit(".", 1)[1])
def test_a_release_has_no_beta():
version = Version.parse("4.4.0")
assert not version.is_prerelease
assert version.beta is None
@pytest.mark.parametrize(
"lesser,greater",
[
("4.4.0-beta.1", "4.4.0"),
("4.4.0-beta.1", "4.4.0-beta.2"),
("4.4.0-beta.9", "4.4.0-beta.10"), # numeric, not lexicographic
("4.4.0-beta.9", "4.4.1"),
],
)
def test_a_candidate_sorts_before_its_release_and_by_numeric_beta(lesser, greater):
assert Version.parse(lesser) < Version.parse(greater)
assert Version.parse(greater) > Version.parse(lesser)
def test_base_strips_the_candidate_suffix():
assert str(Version.parse("4.4.0-beta.3").base) == "4.4.0"
assert Version.parse("4.4.0").base == Version.parse("4.4.0")
def test_bumped_always_returns_a_release_even_from_a_candidate():
"""`bumped()` answers "what would the next fixed version be" - it is
`escalate()` that knows about running candidates."""
assert not Version.parse("4.4.0-beta.3").bumped("patch").is_prerelease
# --- candidates: escalation --------------------------------------------------
def test_escalate_opens_the_first_candidate_at_beta_one():
release = Version.parse("4.3.3")
candidate = version_mod.escalate(release, release, "minor")
assert str(candidate) == "4.4.0-beta.1"
def test_escalate_on_the_same_stage_only_advances_the_bump_count():
release = Version.parse("4.3.3")
first = version_mod.escalate(release, release, "minor")
second = version_mod.escalate(release, first, "patch")
assert str(second) == "4.4.0-beta.2"
def test_escalate_never_steps_back_down():
release = Version.parse("1.4.0")
major = version_mod.escalate(release, release, "major")
still_major = version_mod.escalate(release, major, "patch")
assert still_major.base == major.base
assert still_major.beta == 2
def test_escalate_raises_the_base_and_resets_the_bump_count():
release = Version.parse("4.3.3")
minor = version_mod.escalate(release, release, "minor")
major = version_mod.escalate(release, minor, "major")
assert str(major) == "5.0.0-beta.1"
def test_escalate_with_no_last_release_bumps_the_current_version_directly():
"""The fresh-distribution edge case: a changelog with no versioned entry at
all opens a candidate straight from `current`, rather than failing."""
fresh = Version.parse("0.1.0")
candidate = version_mod.escalate(None, fresh, "patch")
assert str(candidate) == "0.1.1-beta.1"
def test_a_migration_headline_says_so_rather_than_just_being_louder():
status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
assert "migration" in status.headline.lower()
# --- reading the tree ------------------------------------------------------
def test_read_version_reads_the_file(tree):
assert version_mod.read_version() == Version(1, 0, 0)
def test_a_missing_version_file_raises_rather_than_guessing(tree):
(tree / "VERSION").unlink()
with pytest.raises(VersionError):
version_mod.read_version()
def test_no_stamp_is_a_normal_answer_for_a_dev_tree(tree):
assert version_mod.read_stamp() is None
def test_a_malformed_stamp_raises(tree):
(tree / version_mod.RELEASE_STAMP_FILENAME).write_text("{not json", encoding="utf-8")
with pytest.raises(VersionError):
version_mod.read_stamp()
def test_update_url_prefers_env_then_stamp_then_default(tree, monkeypatch):
stamp = {"update_url": "https://stamp.example/feed"}
assert version_mod.update_url(None) == version_mod.DEFAULT_UPDATE_URL
assert version_mod.update_url(stamp) == "https://stamp.example/feed"
monkeypatch.setenv(version_mod.UPDATE_URL_ENV, "https://env.example/feed")
assert version_mod.update_url(stamp) == "https://env.example/feed"
# --- the changelog ---------------------------------------------------------
def test_top_changes_version_ignores_pre_versioning_date_headings():
text = CHANGES_HEADER + "## 2026-08-01 - Older, unversioned\n\nBody.\n"
assert version_mod.top_changes_version(text) is None
def test_top_changes_version_finds_the_newest_versioned_entry():
text = (
CHANGES_HEADER
+ "## 0.2.0 - 2026-09-01 - Newer\n\nBody.\n\n---\n\n"
+ "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
)
assert version_mod.top_changes_version(text) == Version(0, 2, 0)
def test_changes_section_returns_one_entry_without_the_separator():
text = (
CHANGES_HEADER
+ "## 0.2.0 - 2026-09-01 - Newer\n\nNew body.\n\n---\n\n"
+ "## 0.1.0 - 2026-08-29 - Older\n\nOld body.\n"
)
section = version_mod.changes_section(text, Version(0, 2, 0))
assert "New body." in section
assert "Old body." not in section
assert not section.rstrip().endswith("---")
def test_changes_section_stops_at_a_pre_versioning_dated_entry():
"""Regression: terminating on the next *versioned* heading ran the newest
entry to the end of the file, because every entry below 0.1.0 is headed by
a date instead."""
text = (
CHANGES_HEADER
+ "## 0.1.0 - 2026-08-29 - Newest\n\nNew body.\n\n---\n\n"
+ "## 2026-08-01 - Before versioning\n\nAncient body.\n"
)
section = version_mod.changes_section(text, Version(0, 1, 0))
assert "New body." in section
assert "Ancient body." not in section
assert "Before versioning" not in section
def test_changes_section_is_none_for_an_undocumented_version():
assert version_mod.changes_section(CHANGES_HEADER, Version(9, 9, 9)) is None
def test_last_release_skips_an_open_candidate_above_it():
text = (
CHANGES_HEADER
+ "## 0.2.0-beta.1 - 2026-09-04 - Candidate\n\nBody.\n\n---\n\n"
+ "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
)
assert version_mod.last_release(text) == Version(0, 1, 0)
def test_last_release_is_none_with_no_versioned_entry_at_all():
text = CHANGES_HEADER + "## 2026-08-01 - Before versioning\n\nBody.\n"
assert version_mod.last_release(text) is None
def test_insert_changes_entry_opens_a_fresh_candidate_above_the_newest_entry():
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
result = version_mod.insert_changes_entry(
text, Version(0, 2, 0, beta=1), "2026-09-01", "Newer", "Someone"
)
assert result.index("## 0.2.0-beta.1") < result.index("## 0.1.0")
assert "Preamble." in result
assert "- Newer" in result # the bump-title list seeds itself with this title
assert version_mod.top_changes_version(result) == Version(0, 2, 0, beta=1)
def test_insert_changes_entry_updates_an_open_candidate_in_place():
"""The second bump of the same candidate must not open a second entry -
one entry per running candidate, per instructions/dev/version-parts.md."""
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
first = version_mod.insert_changes_entry(
text, Version(0, 2, 0, beta=1), "2026-09-01", "First title", "Someone"
)
second = version_mod.insert_changes_entry(
first, Version(0, 2, 0, beta=2), "2026-09-02", "Second title", "Someone"
)
assert second.count("## 0.2.0") == 1
assert "## 0.2.0-beta.2 - 2026-09-02 - Second title" in second
assert "- First title" in second
assert "- Second title" in second
assert "## 0.1.0" in second # the older, already-released entry survives untouched
def test_insert_changes_entry_keeps_the_breaking_line_across_a_later_bump():
text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n"
first = version_mod.insert_changes_entry(
text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone",
breaking_reason="the feed moved", no_migration_reason="kb untouched",
)
second = version_mod.insert_changes_entry(
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
)
assert version_mod.BREAKING_CHANGE_MARKER in second
assert "the feed moved" in second
assert version_mod.MIGRATION_NONE_MARKER in second
assert "kb untouched" in second
def test_release_entry_fixes_the_heading_and_keeps_the_bump_titles():
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\n<!-- wikitool:bumps -->\n- First title\n- Second title\n<!-- /wikitool:bumps -->\n\nBody.\n"
released = version_mod.release_entry(text, "2026-09-05")
assert "## 0.2.0 - 2026-09-05 - Second title" in released
assert "- First title" in released
assert "- Second title" in released
def test_release_entry_can_replace_the_title():
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\nBody.\n"
released = version_mod.release_entry(text, "2026-09-05", title="Summarising title")
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
# --- version bump ----------------------------------------------------------
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Something happened",
breaking=None, no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert "## 1.1.0-beta.1 - " in changes
assert "Something happened" in changes
assert "**Author:** Test Author" in changes
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second",
breaking=None, no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert changes.count("## 1.1.0") == 1
assert "First" in changes and "Second" in changes
def test_bump_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
@pytest.mark.parametrize(
"flags", [(False, False, False), (True, True, False), (True, False, True)]
)
def test_bump_demands_exactly_one_part(tree, flags):
major, minor, patch = flags
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, dry_run=False
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
def test_bump_refuses_an_empty_title(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, dry_run=False
)
def test_bump_refuses_when_version_and_changelog_disagree(tree):
"""A changelog whose newest entry names a different version than VERSION
means someone edited one of the two by hand; bumping past it would hide
that instead of surfacing it."""
(tree / "CHANGES.md").write_text(
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8"
)
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, dry_run=False
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
# --- version bump: the compatibility boundary ------------------------------
def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree):
"""An instance being told it must migrate, with nothing telling it how, is
the gap this closes."""
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
migrations = tree / "instructions" / "migrations"
(migrations / "2.0.0-retype.md").write_text(
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
"description: Retype every page.\nmanual: true\n"
"migrates_to: 2.0.0\nmigration_kind: assisted\n---\n\n# M\n",
encoding="utf-8",
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migration(tree):
"""Finding #4: the requirement fires once, at the bump that first escalates
to the boundary; a later bump of the same candidate is not asked again."""
migrations = tree / "instructions" / "migrations"
(migrations / "2.0.0-retype.md").write_text(
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
"description: Retype every page.\nmanual: true\n"
"migrates_to: 2.0.0\nmigration_kind: assisted\n---\n\n# M\n",
encoding="utf-8",
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, dry_run=False,
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Follow-up",
breaking=None, no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.BREAKING_CHANGE_MARKER in changes
assert "every page is retyped" in changes
def test_no_migration_records_the_reason_in_the_changelog(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the release feed moved",
no_migration="no distributed instance exists yet", dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER in changes
assert "no distributed instance exists yet" in changes
def test_no_migration_is_refused_on_a_compatible_bump(tree):
"""It would otherwise become a habit rather than a statement."""
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Fix",
breaking=None, no_migration="not needed", dry_run=False,
)
# --- version bump: the breaking-change note --------------------------------
def test_a_boundary_crossing_bump_without_breaking_is_refused(tree):
"""The corpus question and the drop-in question are independent: a bump
can migrate nothing and still leave every instance with hand-work."""
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Renamed the feed",
breaking=None, no_migration="kb/ keeps its shape", dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
def test_breaking_records_what_stops_working_in_the_changelog(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Renamed the feed",
breaking="update_url points at a repo path that no longer exists",
no_migration="kb/ keeps its shape", dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.BREAKING_CHANGE_MARKER in changes
assert "update_url points at a repo path that no longer exists" in changes
# The break comes before the migration note: it is what an operator acts on.
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(
version_mod.MIGRATION_NONE_MARKER
)
def test_breaking_is_refused_on_a_compatible_bump(tree):
"""A compatible bump that claims a break is describing itself wrongly -
one of the two is a mistake, and the command will not guess which."""
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="New command",
breaking="nothing, really", no_migration=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
# --- version release --------------------------------------------------------
def test_release_fixes_version_and_the_changelog_heading(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert "## 1.1.0 - " in changes
assert "-beta." not in changes.split("## 1.1.0")[1].split("## ")[0]
assert "First bump" in changes # kept, since --title was not given
def test_release_can_replace_the_title(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert "## 1.1.0 - " in changes
assert "Summary of both bumps" in changes
# the machine-managed bump list is left as the record of what happened
assert "First bump" in changes
assert "Second bump" in changes
def test_release_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.release_command(title=None, dry_run=True)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
def test_release_refuses_when_version_is_already_a_release(tree):
with pytest.raises(typer.Exit):
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
def test_release_refuses_when_version_and_changelog_disagree(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
with pytest.raises(typer.Exit):
version_cmd.release_command(title=None, dry_run=False)
# --- version notes ---------------------------------------------------------
def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
version_cmd.notes_command(version=None)
assert "## 1.0.0" in capsys.readouterr().out
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Second bump",
breaking=None, no_migration=None, dry_run=False,
)
version_cmd.notes_command(version=None)
out = capsys.readouterr().out
assert "## 1.1.0-beta.2" in out
assert "First bump" in out and "Second bump" in out
def test_notes_fails_for_a_version_with_no_entry(tree):
with pytest.raises(typer.Exit):
version_cmd.notes_command(version="9.9.9")
# --- version check ---------------------------------------------------------
def _feed(payload: dict):
def fetcher(url: str, token, timeout: float) -> bytes:
return json.dumps(payload).encode("utf-8")
return fetcher
def test_fetch_latest_reads_the_tag(tree):
assert version_mod.fetch_latest(
"https://example/feed", fetcher=_feed({"tag_name": "v0.4.2"})
) == Version(0, 4, 2)
def test_fetch_latest_release_also_returns_url_and_date(tree):
version, url, published = version_mod.fetch_latest_release(
"https://example/feed",
fetcher=_feed(
{"tag_name": "0.4.2", "html_url": "https://example/r/0.4.2", "published_at": "2026-09-01"}
),
)
assert (str(version), url, published) == ("0.4.2", "https://example/r/0.4.2", "2026-09-01")
def test_a_feed_without_a_tag_is_an_error_not_an_answer(tree):
with pytest.raises(VersionError):
version_mod.fetch_latest("https://example/feed", fetcher=_feed({"message": "nope"}))
def test_an_unreachable_feed_is_an_error_not_up_to_date(tree):
"""The failure mode worth a test of its own: reporting "no update" when
the question was never answered."""
def refuse(url, token, timeout):
raise urllib.error.URLError("connection refused")
with pytest.raises(VersionError) as exc:
version_mod.fetch_latest("https://example/feed", fetcher=refuse)
assert "Could not reach" in str(exc.value)
def test_an_authenticated_feed_names_the_token_variable(tree):
def unauthorized(url, token, timeout):
raise urllib.error.HTTPError(url, 401, "Unauthorized", {}, None)
with pytest.raises(VersionError) as exc:
version_mod.fetch_latest("https://example/feed", fetcher=unauthorized)
assert version_mod.UPDATE_TOKEN_ENV in str(exc.value)
def test_check_reports_a_migration_in_json(tree, monkeypatch, capsys):
monkeypatch.setattr(
version_mod, "fetch_latest_release", lambda *a, **k: (Version(2, 0, 0), None, None)
)
version_cmd.check_command(url="https://example/feed", timeout=1.0, json_out=True)
result = json.loads(capsys.readouterr().out)
assert result["state"] == "migration"
assert result["requires_migration"] is True
def test_check_exits_nonzero_when_the_feed_cannot_be_reached(tree, monkeypatch):
def refuse(*args, **kwargs):
raise VersionError("Could not reach https://example/feed")
monkeypatch.setattr(version_mod, "fetch_latest_release", refuse)
with pytest.raises(typer.Exit):
version_cmd.check_command(url="https://example/feed", timeout=1.0, json_out=False)
# --- version show ----------------------------------------------------------
def test_show_json_carries_the_stamp_and_the_feed(tree):
(tree / version_mod.RELEASE_STAMP_FILENAME).write_text(
json.dumps({"version": "1.0.0", "update_url": "https://stamp.example/feed"}),
encoding="utf-8",
)
import io
import contextlib
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
version_cmd.show_command(json_out=True)
result = json.loads(buffer.getvalue())
assert result["version"] == "1.0.0"
assert result["update_url"] == "https://stamp.example/feed"
assert result["stamp"]["update_url"] == "https://stamp.example/feed"