Skip to content

Commit d8465eb

Browse files
AA-Turnerhugovk
andauthored
Generate a release schedule as an .ics file (#4705)
Co-authored-by: Hugo van Kemenade <[email protected]>
1 parent 74e861f commit d8465eb

File tree

6 files changed

+132
-4
lines changed

6 files changed

+132
-4
lines changed

.pytest.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ addopts = [
1212
# https://pytest-cov.readthedocs.io/en/latest/config.html#reference
1313
"--cov=check_peps",
1414
"--cov=pep_sphinx_extensions",
15+
"--cov=release_management",
1516
"--cov-report=html",
1617
"--cov-report=xml",
1718
]
@@ -23,6 +24,7 @@ filterwarnings = ["error"]
2324

2425
testpaths = [
2526
"pep_sphinx_extensions",
27+
"release_management",
2628
]
2729

2830
# https://docs.pytest.org/en/stable/reference/reference.html#confval-strict

pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from pep_sphinx_extensions.pep_zero_generator import subindices
2727
from pep_sphinx_extensions.pep_zero_generator import writer
2828
from pep_sphinx_extensions.pep_zero_generator.constants import SUBINDICES_BY_TOPIC
29-
from release_management.serialize import create_release_cycle, create_release_json
29+
from release_management.serialize import create_release_cycle, create_release_schedule_calendar, create_release_json
3030

3131
if TYPE_CHECKING:
3232
from sphinx.application import Sphinx
@@ -79,3 +79,6 @@ def create_pep_zero(app: Sphinx, env: BuildEnvironment, docnames: list[str]) ->
7979

8080
release_json = create_release_json()
8181
app.outdir.joinpath('api/python-releases.json').write_text(release_json, encoding="utf-8")
82+
83+
release_ical = create_release_schedule_calendar()
84+
app.outdir.joinpath('release-schedule.ics').write_text(release_ical, encoding="utf-8")

release_management/__main__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
CMD_FULL_JSON := 'full-json',
77
CMD_UPDATE_PEPS := 'update-peps',
88
CMD_RELEASE_CYCLE := 'release-cycle',
9+
CMD_CALENDAR := 'calendar',
910
)
1011
parser = argparse.ArgumentParser(allow_abbrev=False)
1112
parser.add_argument('COMMAND', choices=commands)
@@ -31,3 +32,11 @@
3132
json_path = ROOT_DIR / 'release-cycle.json'
3233
json_path.write_text(create_release_cycle(), encoding='utf-8')
3334
raise SystemExit(0)
35+
36+
if args.COMMAND == CMD_CALENDAR:
37+
from release_management import ROOT_DIR
38+
from release_management.serialize import create_release_schedule_calendar
39+
40+
calendar_path = ROOT_DIR / 'release-schedule.ics'
41+
calendar_path.write_text(create_release_schedule_calendar(), encoding='utf-8')
42+
raise SystemExit(0)

release_management/serialize.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
from __future__ import annotations
22

3+
import datetime as dt
34
import dataclasses
45
import json
56

67
from release_management import ROOT_DIR, load_python_releases
78

89
TYPE_CHECKING = False
910
if TYPE_CHECKING:
10-
from release_management import VersionMetadata
11+
from release_management import ReleaseInfo, VersionMetadata
12+
13+
# Seven years captures the full lifecycle from prereleases to end-of-life
14+
TODAY = dt.date.today()
15+
SEVEN_YEARS_AGO = TODAY.replace(year=TODAY.year - 7)
16+
17+
# https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.11
18+
CALENDAR_ESCAPE_TEXT = str.maketrans({
19+
'\\': r'\\',
20+
';': r'\;',
21+
',': r'\,',
22+
'\n': r'\n',
23+
})
1124

1225

