diff --git a/.pytest.toml b/.pytest.toml index 801cb596947..82dcecae263 100644 --- a/.pytest.toml +++ b/.pytest.toml @@ -12,6 +12,7 @@ addopts = [ # https://pytest-cov.readthedocs.io/en/latest/config.html#reference "--cov=check_peps", "--cov=pep_sphinx_extensions", + "--cov=release_management", "--cov-report=html", "--cov-report=xml", ] @@ -23,6 +24,7 @@ filterwarnings = ["error"] testpaths = [ "pep_sphinx_extensions", + "release_management", ] # https://docs.pytest.org/en/stable/reference/reference.html#confval-strict diff --git a/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py b/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py index 84787aabb85..6b5d56a4392 100644 --- a/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py +++ b/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py @@ -26,7 +26,7 @@ from pep_sphinx_extensions.pep_zero_generator import subindices from pep_sphinx_extensions.pep_zero_generator import writer from pep_sphinx_extensions.pep_zero_generator.constants import SUBINDICES_BY_TOPIC -from release_management.serialize import create_release_cycle, create_release_json +from release_management.serialize import create_release_cycle, create_release_schedule_calendar, create_release_json if TYPE_CHECKING: from sphinx.application import Sphinx @@ -79,3 +79,6 @@ def create_pep_zero(app: Sphinx, env: BuildEnvironment, docnames: list[str]) -> release_json = create_release_json() app.outdir.joinpath('api/python-releases.json').write_text(release_json, encoding="utf-8") + + release_ical = create_release_schedule_calendar() + app.outdir.joinpath('release-schedule.ics').write_text(release_ical, encoding="utf-8") diff --git a/release_management/__main__.py b/release_management/__main__.py index 5842cab0552..458a00ef90d 100644 --- a/release_management/__main__.py +++ b/release_management/__main__.py @@ -6,6 +6,7 @@ CMD_FULL_JSON := 'full-json', CMD_UPDATE_PEPS := 'update-peps', CMD_RELEASE_CYCLE := 'release-cycle', + CMD_CALENDAR := 'calendar', ) parser = argparse.ArgumentParser(allow_abbrev=False) parser.add_argument('COMMAND', choices=commands) @@ -31,3 +32,11 @@ json_path = ROOT_DIR / 'release-cycle.json' json_path.write_text(create_release_cycle(), encoding='utf-8') raise SystemExit(0) + +if args.COMMAND == CMD_CALENDAR: + from release_management import ROOT_DIR + from release_management.serialize import create_release_schedule_calendar + + calendar_path = ROOT_DIR / 'release-schedule.ics' + calendar_path.write_text(create_release_schedule_calendar(), encoding='utf-8') + raise SystemExit(0) diff --git a/release_management/serialize.py b/release_management/serialize.py index 3418098278c..051ed77c3ec 100644 --- a/release_management/serialize.py +++ b/release_management/serialize.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime as dt import dataclasses import json @@ -7,7 +8,19 @@ TYPE_CHECKING = False if TYPE_CHECKING: - from release_management import VersionMetadata + from release_management import ReleaseInfo, VersionMetadata + +# Seven years captures the full lifecycle from prereleases to end-of-life +TODAY = dt.date.today() +SEVEN_YEARS_AGO = TODAY.replace(year=TODAY.year - 7) + +# https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.11 +CALENDAR_ESCAPE_TEXT = str.maketrans({ + '\\': r'\\', + ';': r'\;', + ',': r'\,', + '\n': r'\n', +}) def create_release_json() -> str: @@ -48,3 +61,55 @@ def version_info(metadata: VersionMetadata, /) -> dict[str, str | int]: 'end_of_life': end_of_life, 'release_manager': metadata.release_manager, } + + +def create_release_schedule_calendar() -> str: + python_releases = load_python_releases() + releases = [] + for version, all_releases in python_releases.releases.items(): + pep_number = python_releases.metadata[version].pep + for release in all_releases: + # Keep size reasonable by omitting releases older than 7 years + if release.date < SEVEN_YEARS_AGO: + continue + releases.append((pep_number, release)) + releases.sort(key=lambda r: r[1].date) + lines = release_schedule_calendar_lines(releases) + return '\r\n'.join(lines) + + +def release_schedule_calendar_lines( + releases: list[tuple[int, ReleaseInfo]], / +) -> list[str]: + dtstamp = dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ') + + lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Python Software Foundation//Python release schedule//EN', + 'X-WR-CALDESC:Python releases schedule from https://peps.python.org', + 'X-WR-CALNAME:Python releases schedule', + ] + for pep_number, release in releases: + normalised_stage = release.stage.replace(' ', '') + normalised_stage = normalised_stage.translate(CALENDAR_ESCAPE_TEXT) + if release.note: + normalised_note = release.note.translate(CALENDAR_ESCAPE_TEXT) + note = (f'DESCRIPTION:Note: {normalised_note}',) + else: + note = () + lines += ( + 'BEGIN:VEVENT', + f'DTSTAMP:{dtstamp}', + f'UID:python-{normalised_stage}@releases.python.org', + f'DTSTART;VALUE=DATE:{release.date.strftime("%Y%m%d")}', + f'SUMMARY:Python {release.stage}', + *note, + f'URL:https://peps.python.org/pep-{pep_number:04d}/', + 'END:VEVENT', + ) + lines += ( + 'END:VCALENDAR', + '', + ) + return lines diff --git a/release_management/tests/test_release_schedule_calendar.py b/release_management/tests/test_release_schedule_calendar.py new file mode 100644 index 00000000000..0d1fcff61bf --- /dev/null +++ b/release_management/tests/test_release_schedule_calendar.py @@ -0,0 +1,49 @@ +import datetime as dt + +from release_management import ReleaseInfo, serialize + +FAKE_RELEASE = ReleaseInfo( + stage='X.Y.Z final', + state='actual', + date=dt.date(2000, 1, 1), + note='These characters need escaping: \\ , ; \n', +) + + +def test_create_release_calendar_has_calendar_metadata() -> None: + # Act + cal_lines = serialize.create_release_schedule_calendar().split('\r\n') + + # Assert + + # Check calendar metadata + assert cal_lines[:5] == [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Python Software Foundation//Python release schedule//EN', + 'X-WR-CALDESC:Python releases schedule from https://peps.python.org', + 'X-WR-CALNAME:Python releases schedule', + ] + assert cal_lines[-2:] == [ + 'END:VCALENDAR', + '', + ] + + +def test_create_release_calendar_first_event() -> None: + # Act + releases = [(9999, FAKE_RELEASE)] + cal_lines = serialize.release_schedule_calendar_lines(releases) + + # Assert + assert cal_lines[5] == 'BEGIN:VEVENT' + assert cal_lines[6].startswith('DTSTAMP:') + assert cal_lines[6].endswith('Z') + assert cal_lines[7] == 'UID:python-X.Y.Zfinal@releases.python.org' + assert cal_lines[8] == 'DTSTART;VALUE=DATE:20000101' + assert cal_lines[9] == 'SUMMARY:Python X.Y.Z final' + assert cal_lines[10] == ( + 'DESCRIPTION:Note: These characters need escaping: \\\\ \\, \\; \\n' + ) + assert cal_lines[11] == 'URL:https://peps.python.org/pep-9999/' + assert cal_lines[12] == 'END:VEVENT' diff --git a/tox.ini b/tox.ini index 5427b2511e2..d31e9b379bb 100644 --- a/tox.ini +++ b/tox.ini @@ -15,8 +15,8 @@ commands = [coverage:run] omit = - */__main__.py # Ignore all __main__.py files - peps/* # Ignore all files in the PEPs folder + */__main__.py + peps/* [coverage:report] exclude_also =