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
11 changes: 6 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ repos:
- '--check'
files: '^release_management/'

- repo: https://github.com/tox-dev/tox-ini-fmt
rev: 1.4.1
hooks:
- id: tox-ini-fmt
name: "Format tox.ini"
# Disabled: no support for comments as at 2025-11-27.
# - repo: https://github.com/tox-dev/tox-ini-fmt
# rev: 1.4.1
# hooks:
# - id: tox-ini-fmt
# name: "Format tox.ini"
Comment on lines +74 to +79
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @hugovk per #4715 (comment). The other option is to keep tox-ini-fmt but remove the comments entirely. What do you prefer?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep tox-ini-fmt and remove the comments, the comments are quite self-explanatory.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First time I encounter the need for autoformatting tox.ini !


- repo: https://github.com/sphinx-contrib/sphinx-lint
rev: v1.0.0
Expand Down
2 changes: 2 additions & 0 deletions .pytest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ addopts = [
# https://pytest-cov.readthedocs.io/en/latest/config.html#reference
"--cov=check_peps",
"--cov=pep_sphinx_extensions",
"--cov=release_management",
"--cov-report=html",
"--cov-report=xml",
]
Expand All @@ -23,6 +24,7 @@ filterwarnings = ["error"]

testpaths = [
"pep_sphinx_extensions",
"release_management",
]

# https://docs.pytest.org/en/stable/reference/reference.html#confval-strict
Expand Down
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")
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)
67 changes: 66 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,55 @@ 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]:
dtstamp = dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')

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.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'DTSTAMP:{dtstamp}',
f'UID:python-{normalised_stage}@releases.python.org',
f'DTSTART;VALUE=DATE:{release.date.strftime("%Y%m%d")}',
f'SUMMARY:Python {release.stage}',
*note,
f'URL:https://peps.python.org/pep-{pep_number:04d}/',
'END:VEVENT',
)
lines += (
'END:VCALENDAR',
'',
)
return lines
49 changes: 49 additions & 0 deletions release_management/tests/test_release_schedule_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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].startswith('DTSTAMP:')
assert cal_lines[6].endswith('Z')
assert cal_lines[7] == 'UID:[email protected]'
assert cal_lines[8] == 'DTSTART;VALUE=DATE:20000101'
assert cal_lines[9] == 'SUMMARY:Python X.Y.Z final'
assert cal_lines[10] == (
'DESCRIPTION:Note: These characters need escaping: \\\\ \\, \\; \\n'
)
assert cal_lines[11] == 'URL:https://peps.python.org/pep-9999/'
assert cal_lines[12] == 'END:VEVENT'
6 changes: 4 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ commands =

[coverage:run]
omit =
*/__main__.py # Ignore all __main__.py files
peps/* # Ignore all files in the PEPs folder
# Ignore all __main__.py files
*/__main__.py
# Ignore all files in the PEPs folder
peps/*

[coverage:report]
exclude_also =
Expand Down