1326
def create_release_json() -> str:
@@ -48,3 +61,55 @@ def version_info(metadata: VersionMetadata, /) -> dict[str, str | int]:
4861
'end_of_life': end_of_life,
4962
'release_manager': metadata.release_manager,
5063
}
64+
65+
66+
def create_release_schedule_calendar() -> str:
67+
python_releases = load_python_releases()
68+
releases = []
69+
for version, all_releases in python_releases.releases.items():
70+
pep_number = python_releases.metadata[version].pep
71+
for release in all_releases:
72+
# Keep size reasonable by omitting releases older than 7 years
73+
if release.date < SEVEN_YEARS_AGO:
74+
continue
75+
releases.append((pep_number, release))
76+
releases.sort(key=lambda r: r[1].date)
77+
lines = release_schedule_calendar_lines(releases)
78+
return '\r\n'.join(lines)
79+
80+
81+
def release_schedule_calendar_lines(
82+
releases: list[tuple[int, ReleaseInfo]], /
83+
) -> list[str]:
84+
dtstamp = dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
85+
86+
lines = [
87+
'BEGIN:VCALENDAR',
88+
'VERSION:2.0',
89+
'PRODID:-//Python Software Foundation//Python release schedule//EN',
90+
'X-WR-CALDESC:Python releases schedule from https://peps.python.org',
91+
'X-WR-CALNAME:Python releases schedule',
92+
]
93+
for pep_number, release in releases:
94+
normalised_stage = release.stage.replace(' ', '')
95+
normalised_stage = normalised_stage.translate(CALENDAR_ESCAPE_TEXT)
96+
if release.note:
97+
normalised_note = release.note.translate(CALENDAR_ESCAPE_TEXT)
98+
note = (f'DESCRIPTION:Note: {normalised_note}',)
99+
else:
100+
note = ()
101+
lines += (
102+
'BEGIN:VEVENT',
103+
f'DTSTAMP:{dtstamp}',
104+
f'UID:python-{normalised_stage}@releases.python.org',
105+
f'DTSTART;VALUE=DATE:{release.date.strftime("%Y%m%d")}',
106+
f'SUMMARY:Python {release.stage}',
107+
*note,
108+
f'URL:https://peps.python.org/pep-{pep_number:04d}/',
109+
'END:VEVENT',
110+
)
111+
lines += (
112+
'END:VCALENDAR',
113+
'',
114+
)
115+
return lines
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import datetime as dt
2+
3+
from release_management import ReleaseInfo, serialize
4+
5+
FAKE_RELEASE = ReleaseInfo(
6+
stage='X.Y.Z final',
7+
state='actual',
8+
date=dt.date(2000, 1, 1),
9+
note='These characters need escaping: \\ , ; \n',
10+
)
11+
12+
13+
def test_create_release_calendar_has_calendar_metadata() -> None:
14+
# Act
15+
cal_lines = serialize.create_release_schedule_calendar().split('\r\n')
16+
17+
# Assert
18+
19+
# Check calendar metadata
20+
assert cal_lines[:5] == [
21+
'BEGIN:VCALENDAR',
22+
'VERSION:2.0',
23+
'PRODID:-//Python Software Foundation//Python release schedule//EN',
24+
'X-WR-CALDESC:Python releases schedule from https://peps.python.org',
25+
'X-WR-CALNAME:Python releases schedule',
26+
]
27+
assert cal_lines[-2:] == [
28+
'END:VCALENDAR',
29+
'',
30+
]
31+
32+
33+
def test_create_release_calendar_first_event() -> None:
34+
# Act
35+
releases = [(9999, FAKE_RELEASE)]
36+
cal_lines = serialize.release_schedule_calendar_lines(releases)
37+
38+
# Assert
39+
assert cal_lines[5] == 'BEGIN:VEVENT'
40+
assert cal_lines[6].startswith('DTSTAMP:')
41+
assert cal_lines[6].endswith('Z')
42+
assert cal_lines[7] == 'UID:[email protected]'
43+
assert cal_lines[8] == 'DTSTART;VALUE=DATE:20000101'
44+
assert cal_lines[9] == 'SUMMARY:Python X.Y.Z final'
45+
assert cal_lines[10] == (
46+
'DESCRIPTION:Note: These characters need escaping: \\\\ \\, \\; \\n'
47+
)
48+
assert cal_lines[11] == 'URL:https://peps.python.org/pep-9999/'
49+
assert cal_lines[12] == 'END:VEVENT'

tox.ini

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ commands =
1515

1616
[coverage:run]
1717
omit =
18-
*/__main__.py # Ignore all __main__.py files
19-
peps/* # Ignore all files in the PEPs folder
18+
*/__main__.py
19+
peps/*
2020

2121
[coverage:report]
2222
exclude_also =

0 commit comments

Comments
 (0)