Skip to content

Generate the release cycle chart directly as SVG #1034

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Feb 1, 2023
Merged
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
31 changes: 0 additions & 31 deletions .github/workflows/release-cycle.yml

This file was deleted.

11 changes: 6 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ __pycache__/
# Distribution / packaging
.Python
env/
ENV/
venv/
build/
develop-eggs/
Expand Down Expand Up @@ -80,13 +81,13 @@ celerybeat-schedule
# dotenv
.env

# virtualenv
venv/
ENV/
venv/

# Spyder project settings
.spyderproject

# Rope project settings
.ropeproject

# Generated CSV and SVG files
include/branches.csv
include/end-of-life.csv
include/release-cycle.svg
12 changes: 6 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ html: ensure-venv versions
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."

.PHONY: dirhtml
dirhtml: ensure-venv
dirhtml: ensure-venv versions
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
Expand Down Expand Up @@ -189,14 +189,14 @@ serve:
"(see https://github.com/python/cpython/issues/80510)"

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

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

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

.PHONY: versions
versions: include/branches.csv include/end-of-life.csv include/release-cycle.mmd
versions: venv include/branches.csv include/end-of-life.csv include/release-cycle.svg
@echo Release cycle data generated.
97 changes: 50 additions & 47 deletions _static/devguide_overrides.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,66 +7,69 @@
}

/* Release cycle chart */
#python-release-cycle .mermaid .active0,
#python-release-cycle .mermaid .active1,
#python-release-cycle .mermaid .active2,
#python-release-cycle .mermaid .active3 {
fill: #00dd00;
stroke: darkgreen;

.release-cycle-chart {
width: 100%;
}

.release-cycle-chart .release-cycle-year-line {
stroke: var(--color-foreground-primary);
stroke-width: 0.8px;
opacity: 75%;
}

.release-cycle-chart .release-cycle-year-text {
fill: var(--color-foreground-primary);
}

#python-release-cycle .mermaid .done0,
#python-release-cycle .mermaid .done1,
#python-release-cycle .mermaid .done2,
#python-release-cycle .mermaid .done3 {
fill: orange;
stroke: darkorange;
.release-cycle-chart .release-cycle-today-line {
stroke: var(--color-brand-primary);
stroke-width: 1.6px;
}

#python-release-cycle .mermaid .task0,
#python-release-cycle .mermaid .task1,
#python-release-cycle .mermaid .task2,
#python-release-cycle .mermaid .task3 {
fill: #007acc;
stroke: #004455;
.release-cycle-chart .release-cycle-row-shade {
fill: var(--color-background-item);
opacity: 50%;
}

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

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

