Skip to content

Commit 813ac0d

Browse files
committed
Add initial scripts
1 parent 13bf22c commit 813ac0d

File tree

4 files changed

+300
-0
lines changed

4 files changed

+300
-0
lines changed

release_engineering/__init__.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
import datetime as dt
5+
import tomllib
6+
from pathlib import Path
7+
8+
TYPE_CHECKING = False
9+
if TYPE_CHECKING:
10+
from typing import Literal, TypeAlias
11+
12+
ReleaseState: TypeAlias = Literal['actual', 'expected']
13+
ReleaseSchedules: TypeAlias = dict[tuple[str, ReleaseState], list['ReleaseInfo']]
14+
15+
RELEASE_DIR = Path(__file__).resolve().parent
16+
ROOT_DIR = RELEASE_DIR.parent
17+
PEP_ROOT = ROOT_DIR / 'peps'
18+
19+
20+
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
21+
class PythonReleases:
22+
metadata: dict[str, 'VersionMetadata']
23+
releases: dict[str, list['ReleaseInfo']]
24+
25+
26+
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
27+
class VersionMetadata:
28+
"""Metadata for a given interpreter version (MAJOR.MINOR)."""
29+
30+
pep: int
31+
status: str
32+
branch: str
33+
release_manager: str
34+
start_of_development: dt.date
35+
first_release: dt.date
36+
feature_freeze: dt.date
37+
end_of_bugfix: dt.date # a.k.a. security mode or source-only releases
38+
end_of_life: dt.date
39+
40+
@classmethod
41+
def from_toml(cls, data: dict[str, int | str | dt.date]):
42+
return cls(**{k.replace('-', '_'): v for k, v in data.items()})
43+
44+
45+
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
46+
class ReleaseInfo:
47+
"""Information about a release."""
48+
49+
stage: str
50+
state: ReleaseState
51+
date: dt.date
52+
note: str = '' # optional note / comment, displayed in the schedule
53+
54+
@property
55+
def schedule_bullet(self):
56+
"""Return a formatted bullet point for the schedule list."""
57+
return f'- {self.stage}: {self.date:%A, %Y-%m-%d}'
58+
59+
60+
def load_python_releases() -> PythonReleases:
61+
with open(RELEASE_DIR / 'python-releases.toml', 'rb') as f:
62+
python_releases = tomllib.load(f)
63+
all_metadata = {
64+
v: VersionMetadata.from_toml(metadata)
65+
for v, metadata in python_releases['metadata'].items()
66+
}
67+
all_releases = {
68+
v: [ReleaseInfo(**r) for r in releases]
69+
for v, releases in python_releases['release'].items()
70+
}
71+
return PythonReleases(metadata=all_metadata, releases=all_releases)

