Skip to content

Commit 21d6d8d

Browse files
encukouCAM-Gerlachhugovk
authored
Generate the release cycle chart directly as SVG (#1034)
Co-authored-by: C.A.M. Gerlach <[email protected]> Co-authored-by: Hugo van Kemenade <[email protected]>
1 parent bd1c5cd commit 21d6d8d

12 files changed

+245
-217
lines changed

.github/workflows/release-cycle.yml

Lines changed: 0 additions & 31 deletions
This file was deleted.

.gitignore

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ __pycache__/
99
# Distribution / packaging
1010
.Python
1111
env/
12+
ENV/
1213
venv/
1314
build/
1415
develop-eggs/
@@ -80,13 +81,13 @@ celerybeat-schedule
8081
# dotenv
8182
.env
8283

83-
# virtualenv
84-
venv/
85-
ENV/
86-
venv/
87-
8884
# Spyder project settings
8985
.spyderproject
9086

9187
# Rope project settings
9288
.ropeproject
89+
90+
# Generated CSV and SVG files
91+
include/branches.csv
92+
include/end-of-life.csv
93+
include/release-cycle.svg

Makefile

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ html: ensure-venv versions
7373
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
7474

7575
.PHONY: dirhtml
76-
dirhtml: ensure-venv
76+
dirhtml: ensure-venv versions
7777
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
7878
@echo
7979
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
@@ -189,14 +189,14 @@ serve:
189189
"(see https://github.com/python/cpython/issues/80510)"
190190

191191
include/branches.csv: include/release-cycle.json
192-
$(PYTHON) _tools/generate_release_cycle.py
192+
$(VENVDIR)/bin/python3 _tools/generate_release_cycle.py
193193

194194
include/end-of-life.csv: include/release-cycle.json
195-
$(PYTHON) _tools/generate_release_cycle.py
195+
$(VENVDIR)/bin/python3 _tools/generate_release_cycle.py
196196

197-
include/release-cycle.mmd: include/release-cycle.json
198-
$(PYTHON) _tools/generate_release_cycle.py
197+
include/release-cycle.svg: include/release-cycle.json
198+
$(VENVDIR)/bin/python3 _tools/generate_release_cycle.py
199199

200200
.PHONY: versions
201-
versions: include/branches.csv include/end-of-life.csv include/release-cycle.mmd
201+
versions: venv include/branches.csv include/end-of-life.csv include/release-cycle.svg
202202
@echo Release cycle data generated.

_static/devguide_overrides.css

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,66 +7,69 @@
77
}
88

99
/* Release cycle chart */
10-
#python-release-cycle .mermaid .active0,
11-
#python-release-cycle .mermaid .active1,
12-
#python-release-cycle .mermaid .active2,
13-
#python-release-cycle .mermaid .active3 {
14-
fill: #00dd00;
15-
stroke: darkgreen;
10+
11+
.release-cycle-chart {
12+
width: 100%;
13+
}
14+
15+
.release-cycle-chart .release-cycle-year-line {
16+
stroke: var(--color-foreground-primary);
17+
stroke-width: 0.8px;
18+
opacity: 75%;
19+
}
20+
21+
.release-cycle-chart .release-cycle-year-text {
22+
fill: var(--color-foreground-primary);
1623
}
1724

18-
#python-release-cycle .mermaid .done0,
19-
#python-release-cycle .mermaid .done1,
20-
#python-release-cycle .mermaid .done2,
21-
#python-release-cycle .mermaid .done3 {
22-
fill: orange;
23-
stroke: darkorange;
25+
.release-cycle-chart .release-cycle-today-line {
26+
stroke: var(--color-brand-primary);
27+
stroke-width: 1.6px;
2428
}
2529

26-
#python-release-cycle .mermaid .task0,
27-
#python-release-cycle .mermaid .task1,
28-
#python-release-cycle .mermaid .task2,
29-
#python-release-cycle .mermaid .task3 {
30-
fill: #007acc;
31-
stroke: #004455;
30+
.release-cycle-chart .release-cycle-row-shade {
31+
fill: var(--color-background-item);
32+
opacity: 50%;
3233
}
3334

34-
#python-release-cycle .mermaid .section0,
35-
#python-release-cycle .mermaid .section2 {
36-
fill: darkgrey;
35+
.release-cycle-chart .release-cycle-version-label {
36+
fill: var(--color-foreground-primary);
3737
}
3838

