Skip to content

Commit 83d645e

Browse files
hugovkAA-Turner
authored andcommitted
Generate a release schedule as an .ics file
Co-authored-by: Hugo van Kemenade <[email protected]>
1 parent 3ede647 commit 83d645e

File tree

5 files changed

+114
-2
lines changed

5 files changed

+114
-2
lines changed

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/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
RELEASE_DIR = Path(__file__).resolve().parent
2424
ROOT_DIR = RELEASE_DIR.parent
2525
PEP_ROOT = ROOT_DIR / 'peps'
26+
_PYTHON_RELEASES = None
2627

2728
dc_kw = {'kw_only': True, 'slots': True} if sys.version_info[:2] >= (3, 10) else {}
2829

@@ -68,6 +69,10 @@ def schedule_bullet(self):
6869

6970

7071
def load_python_releases() -> PythonReleases:
72+
global _PYTHON_RELEASES
73+
if _PYTHON_RELEASES is not None:
74+
return _PYTHON_RELEASES
75+
7176
with open(RELEASE_DIR / 'python-releases.toml', 'rb') as f:
7277
python_releases = tomllib.load(f)
7378
all_metadata = {
@@ -78,4 +83,5 @@ def load_python_releases() -> PythonReleases:
7883
v: [ReleaseInfo(**r) for r in releases]
7984
for v, releases in python_releases['release'].items()
8085
}
81-
return PythonReleases(metadata=all_metadata, releases=all_releases)
86+
_PYTHON_RELEASES = PythonReleases(metadata=all_metadata, releases=all_releases)
87+
return _PYTHON_RELEASES

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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
from __future__ import annotations
22

3+
import datetime as dt
34
import dataclasses
45
import json
6+
import re
57

68
from release_management import ROOT_DIR, load_python_releases
79

810
TYPE_CHECKING = False
911
if TYPE_CHECKING:
1012
from release_management import VersionMetadata
1113

14+
# Seven years captures the full lifecycle from prereleases to end-of-life
15+
TODAY = dt.date.today()
16+
SEVEN_YEARS_AGO = TODAY.replace(year=TODAY.year - 7)
17+
1218

1319
def create_release_json() -> str:
1420
python_releases = dataclasses.asdict(load_python_releases())
@@ -48,3 +54,41 @@ def version_info(metadata: VersionMetadata, /) -> dict[str, str | int]:
4854
'end_of_life': end_of_life,
4955
'release_manager': metadata.release_manager,
5056
}
57+
58+
59+
def create_release_schedule_calendar() -> str:
60+
python_releases = load_python_releases()
61+
all_releases = [
62+
(version, release)
63+
for version, releases in python_releases.releases.items()
64+
for release in releases
65+
# Keep size reasonable by omitting releases older than 7 years
66+
if release.date >= SEVEN_YEARS_AGO
67+
]
68+
all_releases.sort(key=lambda r: r[1].date)
69+
70+
lines = [
71+
'BEGIN:VCALENDAR',
72+
'VERSION:2.0',
73+
'PRODID:-//Python Software Foundation//Python release schedule//EN',
74+
'X-WR-CALDESC:Python releases schedule from https://peps.python.org',
75+
'X-WR-CALNAME:Python releases schedule',
76+
]
77+
for version, release in all_releases:
78+
normalised_stage = release.stage.casefold().replace(' ', '')
79+
note = (f'DESCRIPTION:Note: {release.note}',) if release.note else ()
80+
pep_number = python_releases.metadata[version].pep
81+
lines += (
82+
'BEGIN:VEVENT',
83+
f'SUMMARY:Python {release.stage}',
84+
f'DTSTART;VALUE=DATE:{release.date.strftime("%Y%m%d")}',
85+
f'UID:python-{normalised_stage}@releases.python.org',
86+
*note,
87+
f'URL:https://peps.python.org/pep-{pep_number:04d}/',
88+
'END:VEVENT',
89+
)
90+
lines += (
91+
'END:VCALENDAR',
92+
'',
93+
)
94+
return '\n'.join(lines)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import pytest
2+
3+
from release_management import serialize
4+
5+
6+
@pytest.mark.parametrize(
7+
('test_input', 'expected'),
8+
[
9+
('3.14.0 alpha 1', '[email protected]'),
10+
('3.14.0 beta 2', '[email protected]'),
11+
('3.14.0 candidate 3', '[email protected]'),
12+
('3.14.1', '[email protected]'),
13+
],
14+
)
15+
def test_ical_uid(test_input, expected):
16+
assert serialize.ical_uid(test_input) == expected
17+
18+
19+
def test_create_release_calendar_has_calendar_metadata():
20+
# Act
21+
cal_lines = serialize.create_release_schedule_calendar().split('\n')
22+
23+
# Assert
24+
25+
# Check calendar metadata
26+
assert cal_lines[:5] == [
27+
'BEGIN:VCALENDAR',
28+
'VERSION:2.0',
29+
'PRODID:-//Python Software Foundation//Python release schedule//EN',
30+
'X-WR-CALDESC:Python releases schedule from https://peps.python.org',
31+
'X-WR-CALNAME:Python releases schedule',
32+
]
33+
assert cal_lines[-2:] == [
34+
'END:VCALENDAR',
35+
'',
36+
]
37+
38+
39+
def test_create_release_calendar_first_event():
40+
# Act
41+
cal_lines = serialize.create_release_schedule_calendar().split('\n')
42+
43+
# Assert
44+
assert cal_lines[5] == 'BEGIN:VEVENT'
45+
assert cal_lines[6].startswith('SUMMARY:Python ')
46+
assert cal_lines[7].startswith('DTSTART;VALUE=DATE:')
47+
assert cal_lines[8].startswith('UID:python-')
48+
assert cal_lines[8].endswith('@release.python.org')
49+
assert cal_lines[9].startswith('URL:https://peps.python.org/pep-')
50+
assert cal_lines[10].startswith('END:VEVENT')

0 commit comments

Comments
 (0)