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
44 changes: 44 additions & 0 deletions .github/workflows/adopt-energyplus.yml
Original file line number Diff line number Diff line change
@@ -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 }}
147 changes: 147 additions & 0 deletions scripts/adopt_release.py
Original file line number Diff line number Diff line change
@@ -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<left>\|[^|\n]*\|[^|\n]*\|) {12}\| {12}\|$", re.MULTILINE)
_LISTING = re.compile(r"(?P<head>Available schema versions: \[)(?P<items>[^\]\n]*)(?P<tail>\])")


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())
59 changes: 59 additions & 0 deletions tests/test_adopt_release.py
Original file line number Diff line number Diff line change
@@ -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)
Loading