From 412cc99387e0c3dea19a1d09ce5d6b5b6aaa3da9 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:39:19 +0200 Subject: [PATCH 1/2] Generate a release schedule as an .ics file Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .../pep_zero_generator/pep_index_generator.py | 5 +- release_management/__init__.py | 8 ++- release_management/__main__.py | 9 ++++ release_management/serialize.py | 43 ++++++++++++++++ .../tests/test_release_schedule_calendar.py | 50 +++++++++++++++++++ 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 release_management/tests/test_release_schedule_calendar.py 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/__init__.py b/release_management/__init__.py index 9846219fdc0..2e24777da81 100644 --- a/release_management/__init__.py +++ b/release_management/__init__.py @@ -23,6 +23,7 @@ RELEASE_DIR = Path(__file__).resolve().parent ROOT_DIR = RELEASE_DIR.parent PEP_ROOT = ROOT_DIR / 'peps' +_PYTHON_RELEASES = None dc_kw = {'kw_only': True, 'slots': True} if sys.version_info[:2] >= (3, 10) else {} @@ -68,6 +69,10 @@ def schedule_bullet(self): def load_python_releases() -> PythonReleases: + global _PYTHON_RELEASES + if _PYTHON_RELEASES is not None: + return _PYTHON_RELEASES + with open(RELEASE_DIR / 'python-releases.toml', 'rb') as f: python_releases = tomllib.load(f) all_metadata = { @@ -78,4 +83,5 @@ def load_python_releases() -> PythonReleases: v: [ReleaseInfo(**r) for r in releases] for v, releases in python_releases['release'].items() } - return PythonReleases(metadata=all_metadata, releases=all_releases) + _PYTHON_RELEASES = PythonReleases(metadata=all_metadata, releases=all_releases) + return _PYTHON_RELEASES 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..f8dc17b8e0d 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 @@ -9,6 +10,10 @@ if TYPE_CHECKING: from release_management import 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) + def create_release_json() -> str: python_releases = dataclasses.asdict(load_python_releases()) @@ -48,3 +53,41 @@ 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() + all_releases = [ + (version, release) + for version, releases in python_releases.releases.items() + for release in releases + # Keep size reasonable by omitting releases older than 7 years + if release.date >= SEVEN_YEARS_AGO + ] + all_releases.sort(key=lambda r: r[1].date) + + 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 version, release in all_releases: + normalised_stage = release.stage.casefold().replace(' ', '') + note = (f'DESCRIPTION:Note: {release.note}',) if release.note else () + pep_number = python_releases.metadata[version].pep + lines += ( + 'BEGIN:VEVENT', + f'SUMMARY:Python {release.stage}', + f'DTSTART;VALUE=DATE:{release.date.strftime("%Y%m%d")}', + f'UID:python-{normalised_stage}@releases.python.org', + *note, + f'URL:https://peps.python.org/pep-{pep_number:04d}/', + 'END:VEVENT', + ) + lines += ( + 'END:VCALENDAR', + '', + ) + return '\n'.join(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..aa4b3e9434d --- /dev/null +++ b/release_management/tests/test_release_schedule_calendar.py @@ -0,0 +1,50 @@ +import pytest + +from release_management import serialize + + +@pytest.mark.parametrize( + ('test_input', 'expected'), + [ + ('3.14.0 alpha 1', 'python-3.14.0alpha1@releases.python.org'), + ('3.14.0 beta 2', 'python-3.14.0beta2@releases.python.org'), + ('3.14.0 candidate 3', 'python-3.14.0candidate3@releases.python.org'), + ('3.14.1', 'python-3.14.1@releases.python.org'), + ], +) +def test_ical_uid(test_input, expected): + assert serialize.ical_uid(test_input) == expected + + +def test_create_release_calendar_has_calendar_metadata(): + # Act + cal_lines = serialize.create_release_schedule_calendar().split('\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(): + # Act + cal_lines = serialize.create_release_schedule_calendar().split('\n') + + # Assert + assert cal_lines[5] == 'BEGIN:VEVENT' + assert cal_lines[6].startswith('SUMMARY:Python ') + assert cal_lines[7].startswith('DTSTART;VALUE=DATE:') + assert cal_lines[8].startswith('UID:python-') + assert cal_lines[8].endswith('@release.python.org') + assert cal_lines[9].startswith('URL:https://peps.python.org/pep-') + assert cal_lines[10].startswith('END:VEVENT') From 730266ce1e41ba2fab5dd7b83a216befc69e4afa Mon Sep 17 00:00:00 2001 From: Adam Turner <9087854+aa-turner@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:34:53 +0000 Subject: [PATCH 2/2] Hugo's review --- pytest.ini | 4 +- release_management/serialize.py | 45 +++++++++++++------ .../tests/test_release_schedule_calendar.py | 43 +++++++++--------- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/pytest.ini b/pytest.ini index 10404cc0b3b..86d44c56a64 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,12 +5,12 @@ addopts = --strict-config --strict-markers --import-mode=importlib - --cov check_peps --cov pep_sphinx_extensions + --cov check_peps --cov pep_sphinx_extensions --cov release_management --cov-report html --cov-report xml empty_parameter_set_mark = fail_at_collect filterwarnings = error minversion = 6.0 -testpaths = pep_sphinx_extensions +testpaths = pep_sphinx_extensions release_management xfail_strict = True disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True diff --git a/release_management/serialize.py b/release_management/serialize.py index f8dc17b8e0d..5d458003e5e 100644 --- a/release_management/serialize.py +++ b/release_management/serialize.py @@ -8,12 +8,20 @@ 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: python_releases = dataclasses.asdict(load_python_releases()) @@ -57,15 +65,22 @@ def version_info(metadata: VersionMetadata, /) -> dict[str, str | int]: def create_release_schedule_calendar() -> str: python_releases = load_python_releases() - all_releases = [ - (version, release) - for version, releases in python_releases.releases.items() - for release in releases - # Keep size reasonable by omitting releases older than 7 years - if release.date >= SEVEN_YEARS_AGO - ] - all_releases.sort(key=lambda r: r[1].date) + 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]: lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', @@ -73,10 +88,14 @@ def create_release_schedule_calendar() -> str: 'X-WR-CALDESC:Python releases schedule from https://peps.python.org', 'X-WR-CALNAME:Python releases schedule', ] - for version, release in all_releases: + for pep_number, release in releases: normalised_stage = release.stage.casefold().replace(' ', '') - note = (f'DESCRIPTION:Note: {release.note}',) if release.note else () - pep_number = python_releases.metadata[version].pep + 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'SUMMARY:Python {release.stage}', @@ -90,4 +109,4 @@ def create_release_schedule_calendar() -> str: 'END:VCALENDAR', '', ) - return '\n'.join(lines) + return lines diff --git a/release_management/tests/test_release_schedule_calendar.py b/release_management/tests/test_release_schedule_calendar.py index aa4b3e9434d..5291587186a 100644 --- a/release_management/tests/test_release_schedule_calendar.py +++ b/release_management/tests/test_release_schedule_calendar.py @@ -1,24 +1,18 @@ -import pytest +import datetime as dt -from release_management import serialize +from release_management import ReleaseInfo, serialize - -@pytest.mark.parametrize( - ('test_input', 'expected'), - [ - ('3.14.0 alpha 1', 'python-3.14.0alpha1@releases.python.org'), - ('3.14.0 beta 2', 'python-3.14.0beta2@releases.python.org'), - ('3.14.0 candidate 3', 'python-3.14.0candidate3@releases.python.org'), - ('3.14.1', 'python-3.14.1@releases.python.org'), - ], +FAKE_RELEASE = ReleaseInfo( + stage='X.Y.Z final', + state='actual', + date=dt.date(2000, 1, 1), + note='These characters need escaping: \\ , ; \n', ) -def test_ical_uid(test_input, expected): - assert serialize.ical_uid(test_input) == expected -def test_create_release_calendar_has_calendar_metadata(): +def test_create_release_calendar_has_calendar_metadata() -> None: # Act - cal_lines = serialize.create_release_schedule_calendar().split('\n') + cal_lines = serialize.create_release_schedule_calendar().split('\r\n') # Assert @@ -36,15 +30,18 @@ def test_create_release_calendar_has_calendar_metadata(): ] -def test_create_release_calendar_first_event(): +def test_create_release_calendar_first_event() -> None: # Act - cal_lines = serialize.create_release_schedule_calendar().split('\n') + releases = [(9999, FAKE_RELEASE)] + cal_lines = serialize.release_schedule_calendar_lines(releases) # Assert assert cal_lines[5] == 'BEGIN:VEVENT' - assert cal_lines[6].startswith('SUMMARY:Python ') - assert cal_lines[7].startswith('DTSTART;VALUE=DATE:') - assert cal_lines[8].startswith('UID:python-') - assert cal_lines[8].endswith('@release.python.org') - assert cal_lines[9].startswith('URL:https://peps.python.org/pep-') - assert cal_lines[10].startswith('END:VEVENT') + assert cal_lines[6] == 'SUMMARY:Python X.Y.Z final' + assert cal_lines[7] == 'DTSTART;VALUE=DATE:20000101' + assert cal_lines[8] == 'UID:python-x.y.zfinal@releases.python.org' + assert cal_lines[9] == ( + 'DESCRIPTION:Note: These characters need escaping: \\\\ \\, \\; \\n' + ) + assert cal_lines[10] == 'URL:https://peps.python.org/pep-9999/' + assert cal_lines[11] == 'END:VEVENT'