Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'