release_engineering/__main__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
5+
CMD_UPDATE_PEPS = 'update-peps'
6+
CMD_RELEASE_CYCLE = 'release-cycle'
7+
8+
PARSER = argparse.ArgumentParser(allow_abbrev=False)
9+
PARSER.add_argument('COMMAND', choices=[CMD_UPDATE_PEPS, CMD_RELEASE_CYCLE])
10+
11+
args = PARSER.parse_args()
12+
if args.COMMAND == CMD_UPDATE_PEPS:
13+
from release_engineering.update_release_schedules import update_peps
14+
15+
update_peps()
16+
elif args.COMMAND == CMD_RELEASE_CYCLE:
17+
from pathlib import Path
18+
19+
from release_engineering.generate_release_cycle import create_release_cycle
20+
21+
Path('release-cycle.json').write_text(create_release_cycle(), encoding='utf-8')
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
from release_engineering import load_python_releases
6+
7+
TYPE_CHECKING = False
8+
if TYPE_CHECKING:
9+
from release_engineering import VersionMetadata
10+
11+
12+
def create_release_cycle() -> str:
13+
metadata = load_python_releases().metadata
14+
versions = [v for v in metadata if version_to_tuple(v) >= (2, 6)]
15+
versions.sort(key=version_to_tuple, reverse=True)
16+
if '2.7' in versions:
17+
versions.remove('2.7')
18+
versions.insert(versions.index('3.1'), '2.7')
19+
20+
release_cycle = {version: version_info(metadata[version]) for version in versions}
21+
return (
22+
json.dumps(release_cycle, indent=2, sort_keys=False, ensure_ascii=False) + '\n'
23+
)
24+
25+
26+
def version_to_tuple(version: str, /) -> tuple[int, ...]:
27+
return tuple(map(int, version.split('.')))
28+
29+
30+
def version_info(metadata: VersionMetadata, /) -> dict[str, str]:
31+
end_of_life = metadata.end_of_life.isoformat()
32+
if metadata.status != 'end-of-life':
33+
end_of_life = end_of_life.removesuffix('-01')
34+
return {
35+
'branch': metadata.branch,
36+
'pep': metadata.pep,
37+
'status': metadata.status,
38+
'first_release': metadata.first_release.isoformat(),
39+
'end_of_life': end_of_life,
40+
'release_manager': metadata.release_manager,
41+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Update release schedules in PEPs.
2+
3+
The ``python-releases.toml`` data is treated as authoritative for the given
4+
versions in ``VERSIONS_TO_REGENERATE``. Each PEP must contain markers for the
5+
start and end of each release schedule (feature, bugfix, and security, as
6+
appropriate). These are:
7+
8+
.. feature release schedule
9+
.. bugfix release schedule
10+
.. security release schedule
11+
.. end of schedule
12+
13+
This script will use the dates in the [[release."{version}"]] tables to create
14+
and update the release schedule lists in each PEP.
15+
16+
Optionally, to add a comment or note to a particular release, use the ``note``
17+
field, which will append the given text in brackets to the relevant line.
18+
19+
Usage:
20+
21+
$ python -m release_engineering update-peps
22+
$ # or
23+
$ make regen-all
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import datetime as dt
29+
30+
from release_engineering import (
31+
PEP_ROOT,
32+
ReleaseInfo,
33+
VersionMetadata,
34+
load_python_releases,
35+
)
36+
37+
TYPE_CHECKING = False
38+
if TYPE_CHECKING:
39+
from collections.abc import Iterator
40+
41+
from release_engineering import ReleaseSchedules, ReleaseState, VersionMetadata
42+
43+
TODAY = dt.date.today()
44+
45+
VERSIONS_TO_REGENERATE = (
46+
'3.8',
47+
'3.9',
48+
'3.10',
49+
'3.11',
50+
'3.12',
51+
'3.13',
52+
'3.14',
53+
)
54+
55+
56+
def update_peps() -> None:
57+
python_releases = load_python_releases()
58+
for version in VERSIONS_TO_REGENERATE:
59+
metadata = python_releases.metadata[version]
60+
schedules = create_schedules(
61+
version,
62+
python_releases.releases[version],
63+
metadata.start_of_development,
64+
metadata.end_of_bugfix,
65+
)
66+
update_pep(metadata, schedules)
67+
68+
69+
def create_schedules(
70+
version: str,
71+
releases: list[ReleaseInfo],
72+
start_of_development: dt.date,
73+
bugfix_ends: dt.date,
74+
) -> ReleaseSchedules:
75+
schedules: ReleaseSchedules = {
76+
('feature', 'actual'): [],
77+
('feature', 'expected'): [],
78+
('bugfix', 'actual'): [],
79+
('bugfix', 'expected'): [],
80+
('security', 'actual'): [],
81+
}
82+
83+
# first entry into the dictionary
84+
db_state: ReleaseState = 'actual' if TODAY >= start_of_development else 'expected'
85+
schedules['feature', db_state].append(
86+
ReleaseInfo(
87+
stage=f'{version} development begins',
88+
state=db_state,
89+
date=start_of_development,
90+
)
91+
)
92+
93+
for release_info in releases:
94+
if release_info.stage.startswith(f'{version}.0'):
95+
schedules['feature', release_info.state].append(release_info)
96+
elif release_info.date <= bugfix_ends:
97+
schedules['bugfix', release_info.state].append(release_info)
98+
else:
99+
assert release_info.state == 'actual', release_info
100+
schedules['security', release_info.state].append(release_info)
101+
102+
return schedules
103+
104+
105+
def update_pep(metadata: VersionMetadata, schedules: ReleaseSchedules) -> None:
106+
pep_path = PEP_ROOT.joinpath(f'pep-{metadata.pep:0>4}.rst')
107+
pep_lines = iter(pep_path.read_text(encoding='utf-8').splitlines())
108+
output_lines: list[str] = []
109+
schedule_name = ''
110+
for line in pep_lines:
111+
output_lines.append(line)
112+
if line.startswith('.. ') and 'schedule' in line:
113+
schedule_name = line.split()[1]
114+
assert schedule_name in {'feature', 'bugfix', 'security'}
115+
output_lines += generate_schedule_lists(
116+
schedules,
117+
schedule_name=schedule_name,
118+
feature_freeze_date=metadata.feature_freeze,
119+
)
120+
121+
# skip source lines until the end of schedule marker
122+
while True:
123+
line = next(pep_lines, None)
124+
if line == '.. end of schedule':
125+
output_lines.append(line)
126+
break
127+
if line is None:
128+
raise ValueError('No end of schedule marker found!')
129+
130+
if not schedule_name:
131+
raise ValueError('No schedule markers found!')
132+
133+
with open(pep_path, 'w', encoding='utf-8') as f:
134+
f.write('\n'.join(output_lines))
135+
f.write('\n') # trailing newline
136+
137+
138+
def generate_schedule_lists(
139+
schedules: ReleaseSchedules,
140+
*,
141+
schedule_name: str,
142+
feature_freeze_date: dt.date = dt.date.min,
143+
) -> Iterator[str]:
144+
state: ReleaseState
145+
for state in 'actual', 'expected':
146+
if not schedules.get((schedule_name, state)):
147+
continue
148+
149+
yield ''
150+
if schedule_name != 'security':
151+
yield f'{state.title()}:'
152+
yield ''
153+
for release_info in schedules[schedule_name, state]:
154+
yield release_info.schedule_bullet
155+
if release_info.note:
156+
yield f' ({release_info.note})'
157+
if release_info.date == feature_freeze_date:
158+
yield ' (No new features beyond this point.)'
159+
160+
if schedule_name == 'bugfix':
161+
yield ' (final regular bugfix release with binary installers)'
162+
163+
yield ''
164+
165+
166+
if __name__ == '__main__':
167+
update_peps()

0 commit comments

Comments
 (0)