39-
/* Set master colours */
40-
:root {
41-
--mermaid-section1-3: white;
42-
--mermaid-text-color: black;
39+
.release-cycle-chart .release-cycle-blob {
40+
stroke-width: 1.6px;
41+
/* default colours, overriden below for individual statuses */
42+
fill: var(--color-background-primary);
43+
stroke: var(--color-foreground-primary);
4344
}
4445

45-
@media (prefers-color-scheme: dark) {
46-
body[data-theme=auto] {
47-
--mermaid-section1-3: black;
48-
--mermaid-text-color: #ffffffcc;
49-
}
46+
.release-cycle-chart .release-cycle-blob-label {
47+
/* white looks good on both light & dark */
48+
fill: white;
5049
}
51-
body[data-theme=dark] {
52-
--mermaid-section1-3: black;
53-
--mermaid-text-color: #ffffffcc;
50+
51+
.release-cycle-chart .release-cycle-blob-label.release-cycle-blob-security,
52+
.release-cycle-chart .release-cycle-blob-label.release-cycle-blob-bugfix {
53+
/* but use black to improve contrast for lighter backgrounds */
54+
fill: black;
55+
}
56+
57+
.release-cycle-chart .release-cycle-blob.release-cycle-blob-end-of-life {
58+
fill: #DD2200;
59+
stroke: #FF8888;
60+
}
61+
62+
.release-cycle-chart .release-cycle-blob.release-cycle-blob-security {
63+
fill: #FFDD44;
64+
stroke: #FF8800;
5465
}
5566

56-
#python-release-cycle .mermaid .section1,
57-
#python-release-cycle .mermaid .section3 {
58-
fill: var(--mermaid-section1-3);
67+
.release-cycle-chart .release-cycle-blob.release-cycle-blob-bugfix {
68+
fill: #00DD22;
69+
stroke: #008844;
5970
}
6071

61-
#python-release-cycle .mermaid .grid .tick text,
62-
#python-release-cycle .mermaid .sectionTitle0,
63-
#python-release-cycle .mermaid .sectionTitle1,
64-
#python-release-cycle .mermaid .sectionTitle2,
65-
#python-release-cycle .mermaid .sectionTitle3,
66-
#python-release-cycle .mermaid .taskTextOutside0,
67-
#python-release-cycle .mermaid .taskTextOutside1,
68-
#python-release-cycle .mermaid .taskTextOutside2,
69-
#python-release-cycle .mermaid .taskTextOutside3,
70-
#python-release-cycle .mermaid .titleText {
71-
fill: var(--mermaid-text-color);
72+
.release-cycle-chart .release-cycle-blob.release-cycle-blob-feature {
73+
fill: #2222EE;
74+
stroke: #008888;
7275
}

_tools/generate_release_cycle.py

Lines changed: 89 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,12 @@
1-
"""Read in a JSON and generate two CSVs and a Mermaid file."""
1+
"""Read in a JSON and generate two CSVs and an SVG file."""
22
from __future__ import annotations
33

4+
import argparse
45
import csv
56
import datetime as dt
67
import json
78

8-
MERMAID_HEADER = """
9-
gantt
10-
dateFormat YYYY-MM-DD
11-
title Python release cycle
12-
axisFormat %Y
13-
""".lstrip()
14-
15-
MERMAID_SECTION = """
16-
section Python {version}
17-
{release_status} :{mermaid_status} python{version}, {first_release},{eol}
18-
""" # noqa: E501
19-
20-
MERMAID_STATUS_MAPPING = {
21-
"feature": "",
22-
"bugfix": "active,",
23-
"security": "done,",
24-
"end-of-life": "crit,",
25-
}
9+
import jinja2
2610

2711

2812
def csv_date(date_str: str, now_str: str) -> str:
@@ -33,23 +17,28 @@ def csv_date(date_str: str, now_str: str) -> str:
3317
return date_str
3418

3519

36-
def mermaid_date(date_str: str) -> str:
37-
"""Format a date for Mermaid."""
20+
def parse_date(date_str: str) -> dt.date:
3821
if len(date_str) == len("yyyy-mm"):
39-
# Mermaid needs a full yyyy-mm-dd, so let's approximate
40-
date_str = f"{date_str}-01"
41-
return date_str
22+
# We need a full yyyy-mm-dd, so let's approximate
23+
return dt.date.fromisoformat(date_str + "-01")
24+
return dt.date.fromisoformat(date_str)
4225

4326

4427
class Versions:
45-
"""For converting JSON to CSV and Mermaid."""
28+
"""For converting JSON to CSV and SVG."""
4629

4730
def __init__(self) -> None:
4831
with open("include/release-cycle.json", encoding="UTF-8") as in_file:
4932
self.versions = json.load(in_file)
33+
34+
# Generate a few additional fields
35+
for key, version in self.versions.items():
36+
version["key"] = key
37+
version["first_release_date"] = parse_date(version["first_release"])
38+
version["end_of_life_date"] = parse_date(version["end_of_life"])
5039
self.sorted_versions = sorted(
51-
self.versions.items(),
52-
key=lambda k: [int(i) for i in k[0].split(".")],
40+
self.versions.values(),
41+
key=lambda v: [int(i) for i in v["key"].split(".")],
5342
reverse=True,
5443
)
5544

@@ -59,7 +48,7 @@ def write_csv(self) -> None:
5948

6049
versions_by_category = {"branches": {}, "end-of-life": {}}
6150
headers = None
62-
for version, details in self.sorted_versions:
51+
for details in self.sorted_versions:
6352
row = {
6453
"Branch": details["branch"],
6554
"Schedule": f":pep:`{details['pep']}`",
@@ -70,38 +59,93 @@ def write_csv(self) -> None:
7059
}
7160
headers = row.keys()
7261
cat = "end-of-life" if details["status"] == "end-of-life" else "branches"
73-
versions_by_category[cat][version] = row
62+
versions_by_category[cat][details["key"]] = row
7463

7564
for cat, versions in versions_by_category.items():
7665
with open(f"include/{cat}.csv", "w", encoding="UTF-8", newline="") as file:
7766
csv_file = csv.DictWriter(file, fieldnames=headers, lineterminator="\n")
7867
csv_file.writeheader()
7968
csv_file.writerows(versions.values())
8069

81-
def write_mermaid(self) -> None:
82-
"""Output Mermaid file."""
83-
out = [MERMAID_HEADER]
84-
85-
for version, details in reversed(self.versions.items()):
86-
v = MERMAID_SECTION.format(
87-
version=version,
88-
first_release=details["first_release"],
89-
eol=mermaid_date(details["end_of_life"]),
90-
release_status=details["status"],
91-
mermaid_status=MERMAID_STATUS_MAPPING[details["status"]],
92-
)
93-
out.append(v)
70+
def write_svg(self, today: str) -> None:
71+
"""Output SVG file."""
72+
env = jinja2.Environment(
73+
loader=jinja2.FileSystemLoader("_tools/"),
74+
autoescape=True,
75+
lstrip_blocks=True,
76+
trim_blocks=True,
77+
undefined=jinja2.StrictUndefined,
78+
)
79+
template = env.get_template("release_cycle_template.svg.jinja")
80+
81+
# Scale. Should be roughly the pixel size of the font.
82+
# All later sizes are multiplied by this, so you can think of all other
83+
# numbers being multiples of the font size, like using `em` units in
84+
# CSS.
85+
# (Ideally we'd actually use `em` units, but SVG viewBox doesn't take
86+
# those.)
87+
SCALE = 18
88+
89+
# Width of the drawing and main parts
90+
DIAGRAM_WIDTH = 46
91+
LEGEND_WIDTH = 7
92+
RIGHT_MARGIN = 0.5
93+
94+
# Height of one line. If you change this you'll need to tweak
95+
# some positioning numbers in the template as well.
96+
LINE_HEIGHT = 1.5
97+
98+
first_date = min(ver["first_release_date"] for ver in self.sorted_versions)
99+
last_date = max(ver["end_of_life_date"] for ver in self.sorted_versions)
100+
101+
def date_to_x(date: dt.date) -> float:
102+
"""Convert datetime.date to an SVG X coordinate"""
103+
num_days = (date - first_date).days
104+
total_days = (last_date - first_date).days
105+
ratio = num_days / total_days
106+
x = ratio * (DIAGRAM_WIDTH - LEGEND_WIDTH - RIGHT_MARGIN)
107+
return x + LEGEND_WIDTH
108+
109+
def year_to_x(year: int) -> float:
110+
"""Convert year number to an SVG X coordinate of 1st January"""
111+
return date_to_x(dt.date(year, 1, 1))
112+
113+
def format_year(year: int) -> str:
114+
"""Format year number for display"""
115+
return f"'{year % 100:02}"
94116

95117
with open(
96-
"include/release-cycle.mmd", "w", encoding="UTF-8", newline="\n"
118+
"include/release-cycle.svg", "w", encoding="UTF-8", newline="\n"
97119
) as f:
98-
f.writelines(out)
120+
template.stream(
121+
SCALE=SCALE,
122+
diagram_width=DIAGRAM_WIDTH,
123+
diagram_height=(len(self.sorted_versions) + 2) * LINE_HEIGHT,
124+
years=range(first_date.year, last_date.year + 1),
125+
LINE_HEIGHT=LINE_HEIGHT,
126+
versions=list(reversed(self.sorted_versions)),
127+
today=dt.datetime.strptime(today, "%Y-%m-%d").date(),
128+
year_to_x=year_to_x,
129+
date_to_x=date_to_x,
130+
format_year=format_year,
131+
).dump(f)
99132

100133

101134
def main() -> None:
135+
parser = argparse.ArgumentParser(
136+
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
137+
)
138+
parser.add_argument(
139+
"--today",
140+
default=str(dt.date.today()),
141+
metavar=" YYYY-MM-DD",
142+
help="Override today for testing",
143+
)
144+
args = parser.parse_args()
145+
102146
versions = Versions()
103147
versions.write_csv()
104-
versions.write_mermaid()
148+
versions.write_svg(args.today)
105149

106150

107151
if __name__ == "__main__":

0 commit comments

Comments
 (0)