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
16 changes: 15 additions & 1 deletion .ci/generation/py2cs/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Command line: python -m py2cs check | manifest | diff | align | repos | plan | pr | close | verify | drafts | resolve | notes"""
"""Command line: python -m py2cs check | manifest | diff | align | repos | plan | pr | close | verify | drafts | resolve | notes | alpha-version"""

from __future__ import annotations

Expand All @@ -10,6 +10,7 @@
import tempfile
from pathlib import Path

from py2cs.alpha import alpha_version, read_bhom_version, stamp_version_file
from py2cs.align import AlignError, align, changed_types, generate
from py2cs.check import check_package
from py2cs.ci import affected_repos, load_allowed, make_client, verify_package
Expand Down Expand Up @@ -108,6 +109,11 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--previous", help="the previous release's tag, named in the notes")
p.add_argument("--out", required=True, type=Path)

p = sub.add_parser("alpha-version", help="the version of an alpha release, built from the BHoM version of a bhom_schema folder; fails when that BHoM version is already released")
p.add_argument("--package", required=True, type=Path)
p.add_argument("--tags", nargs="*", default=[], help="the release tags that exist, for example v10.0.0")
p.add_argument("--stamp", action="store_true", help="also write the version into the package's _version.py")

args = parser.parse_args(argv)
try:
return run(args)
Expand Down Expand Up @@ -152,6 +158,14 @@ def run(args: argparse.Namespace) -> int:
print(f"release notes for {len(diff.changes)} changed types")
return 0

if args.command == "alpha-version":
version_file = args.package / "_version.py"
version = alpha_version(read_bhom_version(version_file), args.tags)
if args.stamp:
stamp_version_file(version_file, version)
print(version)
return 0

if args.command == "align":
base, head = read_package(args.base_root), read_package(args.head_root)
changed, removed = changed_types(base, head)
Expand Down
49 changes: 49 additions & 0 deletions .ci/generation/py2cs/alpha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""The version of an alpha release: the BHoM version of develop with a UTC time stamp, as a PEP 440 pre-release."""

from __future__ import annotations
import re
from datetime import datetime, timezone
from pathlib import Path

BHOM_VERSION = re.compile(r'^__bhom_version__ = "(\d+\.\d+)"$', re.MULTILINE)
PACKAGE_VERSION = re.compile(r'^__version__ = ".*"$', re.MULTILINE)

def read_bhom_version(version_file: Path) -> str:
"""Returns the BHoM version (Major.Minor) that the generated _version.py holds.

`alpha_version` builds the alpha from it.
"""
match = BHOM_VERSION.search(version_file.read_text(encoding="utf-8"))
if match is None:
raise ValueError(f"{version_file} has no __bhom_version__ of the form Major.Minor")

return match.group(1)

def alpha_version(bhom_version: str, released_tags: list[str], now: datetime | None = None) -> str:
"""Returns `Major.Minor.0aYYMMDDHHmm` for the BHoM version, with the UTC time of `now` (the current time when left out).

Fails when the tag `vMajor.Minor.0` is among the released tags: the alpha would sort below that release, so develop must first move to the next BHoM version.
The alpha workflow calls it.
"""
if f"v{bhom_version}.0" in released_tags:
raise ValueError(
f"develop still reports BHoM {bhom_version}, which is already released as v{bhom_version}.0, so an alpha would sort below that release. "
"Bump the BHoM version on develop to the next Major.Minor and let the next generation write it, then run the alpha again."
)

moment = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
return f"{bhom_version}.0a{moment:%y%m%d%H%M}"

def stamp_version_file(version_file: Path, version: str) -> None:
"""Writes the version as `__version__` into _version.py, replacing the development version.

The alpha workflow does it in its own checkout only, so develop keeps its development version.
"""
text = version_file.read_text(encoding="utf-8")
stamped, count = PACKAGE_VERSION.subn(f'__version__ = "{version}"', text)
if count != 1:
raise ValueError(f"{version_file} must have exactly one __version__ line")

# newline="" keeps the LF endings of the generated file
with version_file.open("w", encoding="utf-8", newline="") as stream:
stream.write(stamped)
119 changes: 119 additions & 0 deletions .ci/unit-tests/python/test_py2cs/test_alpha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from packaging.version import Version
from py2cs.__main__ import main
from py2cs.alpha import alpha_version, read_bhom_version, stamp_version_file

