Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
4 changes: 2 additions & 2 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion release_management/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -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 = {
Expand All @@ -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
9 changes: 9 additions & 0 deletions release_management/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
64 changes: 63 additions & 1 deletion release_management/serialize.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
from __future__ import annotations

import datetime as dt
import dataclasses
import json

from release_management import ROOT_DIR, load_python_releases

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:
Expand Down Expand Up @@ -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
47 changes: 47 additions & 0 deletions release_management/tests/test_release_schedule_calendar.py
Original file line number Diff line number Diff line change
@@ -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'