diff --git a/.github/workflows/adopt-energyplus.yml b/.github/workflows/adopt-energyplus.yml new file mode 100644 index 00000000..a30c3325 --- /dev/null +++ b/.github/workflows/adopt-energyplus.yml @@ -0,0 +1,44 @@ +name: adopt-energyplus + +# Adopt one EnergyPlus release in this project, as a draft pull request on `energyplus/X.Y.Z`. +# +# This file holds no logic. The steps, their order, the command each one runs and the gate are +# declared for the project `idfkit-developers` in `governance/energyplus.toml` of idfkit-conformance, and +# the reusable workflow called below executes exactly those commands and no others. It never +# merges, publishes or deploys: the caller grants `contents: write` and `pull-requests: write` only. +# +# THE TAG IS STATED TWICE, on `uses:` and as `governance_level`, because a called workflow cannot +# see its own ref. The reusable workflow refuses the run unless the two are the same string, so +# both are changed together or not at all. +# +# No pinned level is moved here. bump-idfkit.yml moves [tool.idfkit.library] and bump-docs-level.yml +# moves [tool.idfkit.docs], each when its library publishes a version that carries the release. +# Until then a run reports that it is waiting and opens nothing. The site deploys from the default +# branch by deploy-docs.yml, never from this workflow. +# +# It is dispatched by the watch in idfkit-conformance, and may be dispatched by hand. A second run +# for a release that is already carried changes nothing. +run-name: Adopt EnergyPlus ${{ inputs.release }} + +on: + workflow_dispatch: + inputs: + release: + description: The EnergyPlus release to adopt, as X.Y.Z. + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + adopt: + uses: idfkit/idfkit-conformance/.github/workflows/adopt-energyplus.yml@governance-2026.25 + with: + project: idfkit-developers + governance_level: governance-2026.25 + release: ${{ inputs.release }} + secrets: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/scripts/adopt_release.py b/scripts/adopt_release.py new file mode 100644 index 00000000..7637c56c --- /dev/null +++ b/scripts/adopt_release.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""List a newly adopted EnergyPlus release on the two pages that enumerate every release. + +Two places in this site enumerate the supported releases one by one, so an adoption adds an entry +to each and rewrites none: + +1. The table of releases in `docs/reference/versions.md`. The release takes the first row whose + right-hand pair of cells is empty, or a new row when none is. +2. The printed output of the cell under "Available schema versions" in + `docs/getting-started/core-tutorial.ipynb`. The release's tuple is appended to the list. + +The command refuses unless the installed `idfkit` lists the release, because both places state what +the pinned library supports. It is idempotent: a release that is already listed changes nothing. + +Exit codes: 0 both places list the release, 1 the release cannot be listed, 2 the arguments are wrong. + +Usage:: + + python scripts/adopt_release.py 26.2.0 +""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +VERSIONS_PAGE = ROOT / "docs" / "reference" / "versions.md" +TUTORIAL = ROOT / "docs" / "getting-started" / "core-tutorial.ipynb" + +_TABLE_HEADER = "| EnergyPlus | Schema key | EnergyPlus | Schema key |" +_OPEN_ROW = re.compile(r"^(?P\|[^|\n]*\|[^|\n]*\|) {12}\| {12}\|$", re.MULTILINE) +_LISTING = re.compile(r"(?PAvailable schema versions: \[)(?P[^\]\n]*)(?P\])") + + +class CannotList(ValueError): + """The release cannot be listed, with the reason.""" + + +@dataclass(frozen=True, slots=True) +class Release: + major: int + minor: int + patch: int + + @classmethod + def parse(cls, text: str) -> Release: + found = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", text) + if found is None: + raise CannotList(f"{text!r} is not a release written as X.Y.Z") + return cls(int(found[1]), int(found[2]), int(found[3])) + + def full(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + def line(self) -> str: + return f"{self.major}.{self.minor}" + + def as_tuple(self) -> str: + return f"({self.major}, {self.minor}, {self.patch})" + + def cells(self) -> str: + """The pair of table cells for this release, padded as the table's other rows are.""" + return f" {self.line():<10} | {f'`{self.full()}`':<10} |" + + +@dataclass(frozen=True, slots=True) +class Change: + path: Path + text: str + changed: bool + + +def with_table_row(text: str, release: Release) -> str: + """The versions page with the release in its table of releases.""" + start = text.find(_TABLE_HEADER) + if start == -1: + raise CannotList("the table of releases was not found in docs/reference/versions.md") + end = text.find("\n\n", start) + end = len(text) if end == -1 else end + table = text[start:end] + if f"`{release.full()}`" in table: + return text + opened = _OPEN_ROW.search(table) + if opened is not None: + table = table[: opened.start()] + opened["left"] + release.cells() + table[opened.end() :] + else: + table += f"\n| {'':<10} | {'':<10} |" + release.cells() + return text[:start] + table + text[end:] + + +def with_listed_tuple(text: str, release: Release) -> str: + """The tutorial notebook with the release in the printed list of schema versions.""" + found = _LISTING.search(text) + if found is None: + raise CannotList("the printed list of schema versions was not found in the core tutorial") + if release.as_tuple() in found["items"]: + return text + return text[: found.end("items")] + f", {release.as_tuple()}" + text[found.end("items") :] + + +def require_listed(release: Release) -> None: + """Refuse a release the installed library does not list.""" + from idfkit.versions import ENERGYPLUS_VERSIONS + + if (release.major, release.minor, release.patch) not in ENERGYPLUS_VERSIONS: + raise CannotList( + f"the installed idfkit does not list {release.full()}, so the site cannot state that it is supported" + ) + + +def changes(release: Release) -> tuple[Change, ...]: + page = VERSIONS_PAGE.read_text(encoding="utf-8") + notebook = TUTORIAL.read_text(encoding="utf-8") + listed_page, listed_notebook = with_table_row(page, release), with_listed_tuple(notebook, release) + return ( + Change(VERSIONS_PAGE, listed_page, listed_page != page), + Change(TUTORIAL, listed_notebook, listed_notebook != notebook), + ) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument("release", help="the EnergyPlus release, as X.Y.Z") + arguments = parser.parse_args(argv) + try: + release = Release.parse(arguments.release) + require_listed(release) + planned = changes(release) + except CannotList as error: + print(f"adopt_release: {error}", file=sys.stderr) + return 1 + for change in planned: + if change.changed: + change.path.write_text(change.text, encoding="utf-8") + print( + f"{change.path.relative_to(ROOT)}: {'listed ' + release.full() if change.changed else 'already lists ' + release.full()}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_adopt_release.py b/tests/test_adopt_release.py new file mode 100644 index 00000000..0b5fb936 --- /dev/null +++ b/tests/test_adopt_release.py @@ -0,0 +1,59 @@ +"""The command that lists an adopted EnergyPlus release, against the committed pages.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import adopt_release +from adopt_release import TUTORIAL, VERSIONS_PAGE, CannotList, Release, with_listed_tuple, with_table_row + +NEXT = Release(99, 1, 0) +PAGE = VERSIONS_PAGE.read_text(encoding="utf-8") +NOTEBOOK = TUTORIAL.read_text(encoding="utf-8") + + +def test_the_release_takes_the_first_open_row_and_no_row_is_rewritten() -> None: + listed = with_table_row(PAGE, NEXT) + before, after = PAGE.splitlines(), listed.splitlines() + assert len(after) == len(before) + differing = [(old, new) for old, new in zip(before, after, strict=True) if old != new] + assert len(differing) == 1 + old, new = differing[0] + assert new.endswith("| 99.1 | `99.1.0` |") and new.startswith(old[:25]) and len(new) == len(old) + + +def test_a_full_table_gains_a_row() -> None: + full = PAGE + for patch in range(1, 10): + if adopt_release._OPEN_ROW.search(full) is None: + break + full = with_table_row(full, Release(98, patch, 0)) + listed = with_table_row(full, NEXT) + assert len(listed.splitlines()) == len(full.splitlines()) + 1 + assert "| | | 99.1 | `99.1.0` |" in listed + + +def test_the_tuple_is_appended_to_the_printed_list() -> None: + listed = with_listed_tuple(NOTEBOOK, NEXT) + assert listed.count("(99, 1, 0)]") == 1 and len(listed) == len(NOTEBOOK) + len(", (99, 1, 0)") + + +def test_listing_twice_changes_nothing() -> None: + assert with_table_row(with_table_row(PAGE, NEXT), NEXT) == with_table_row(PAGE, NEXT) + assert with_listed_tuple(with_listed_tuple(NOTEBOOK, NEXT), NEXT) == with_listed_tuple(NOTEBOOK, NEXT) + + +def test_a_release_the_library_does_not_list_is_refused(capsys: pytest.CaptureFixture[str]) -> None: + assert adopt_release.main(["99.1.0"]) == 1 + assert "does not list 99.1.0" in capsys.readouterr().err + assert VERSIONS_PAGE.read_text(encoding="utf-8") == PAGE + + +def test_a_page_without_the_table_is_an_error() -> None: + with pytest.raises(CannotList): + with_table_row("no table here", NEXT)