def write_version_file(folder: Path, bhom_version: str = "10.1") -> Path:
"""Writes a _version.py in the form the generator writes it and returns its path.

The tests of the alpha version use it.
"""
folder.mkdir(parents=True, exist_ok=True)
version_file = folder / "_version.py"
version_file.write_text(
"# Generated by BHoM_PythonSchema from the BHoM assemblies. Do not edit by hand.\n"
"# __bhom_version__ is the BHoM version (Major.Minor) of the classes; __version__ is the version of this package.\n\n"
f'__bhom_version__ = "{bhom_version}"\n'
f'__version__ = "{bhom_version}.0.dev0"\n',
encoding="utf-8",
newline="\n",
)
return version_file

def at(day: int, hour: int, minute: int) -> datetime:
"""Returns a UTC time in November 2026.

The tests of the alpha version use it as the build time.
"""
return datetime(2026, 11, day, hour, minute, tzinfo=timezone.utc)

def test_the_alpha_version_is_the_bhom_version_with_the_utc_time():
"""Checks that the alpha version is Major.Minor.0a followed by the two-digit year, month, day, hour and minute in UTC.

Protects the format that users and the workflow rely on.
"""
assert alpha_version("10.1", [], at(3, 14, 30)) == "10.1.0a2611031430"

def test_the_time_is_taken_in_utc_whatever_the_zone_of_the_clock():
"""Checks that a time with another zone offset is converted to UTC before it is stamped.

Protects the order of alphas from a runner with a local time zone.
"""
plus_two = datetime(2026, 11, 3, 14, 30, tzinfo=timezone.utc).astimezone(timezone(timedelta(hours=2)))
assert alpha_version("10.1", [], plus_two) == "10.1.0a2611031430"

def test_alphas_of_the_same_day_order_by_time_and_stay_below_the_beta():
"""Checks, with the PEP 440 ordering pip uses, that two alphas of one day order by time, that a later day orders after them, and that all are below the beta of the same version and above the previous beta.

Protects the rule that stable users stay on the beta and alpha users get the newest alpha.
"""
morning = Version(alpha_version("10.1", ["v10.0.0"], at(3, 6, 0)))
afternoon = Version(alpha_version("10.1", ["v10.0.0"], at(3, 14, 30)))
next_day = Version(alpha_version("10.1", ["v10.0.0"], at(4, 6, 0)))

assert Version("10.0.0") < morning < afternoon < next_day < Version("10.1.0")
assert all(version.is_prerelease for version in (morning, afternoon, next_day))

def test_an_alpha_of_a_released_bhom_version_fails_and_says_to_bump():
"""Checks that the alpha fails, naming the released tag and the bump, when the tag of its BHoM version exists.

Protects --pre users from being served an old beta because develop's BHoM version was not bumped after the release.
"""
with pytest.raises(ValueError, match=r"already released as v10\.0\.0.*Bump the BHoM version"):
alpha_version("10.0", ["v9.0.0", "v10.0.0"], at(3, 14, 30))

def test_an_older_release_does_not_stop_the_alpha_of_the_next_version():
"""Checks that tags of other versions, and no tags at all, do not stop an alpha.

Protects the first alpha, and the alpha right after the bump.
"""
assert alpha_version("10.1", ["v9.0.0", "v10.0.0"], at(3, 6, 0)).startswith("10.1.0a")
assert alpha_version("10.0", [], at(3, 6, 0)).startswith("10.0.0a")

def test_the_bhom_version_is_read_from_the_version_file(tmp_path: Path):
"""Checks that the BHoM version is read from the generated _version.py and that a file without one is an error.

Protects the alpha from stamping a version it did not read.
"""
assert read_bhom_version(write_version_file(tmp_path / "ok", "10.1")) == "10.1"

empty = tmp_path / "empty"
empty.mkdir()
(empty / "_version.py").write_text("__version__ = '1'\n", encoding="utf-8")
with pytest.raises(ValueError, match="no __bhom_version__"):
read_bhom_version(empty / "_version.py")

