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."""
2
2
from __future__ import annotations
3
3
4
+ import argparse
4
5
import csv
5
6
import datetime as dt
6
7
import json
7
8
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
26
10
27
11
28
12
def csv_date (date_str : str , now_str : str ) -> str :
@@ -33,23 +17,28 @@ def csv_date(date_str: str, now_str: str) -> str:
33
17
return date_str
34
18
35
19
36
- def mermaid_date (date_str : str ) -> str :
37
- """Format a date for Mermaid."""
20
+ def parse_date (date_str : str ) -> dt .date :
38
21
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 )
42
25
43
26
44
27
class Versions :
45
- """For converting JSON to CSV and Mermaid ."""
28
+ """For converting JSON to CSV and SVG ."""
46
29
47
30
def __init__ (self ) -> None :
48
31
with open ("include/release-cycle.json" , encoding = "UTF-8" ) as in_file :
49
32
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" ])
50
39
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 ("." )],
53
42
reverse = True ,
54
43
)
55
44
@@ -59,7 +48,7 @@ def write_csv(self) -> None:
59
48
60
49
versions_by_category = {"branches" : {}, "end-of-life" : {}}
61
50
headers = None
62
- for version , details in self .sorted_versions :
51
+ for details in self .sorted_versions :
63
52
row = {
64
53
"Branch" : details ["branch" ],
65
54
"Schedule" : f":pep:`{ details ['pep' ]} `" ,
@@ -70,38 +59,93 @@ def write_csv(self) -> None:
70
59
}
71
60
headers = row .keys ()
72
61
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
74
63
75
64
for cat , versions in versions_by_category .items ():
76
65
with open (f"include/{ cat } .csv" , "w" , encoding = "UTF-8" , newline = "" ) as file :
77
66
csv_file = csv .DictWriter (file , fieldnames = headers , lineterminator = "\n " )
78
67
csv_file .writeheader ()
79
68
csv_file .writerows (versions .values ())
80
69
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} "
94
116
95
117
with open (
96
- "include/release-cycle.mmd " , "w" , encoding = "UTF-8" , newline = "\n "
118
+ "include/release-cycle.svg " , "w" , encoding = "UTF-8" , newline = "\n "
97
119
) 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 )
99
132
100
133
101
134
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
+
102
146
versions = Versions ()
103
147
versions .write_csv ()
104
- versions .write_mermaid ( )
148
+ versions .write_svg ( args . today )
105
149
106
150
107
151
if __name__ == "__main__" :
0 commit comments