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/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/__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..5d458003e5e 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,52 @@ 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]: + 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.casefold().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'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 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..5291587186a --- /dev/null +++ b/release_management/tests/test_release_schedule_calendar.py @@ -0,0 +1,47 @@ +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] == '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'