def test_stamping_replaces_only_the_package_version_and_keeps_line_endings(tmp_path: Path):
"""Checks that stamping rewrites the __version__ line, leaves the BHoM version and the comments alone, and keeps the LF line endings.

Protects the generated file in the alpha's own checkout from other edits.
"""
version_file = write_version_file(tmp_path)
stamp_version_file(version_file, "10.1.0a2611031430")

text = version_file.read_bytes().decode("utf-8")
assert '__version__ = "10.1.0a2611031430"\n' in text
assert '__bhom_version__ = "10.1"\n' in text
assert "dev0" not in text
assert "\r" not in text

def test_the_command_prints_the_version_and_stamps_only_when_asked(tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Checks that the alpha-version command prints the version and leaves the file alone without --stamp, writes it with --stamp, and fails with exit code 2 for a released BHoM version.

Protects the workflow step that uses the command.
"""
version_file = write_version_file(tmp_path, "10.1")

assert main(["alpha-version", "--package", str(tmp_path), "--tags", "v10.0.0"]) == 0
printed = capsys.readouterr().out.strip()
assert Version(printed).is_prerelease and printed.startswith("10.1.0a")
assert "dev0" in version_file.read_text(encoding="utf-8")

assert main(["alpha-version", "--package", str(tmp_path), "--tags", "v10.0.0", "--stamp"]) == 0
stamped = capsys.readouterr().out.strip()
assert f'__version__ = "{stamped}"' in version_file.read_text(encoding="utf-8")

assert main(["alpha-version", "--package", str(tmp_path), "--tags", "v10.1.0"]) == 2
36 changes: 36 additions & 0 deletions .ci/unit-tests/python/test_py2cs/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,39 @@ def test_the_included_repositories_file_is_tidy():
assert text.endswith("\n")
assert "dKop" not in text, "the repository is spelt dKoP_Toolkit"
assert all(name == name.strip() for name in text.splitlines())

def test_the_alpha_workflow_publishes_only_when_asked_and_only_the_publish_job_gets_an_identity_token():
"""Checks that the alpha workflow starts on a schedule or by hand, is a dry run by default, publishes only when develop moved and not a dry run, gives an identity token to the publish job only, and stores no secret.

Protects TestPyPI from an unplanned alpha and the identity token from the build job.
"""
workflow = load("alpha-release.yml")
assert set(workflow["on"]) == {"schedule", "workflow_dispatch"}
assert workflow["on"]["workflow_dispatch"]["inputs"]["dry_run"]["default"] is True
assert workflow["permissions"] == {"contents": "read"}
assert list(workflow["jobs"]) == ["build", "publish-testpypi", "mark"]
assert "permissions" not in workflow["jobs"]["build"]
publish = workflow["jobs"]["publish-testpypi"]
assert publish["permissions"] == {"id-token": "write"} and publish["environment"] == "testpypi-alpha"
assert publish["if"] == "${{ needs.build.outputs.publish == 'true' && !inputs.dry_run }}"
assert workflow["jobs"]["mark"]["needs"] == ["build", "publish-testpypi"], "the tag moves only after a publish"
assert "secrets." not in (WORKFLOWS / "alpha-release.yml").read_text(encoding="utf-8")

def test_the_alpha_stamps_the_version_in_its_own_checkout_and_never_commits_it():
"""Checks that the alpha workflow reads the released tags, stamps with py2cs alpha-version, runs no commit or push of develop, and moves only the tag alpha-latest.

Protects develop from a commit with a stamped version, and the weekly generation from a conflict with it.
"""
text = (WORKFLOWS / "alpha-release.yml").read_text(encoding="utf-8")
assert 'git tag --list "v*.0"' in text and "py2cs alpha-version" in text and "--stamp" in text
assert "git commit" not in text and "HEAD:develop" not in text
pushes = [line.strip() for line in text.splitlines() if "git push" in line]
assert pushes == ["git push --force origin alpha-latest"]

def test_the_workflows_that_write_to_develop_or_its_tags_share_one_concurrency_group():
"""Checks that the generation, the release and the alpha share one concurrency group that never cancels a running one.

Protects an alpha from packaging a half-pushed develop and from racing a release.
"""
for name in ("python-schema-generation.yml", "release.yml", "alpha-release.yml"):
assert load(name)["concurrency"] == {"group": "develop-publishing", "cancel-in-progress": False}, name
Loading
Loading