@media (prefers-color-scheme: dark) {
body[data-theme=auto] {
--mermaid-section1-3: black;
--mermaid-text-color: #ffffffcc;
}
.release-cycle-chart .release-cycle-blob-label {
/* white looks good on both light & dark */
fill: white;
}
body[data-theme=dark] {
--mermaid-section1-3: black;
--mermaid-text-color: #ffffffcc;

.release-cycle-chart .release-cycle-blob-label.release-cycle-blob-security,
.release-cycle-chart .release-cycle-blob-label.release-cycle-blob-bugfix {
/* but use black to improve contrast for lighter backgrounds */
fill: black;
}

.release-cycle-chart .release-cycle-blob.release-cycle-blob-end-of-life {
fill: #DD2200;
stroke: #FF8888;
}

.release-cycle-chart .release-cycle-blob.release-cycle-blob-security {
fill: #FFDD44;
stroke: #FF8800;
}

#python-release-cycle .mermaid .section1,
#python-release-cycle .mermaid .section3 {
fill: var(--mermaid-section1-3);
.release-cycle-chart .release-cycle-blob.release-cycle-blob-bugfix {
fill: #00DD22;
stroke: #008844;
}

#python-release-cycle .mermaid .grid .tick text,
#python-release-cycle .mermaid .sectionTitle0,
#python-release-cycle .mermaid .sectionTitle1,
#python-release-cycle .mermaid .sectionTitle2,
#python-release-cycle .mermaid .sectionTitle3,
#python-release-cycle .mermaid .taskTextOutside0,
#python-release-cycle .mermaid .taskTextOutside1,
#python-release-cycle .mermaid .taskTextOutside2,
#python-release-cycle .mermaid .taskTextOutside3,
#python-release-cycle .mermaid .titleText {
fill: var(--mermaid-text-color);
.release-cycle-chart .release-cycle-blob.release-cycle-blob-feature {
fill: #2222EE;
stroke: #008888;
}
134 changes: 89 additions & 45 deletions _tools/generate_release_cycle.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,12 @@
"""Read in a JSON and generate two CSVs and a Mermaid file."""
"""Read in a JSON and generate two CSVs and an SVG file."""
from __future__ import annotations

import argparse
import csv
import datetime as dt
import json

MERMAID_HEADER = """
gantt
dateFormat YYYY-MM-DD
title Python release cycle
axisFormat %Y
""".lstrip()

MERMAID_SECTION = """
section Python {version}
{release_status} :{mermaid_status} python{version}, {first_release},{eol}
""" # noqa: E501

MERMAID_STATUS_MAPPING = {
"feature": "",
"bugfix": "active,",
"security": "done,",
"end-of-life": "crit,",
}
import jinja2


def csv_date(date_str: str, now_str: str) -> str:
Expand All @@ -33,23 +17,28 @@ def csv_date(date_str: str, now_str: str) -> str:
return date_str


def mermaid_date(date_str: str) -> str:
"""Format a date for Mermaid."""
def parse_date(date_str: str) -> dt.date:
if len(date_str) == len("yyyy-mm"):
# Mermaid needs a full yyyy-mm-dd, so let's approximate
date_str = f"{date_str}-01"
return date_str
# We need a full yyyy-mm-dd, so let's approximate
return dt.date.fromisoformat(date_str + "-01")
return dt.date.fromisoformat(date_str)


class Versions:
"""For converting JSON to CSV and Mermaid."""
"""For converting JSON to CSV and SVG."""

def __init__(self) -> None:
with open("include/release-cycle.json", encoding="UTF-8") as in_file:
self.versions = json.load(in_file)

# Generate a few additional fields
for key, version in self.versions.items():
version["key"] = key
version["first_release_date"] = parse_date(version["first_release"])
version["end_of_life_date"] = parse_date(version["end_of_life"])
self.sorted_versions = sorted(
self.versions.items(),
key=lambda k: [int(i) for i in k[0].split(".")],
self.versions.values(),
key=lambda v: [int(i) for i in v["key"].split(".")],
reverse=True,
)

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

versions_by_category = {"branches": {}, "end-of-life": {}}
headers = None
for version, details in self.sorted_versions:
for details in self.sorted_versions:
row = {
"Branch": details["branch"],
"Schedule": f":pep:`{details['pep']}`",
Expand All @@ -70,38 +59,93 @@ def write_csv(self) -> None:
}
headers = row.keys()
cat = "end-of-life" if details["status"] == "end-of-life" else "branches"
versions_by_category[cat][version] = row
versions_by_category[cat][details["key"]] = row

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

def write_mermaid(self) -> None:
"""Output Mermaid file."""
out = [MERMAID_HEADER]

for version, details in reversed(self.versions.items()):
v = MERMAID_SECTION.format(
version=version,
first_release=details["first_release"],
eol=mermaid_date(details["end_of_life"]),
release_status=details["status"],
mermaid_status=MERMAID_STATUS_MAPPING[details["status"]],
)
out.append(v)
def write_svg(self, today: str) -> None:
"""Output SVG file."""
env = jinja2.Environment(
loader=jinja2.FileSystemLoader("_tools/"),
autoescape=True,
lstrip_blocks=True,
trim_blocks=True,
undefined=jinja2.StrictUndefined,
)
template = env.get_template("release_cycle_template.svg.jinja")

# Scale. Should be roughly the pixel size of the font.
# All later sizes are multiplied by this, so you can think of all other
# numbers being multiples of the font size, like using `em` units in
# CSS.
# (Ideally we'd actually use `em` units, but SVG viewBox doesn't take
# those.)
SCALE = 18

# Width of the drawing and main parts
DIAGRAM_WIDTH = 46
LEGEND_WIDTH = 7
RIGHT_MARGIN = 0.5

# Height of one line. If you change this you'll need to tweak
# some positioning numbers in the template as well.
LINE_HEIGHT = 1.5

first_date = min(ver["first_release_date"] for ver in self.sorted_versions)
last_date = max(ver["end_of_life_date"] for ver in self.sorted_versions)

def date_to_x(date: dt.date) -> float:
"""Convert datetime.date to an SVG X coordinate"""
num_days = (date - first_date).days
total_days = (last_date - first_date).days
ratio = num_days / total_days
x = ratio * (DIAGRAM_WIDTH - LEGEND_WIDTH - RIGHT_MARGIN)
return x + LEGEND_WIDTH

def year_to_x(year: int) -> float:
"""Convert year number to an SVG X coordinate of 1st January"""
return date_to_x(dt.date(year, 1, 1))

def format_year(year: int) -> str:
"""Format year number for display"""
return f"'{year % 100:02}"

with open(
"include/release-cycle.mmd", "w", encoding="UTF-8", newline="\n"
"include/release-cycle.svg", "w", encoding="UTF-8", newline="\n"
) as f:
f.writelines(out)
template.stream(
SCALE=SCALE,
diagram_width=DIAGRAM_WIDTH,
diagram_height=(len(self.sorted_versions) + 2) * LINE_HEIGHT,
years=range(first_date.year, last_date.year + 1),
LINE_HEIGHT=LINE_HEIGHT,
versions=list(reversed(self.sorted_versions)),
today=dt.datetime.strptime(today, "%Y-%m-%d").date(),
year_to_x=year_to_x,
date_to_x=date_to_x,
format_year=format_year,
).dump(f)


def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
"--today",
default=str(dt.date.today()),
metavar=" YYYY-MM-DD",
help="Override today for testing",
)
args = parser.parse_args()

versions = Versions()
versions.write_csv()
versions.write_mermaid()
versions.write_svg(args.today)


if __name__ == "__main__":
Expand Down
Loading