From 84d684d25066375f494af20fc506a9a1731e9e0c Mon Sep 17 00:00:00 2001 From: bhargav Date: Sat, 8 Nov 2025 19:06:35 +0530 Subject: [PATCH 01/18] added automatic release svg generation --- Makefile | 1 + .../templates/releases/download.html | 2 +- tools/generate_release_roadmap.py | 220 ++++++++++++++++++ tools/release-data.json | 58 +++++ tools/template.svg.jinja | 84 +++++++ 5 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 tools/generate_release_roadmap.py create mode 100644 tools/release-data.json create mode 100644 tools/template.svg.jinja diff --git a/Makefile b/Makefile index 910ce300e4..cef90a3b7e 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ SCSS = djangoproject/scss STATIC = djangoproject/static ci: compilemessages test + @python tools/generate_release_roadmap.py @python -m coverage report compilemessages: diff --git a/djangoproject/templates/releases/download.html b/djangoproject/templates/releases/download.html index 21d31c81a2..b0e37e2155 100644 --- a/djangoproject/templates/releases/download.html +++ b/djangoproject/templates/releases/download.html @@ -74,7 +74,7 @@

Supported Versions

See the supported versions policy for detailed guidelines about what fixes will be backported.

- Django release roadmap + Django release roadmap
diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py new file mode 100644 index 0000000000..efc7c631d9 --- /dev/null +++ b/tools/generate_release_roadmap.py @@ -0,0 +1,220 @@ +import datetime as dtime +from jinja2 import Environment, FileSystemLoader +import json + +def load_release_data(json_file): + with open(json_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + processed_data = [] + for item in data: + item['release_date'] = dtime.datetime.strptime(item['release_date'], '%Y-%m-%d').date() + item['mainstream_end'] = dtime.datetime.strptime(item['mainstream_end'], '%Y-%m-%d').date() + item['extended_end'] = dtime.datetime.strptime(item['extended_end'], '%Y-%m-%d').date() + processed_data.append(item) + return processed_data + +COLORS = { + "mainstream": "#0C4B33", + "extended": "#CBFDE9", + "grid": "#333333", + "text": "#FFFFFF", + "text_lts": "#0C4B33", + "bg": "transparent", +} + +CONFIG = { + "pixels_per_year": 120, + "bar_height": 20, + "bar_v_spacing": 20, + "padding_top": 30, + "padding_bottom": 20, + "padding_left": 20, + "padding_right": 10, + "font_family": "'Segoe UI', 'Arial'", + "font_size": 12, + "font_size_small": 10, + "font_weight": "bold", + "font_weight_lts": "600", + "font_style_lts": "italic", + "legend_box_size": 14, + "legend_spacing": 150, + "text_padding_x": 10, + "year_line_width": 3, + "month_line_width": 1, +} + +def get_chart_timeline(data, config): + + start_year = data[0]['release_date'].year + + max_end_date = max(d['extended_end'] for d in data) + + end_year = max_end_date.year + 1 + + total_years = end_year - start_year + chart_width = total_years * config['pixels_per_year'] + svg_width = chart_width + config['padding_left'] + config['padding_right'] + + + return start_year, end_year, int(svg_width) + +def calculate_dimensions(config, num_releases): + + chart_height = ( + config['padding_top'] + + config['padding_bottom'] + + (num_releases * config['bar_height']) + + ((num_releases - 1) * config['bar_v_spacing']) + ) + return int(chart_height) + +def date_to_x(date, start_year, config): + + pixels_per_year = config['pixels_per_year'] + pixels_per_block = pixels_per_year / 3.0 + start_x = config['padding_left'] + + year_offset = (date.year - start_year) * pixels_per_year + + if 1 <= date.month <= 4: + + block_num = 0 + elif 5 <= date.month <= 8: + + block_num = 1 + else: + + block_num = 2 + + block_x_end = year_offset + ((block_num + 1) * pixels_per_block) + + return start_x + block_x_end + +def generate_grids(start_year, end_year, config): + + grid_lines = [] + pixels_per_year = config['pixels_per_year'] + pixels_per_block = pixels_per_year / 3.0 + + for i, year in enumerate(range(start_year, end_year + 1)): + year_x_start = config['padding_left'] + (i * pixels_per_year) + + for i in range(3): + grid_lines.append({ + "x": year_x_start + (i * pixels_per_block), + "width": config['year_line_width'] if i==0 else config['month_line_width'], + "label": str(year) if i == 0 else None + }) + return grid_lines + +def generate_releases(data, start_year, config): + + releases_processed = [] + for i, release in enumerate(data): + bar_y = config['padding_top'] + (i * (config['bar_height'] + config['bar_v_spacing'])) + text_y_center = bar_y + (config['bar_height'] / 2) + (config['font_size'] / 3) + + x_start = date_to_x(release['release_date'], start_year, config) + x_end_mainstream = date_to_x(release['mainstream_end'], start_year, config) + x_end_extended = date_to_x(release['extended_end'], start_year, config) + + mainstream_bar = { + "x": x_start, "y": bar_y, + "width": x_end_mainstream - x_start, + "height": config['bar_height'], + "fill": COLORS['mainstream'] + } + + extended_bar = { + "x": x_end_mainstream, "y": bar_y, + "width": x_end_extended - x_end_mainstream, + "height": config['bar_height'], + "fill": COLORS['extended'] + } + + version_text = { + "x": x_start + config['text_padding_x'], + "y": text_y_center, + "text": release['name'] + } + + lts_text = None + if release.get('is_lts', False): + lts_text = { + "x": x_end_mainstream + config['text_padding_x'], + "y": text_y_center, + "text": "LTS" + } + + releases_processed.append({ + "mainstream_bar": mainstream_bar, + "extended_bar": extended_bar, + "version_text": version_text, + "lts_text": lts_text + }) + return releases_processed + +def generate_legend(config,svg_height): + + legend_y = svg_height - (config['padding_bottom'] / 2) + legend2_x = config['padding_left'] + config['legend_spacing'] + + legend = { + "mainstream_box": { + "x": config['padding_left'], + "y": legend_y - config['legend_box_size'] + 2, + "size": config['legend_box_size'], + "fill": COLORS['mainstream'] + }, + "mainstream_text": { + "x": config['padding_left'] + config['legend_box_size'] + 5, + "y": legend_y, + "text": "Mainstream Support" + }, + "extended_box": { + "x": legend2_x, + "y": legend_y - config['legend_box_size'] + 2, + "size": config['legend_box_size'], + "fill": COLORS['extended'] + }, + "extended_text": { + "x": legend2_x + config['legend_box_size'] + 5, + "y": legend_y, + "text": "Extended Support" + } + } + return legend + +def render_svg(): + + data=load_release_data('release-data.json') + + start_year, end_year, svg_width = get_chart_timeline(data, CONFIG) + svg_height = calculate_dimensions(CONFIG, len(data)) + + grid_lines = generate_grids(start_year, end_year, CONFIG) + releases_processed = generate_releases(data, start_year, CONFIG) + + legend = generate_legend(CONFIG, svg_height) + + env = Environment(loader=FileSystemLoader('.')) + template = env.get_template('template.svg.jinja') + + output_svg = template.render( + svg_width=svg_width, + svg_height=svg_height, + config=CONFIG, + colors=COLORS, + grid_lines=grid_lines, + releases=releases_processed, + legend=legend, + ) + + outfile="../djangoproject/static/img/release-roadmap.svg" + + with open(outfile, 'w', encoding='utf-8') as f: + f.write(output_svg) + +if __name__ == "__main__": + render_svg() \ No newline at end of file diff --git a/tools/release-data.json b/tools/release-data.json new file mode 100644 index 0000000000..dc296f2733 --- /dev/null +++ b/tools/release-data.json @@ -0,0 +1,58 @@ +[ + { + "name": "4.2", + "is_lts": true, + "release_date": "2023-04-03", + "mainstream_end": "2023-12-04", + "extended_end": "2026-04-01" + }, + { + "name": "5.0", + "is_lts": false, + "release_date": "2023-12-04", + "mainstream_end": "2024-08-01", + "extended_end": "2025-04-01" + }, + { + "name": "5.1", + "is_lts": false, + "release_date": "2024-08-01", + "mainstream_end": "2025-04-01", + "extended_end": "2025-12-01" + }, + { + "name": "5.2", + "is_lts": true, + "release_date": "2025-04-01", + "mainstream_end": "2025-12-01", + "extended_end": "2028-04-01" + }, + { + "name": "6.0", + "is_lts": false, + "release_date": "2025-12-01", + "mainstream_end": "2026-08-01", + "extended_end": "2027-04-01" + }, + { + "name": "6.1", + "is_lts": false, + "release_date": "2026-08-01", + "mainstream_end": "2027-04-01", + "extended_end": "2027-12-01" + }, + { + "name": "6.2", + "is_lts": true, + "release_date": "2027-04-01", + "mainstream_end": "2027-12-01", + "extended_end": "2030-04-01" + }, + { + "name": "7.0", + "is_lts": false, + "release_date": "2027-12-01", + "mainstream_end": "2028-08-01", + "extended_end": "2029-04-01" + } +] \ No newline at end of file diff --git a/tools/template.svg.jinja b/tools/template.svg.jinja new file mode 100644 index 0000000000..0c107ed376 --- /dev/null +++ b/tools/template.svg.jinja @@ -0,0 +1,84 @@ + + + + + + + + + + + + {% for line in grid_lines %} + + {% if line.label %} + {{ line.label }} + {% endif %} + {% endfor %} + + {% for release in releases %} + {% if release.mainstream_bar.width > 0 %} + + {% endif %} + + {% if release.extended_bar.width > 0 %} + + {% endif %} + + + {{ release.version_text.text }} + + + {% if release.lts_text %} + + {{ release.lts_text.text }} + + {% endif %} + {% endfor %} + + + + {{ legend.mainstream_text.text }} + + + + + {{ legend.extended_text.text }} + + + + + \ No newline at end of file From 06511ea26d7dbaf6191b622f3f3ff4d63177eadc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 8 Nov 2025 13:42:30 +0000 Subject: [PATCH 02/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tools/generate_release_roadmap.py | 248 +++++++++++++++++------------- tools/template.svg.jinja | 12 +- 2 files changed, 144 insertions(+), 116 deletions(-) diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index efc7c631d9..19aec5844f 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -1,206 +1,233 @@ import datetime as dtime +import json + from jinja2 import Environment, FileSystemLoader -import json + def load_release_data(json_file): - with open(json_file, 'r', encoding='utf-8') as f: + with open(json_file, encoding="utf-8") as f: data = json.load(f) - + processed_data = [] for item in data: - item['release_date'] = dtime.datetime.strptime(item['release_date'], '%Y-%m-%d').date() - item['mainstream_end'] = dtime.datetime.strptime(item['mainstream_end'], '%Y-%m-%d').date() - item['extended_end'] = dtime.datetime.strptime(item['extended_end'], '%Y-%m-%d').date() + item["release_date"] = dtime.datetime.strptime( + item["release_date"], "%Y-%m-%d" + ).date() + item["mainstream_end"] = dtime.datetime.strptime( + item["mainstream_end"], "%Y-%m-%d" + ).date() + item["extended_end"] = dtime.datetime.strptime( + item["extended_end"], "%Y-%m-%d" + ).date() processed_data.append(item) return processed_data + COLORS = { - "mainstream": "#0C4B33", - "extended": "#CBFDE9", - "grid": "#333333", - "text": "#FFFFFF", - "text_lts": "#0C4B33", - "bg": "transparent", + "mainstream": "#0C4B33", + "extended": "#CBFDE9", + "grid": "#333333", + "text": "#FFFFFF", + "text_lts": "#0C4B33", + "bg": "transparent", } CONFIG = { - "pixels_per_year": 120, + "pixels_per_year": 120, "bar_height": 20, - "bar_v_spacing": 20, - "padding_top": 30, + "bar_v_spacing": 20, + "padding_top": 30, "padding_bottom": 20, - "padding_left": 20, + "padding_left": 20, "padding_right": 10, "font_family": "'Segoe UI', 'Arial'", "font_size": 12, - "font_size_small": 10, + "font_size_small": 10, "font_weight": "bold", "font_weight_lts": "600", "font_style_lts": "italic", "legend_box_size": 14, - "legend_spacing": 150, - "text_padding_x": 10, - "year_line_width": 3, - "month_line_width": 1, + "legend_spacing": 150, + "text_padding_x": 10, + "year_line_width": 3, + "month_line_width": 1, } + def get_chart_timeline(data, config): - - start_year = data[0]['release_date'].year - - max_end_date = max(d['extended_end'] for d in data) - - end_year = max_end_date.year + 1 - + + start_year = data[0]["release_date"].year + + max_end_date = max(d["extended_end"] for d in data) + + end_year = max_end_date.year + 1 + total_years = end_year - start_year - chart_width = total_years * config['pixels_per_year'] - svg_width = chart_width + config['padding_left'] + config['padding_right'] - - + chart_width = total_years * config["pixels_per_year"] + svg_width = chart_width + config["padding_left"] + config["padding_right"] + return start_year, end_year, int(svg_width) + def calculate_dimensions(config, num_releases): - + chart_height = ( - config['padding_top'] + - config['padding_bottom'] + - (num_releases * config['bar_height']) + - ((num_releases - 1) * config['bar_v_spacing']) + config["padding_top"] + + config["padding_bottom"] + + (num_releases * config["bar_height"]) + + ((num_releases - 1) * config["bar_v_spacing"]) ) return int(chart_height) + def date_to_x(date, start_year, config): - - pixels_per_year = config['pixels_per_year'] + + pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 - start_x = config['padding_left'] + start_x = config["padding_left"] year_offset = (date.year - start_year) * pixels_per_year if 1 <= date.month <= 4: - + block_num = 0 elif 5 <= date.month <= 8: - + block_num = 1 else: - + block_num = 2 block_x_end = year_offset + ((block_num + 1) * pixels_per_block) return start_x + block_x_end + def generate_grids(start_year, end_year, config): - + grid_lines = [] - pixels_per_year = config['pixels_per_year'] + pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 - + for i, year in enumerate(range(start_year, end_year + 1)): - year_x_start = config['padding_left'] + (i * pixels_per_year) + year_x_start = config["padding_left"] + (i * pixels_per_year) for i in range(3): - grid_lines.append({ - "x": year_x_start + (i * pixels_per_block), - "width": config['year_line_width'] if i==0 else config['month_line_width'], - "label": str(year) if i == 0 else None - }) + grid_lines.append( + { + "x": year_x_start + (i * pixels_per_block), + "width": ( + config["year_line_width"] + if i == 0 + else config["month_line_width"] + ), + "label": str(year) if i == 0 else None, + } + ) return grid_lines + def generate_releases(data, start_year, config): - + releases_processed = [] for i, release in enumerate(data): - bar_y = config['padding_top'] + (i * (config['bar_height'] + config['bar_v_spacing'])) - text_y_center = bar_y + (config['bar_height'] / 2) + (config['font_size'] / 3) - - x_start = date_to_x(release['release_date'], start_year, config) - x_end_mainstream = date_to_x(release['mainstream_end'], start_year, config) - x_end_extended = date_to_x(release['extended_end'], start_year, config) - + bar_y = config["padding_top"] + ( + i * (config["bar_height"] + config["bar_v_spacing"]) + ) + text_y_center = bar_y + (config["bar_height"] / 2) + (config["font_size"] / 3) + + x_start = date_to_x(release["release_date"], start_year, config) + x_end_mainstream = date_to_x(release["mainstream_end"], start_year, config) + x_end_extended = date_to_x(release["extended_end"], start_year, config) + mainstream_bar = { - "x": x_start, "y": bar_y, + "x": x_start, + "y": bar_y, "width": x_end_mainstream - x_start, - "height": config['bar_height'], - "fill": COLORS['mainstream'] + "height": config["bar_height"], + "fill": COLORS["mainstream"], } - + extended_bar = { - "x": x_end_mainstream, "y": bar_y, + "x": x_end_mainstream, + "y": bar_y, "width": x_end_extended - x_end_mainstream, - "height": config['bar_height'], - "fill": COLORS['extended'] + "height": config["bar_height"], + "fill": COLORS["extended"], } - + version_text = { - "x": x_start + config['text_padding_x'], + "x": x_start + config["text_padding_x"], "y": text_y_center, - "text": release['name'] + "text": release["name"], } - + lts_text = None - if release.get('is_lts', False): + if release.get("is_lts", False): lts_text = { - "x": x_end_mainstream + config['text_padding_x'], + "x": x_end_mainstream + config["text_padding_x"], "y": text_y_center, - "text": "LTS" + "text": "LTS", } - - releases_processed.append({ - "mainstream_bar": mainstream_bar, - "extended_bar": extended_bar, - "version_text": version_text, - "lts_text": lts_text - }) + + releases_processed.append( + { + "mainstream_bar": mainstream_bar, + "extended_bar": extended_bar, + "version_text": version_text, + "lts_text": lts_text, + } + ) return releases_processed -def generate_legend(config,svg_height): - - legend_y = svg_height - (config['padding_bottom'] / 2) - legend2_x = config['padding_left'] + config['legend_spacing'] - + +def generate_legend(config, svg_height): + + legend_y = svg_height - (config["padding_bottom"] / 2) + legend2_x = config["padding_left"] + config["legend_spacing"] + legend = { "mainstream_box": { - "x": config['padding_left'], - "y": legend_y - config['legend_box_size'] + 2, - "size": config['legend_box_size'], - "fill": COLORS['mainstream'] + "x": config["padding_left"], + "y": legend_y - config["legend_box_size"] + 2, + "size": config["legend_box_size"], + "fill": COLORS["mainstream"], }, "mainstream_text": { - "x": config['padding_left'] + config['legend_box_size'] + 5, + "x": config["padding_left"] + config["legend_box_size"] + 5, "y": legend_y, - "text": "Mainstream Support" + "text": "Mainstream Support", }, "extended_box": { "x": legend2_x, - "y": legend_y - config['legend_box_size'] + 2, - "size": config['legend_box_size'], - "fill": COLORS['extended'] + "y": legend_y - config["legend_box_size"] + 2, + "size": config["legend_box_size"], + "fill": COLORS["extended"], }, "extended_text": { - "x": legend2_x + config['legend_box_size'] + 5, + "x": legend2_x + config["legend_box_size"] + 5, "y": legend_y, - "text": "Extended Support" - } + "text": "Extended Support", + }, } return legend + def render_svg(): - data=load_release_data('release-data.json') - + data = load_release_data("release-data.json") + start_year, end_year, svg_width = get_chart_timeline(data, CONFIG) svg_height = calculate_dimensions(CONFIG, len(data)) - + grid_lines = generate_grids(start_year, end_year, CONFIG) releases_processed = generate_releases(data, start_year, CONFIG) legend = generate_legend(CONFIG, svg_height) - - env = Environment(loader=FileSystemLoader('.')) - template = env.get_template('template.svg.jinja') - + + env = Environment(loader=FileSystemLoader(".")) + template = env.get_template("template.svg.jinja") + output_svg = template.render( svg_width=svg_width, svg_height=svg_height, @@ -210,11 +237,12 @@ def render_svg(): releases=releases_processed, legend=legend, ) - - outfile="../djangoproject/static/img/release-roadmap.svg" - with open(outfile, 'w', encoding='utf-8') as f: + outfile = "../djangoproject/static/img/release-roadmap.svg" + + with open(outfile, "w", encoding="utf-8") as f: f.write(output_svg) - + + if __name__ == "__main__": - render_svg() \ No newline at end of file + render_svg() diff --git a/tools/template.svg.jinja b/tools/template.svg.jinja index 0c107ed376..a6cf289097 100644 --- a/tools/template.svg.jinja +++ b/tools/template.svg.jinja @@ -47,17 +47,17 @@ width="{{ release.mainstream_bar.width }}" height="{{ release.mainstream_bar.height }}" fill="{{ release.mainstream_bar.fill }}" /> {% endif %} - + {% if release.extended_bar.width > 0 %} {% endif %} - + {{ release.version_text.text }} - + {% if release.lts_text %} {{ release.lts_text.text }} @@ -71,7 +71,7 @@ {{ legend.mainstream_text.text }} - + @@ -79,6 +79,6 @@ {{ legend.extended_text.text }} - - \ No newline at end of file + + From 2213e0ea754ac7b762d765172282faabbead0c3c Mon Sep 17 00:00:00 2001 From: Bhargav <101399269+bhargav-j47@users.noreply.github.com> Date: Sat, 8 Nov 2025 20:06:12 +0530 Subject: [PATCH 03/18] Update generate_release_roadmap.py fixed colors --- tools/generate_release_roadmap.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index 19aec5844f..9644ee1394 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -29,7 +29,7 @@ def load_release_data(json_file): "grid": "#333333", "text": "#FFFFFF", "text_lts": "#0C4B33", - "bg": "transparent", + "bg": "#000000", } CONFIG = { @@ -246,3 +246,4 @@ def render_svg(): if __name__ == "__main__": render_svg() + From 1b18f70fa8493293a519307c98275b776f58d9f9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 8 Nov 2025 14:38:34 +0000 Subject: [PATCH 04/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tools/generate_release_roadmap.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index 9644ee1394..99d7b225ed 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -246,4 +246,3 @@ def render_svg(): if __name__ == "__main__": render_svg() - From 0a426405116b344c55e1b19719a338d23e7d0467 Mon Sep 17 00:00:00 2001 From: Bhargav <101399269+bhargav-j47@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:17:07 +0530 Subject: [PATCH 05/18] Update tools/template.svg.jinja Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> --- tools/template.svg.jinja | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/template.svg.jinja b/tools/template.svg.jinja index a6cf289097..0749d4b2f8 100644 --- a/tools/template.svg.jinja +++ b/tools/template.svg.jinja @@ -36,8 +36,18 @@ {% for line in grid_lines %} - {% if line.label %} - {{ line.label }} + {% if line.top_label %} + + + {{ line.top_label }} + + {% endif %} + + {% if line.bottom_label %} + + + {{ line.bottom_label }} + {% endif %} {% endfor %} From 4a49490cbce72da22129e0cf26e0ae27c652a59f Mon Sep 17 00:00:00 2001 From: Bhargav <101399269+bhargav-j47@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:35:37 +0530 Subject: [PATCH 06/18] Update tools/generate_release_roadmap.py Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> --- tools/generate_release_roadmap.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index 99d7b225ed..5e7186473e 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -1,9 +1,39 @@ +""" +Generates an SVG roadmap of Django releases, showing mainstream and extended support periods. + +Usage: + python generate_release_roadmap.py --first-release --date + +Arguments: + --first-release The first release number in Django versioning style, e.g., "4.2" + --date The release date of the first release in YYYY-MM format, e.g., "2023-04" + +Behavior: + - Automatically generates 8 consecutive Django releases: + X.0, X.1, X.2 (LTS), X+1.0, X+1.1, X+1.2 (LTS), X+2.0, X+2.1 + - Mainstream support: 8 months per release + - Extended support: + - LTS releases (*.2) have 28 months total extended support + - Non-LTS releases have 8 months of extended support beyond mainstream + - Produces an SVG at: ../djangoproject/static/img/release-roadmap.svg +""" + +import argparse import datetime as dtime import json +import os +import calendar from jinja2 import Environment, FileSystemLoader +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +TEMPLATE_DIR = BASE_DIR +OUTPUT_FILE = os.path.join( + BASE_DIR, "..", "djangoproject", "static", "img", "release-roadmap.svg" +) + + def load_release_data(json_file): with open(json_file, encoding="utf-8") as f: data = json.load(f) From c9599a3f2d7e73d4016baac7419a596285677921 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 17:05:57 +0000 Subject: [PATCH 07/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tools/generate_release_roadmap.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index 5e7186473e..705c77ddf7 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -19,14 +19,13 @@ """ import argparse +import calendar import datetime as dtime import json import os -import calendar from jinja2 import Environment, FileSystemLoader - BASE_DIR = os.path.dirname(os.path.abspath(__file__)) TEMPLATE_DIR = BASE_DIR OUTPUT_FILE = os.path.join( From 6f753947dc7ffe7e8b9614f5d1a7fecd00453024 Mon Sep 17 00:00:00 2001 From: bhargav Date: Wed, 19 Nov 2025 01:11:22 +0530 Subject: [PATCH 08/18] added suggested changes --- Makefile | 1 - djangoproject/static/img/release-roadmap.png | Bin 39982 -> 0 bytes djangoproject/static/img/release-roadmap.svg | 1170 +++++++----------- tools/generate_release_roadmap.py | 132 +- tools/release-data.json | 58 - tools/template.svg.jinja | 188 +-- 6 files changed, 614 insertions(+), 935 deletions(-) delete mode 100644 djangoproject/static/img/release-roadmap.png delete mode 100644 tools/release-data.json diff --git a/Makefile b/Makefile index cef90a3b7e..910ce300e4 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,6 @@ SCSS = djangoproject/scss STATIC = djangoproject/static ci: compilemessages test - @python tools/generate_release_roadmap.py @python -m coverage report compilemessages: diff --git a/djangoproject/static/img/release-roadmap.png b/djangoproject/static/img/release-roadmap.png deleted file mode 100644 index a66c8f1f03ad13852b5fbbab5e11e516ab25f026..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39982 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 - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 2023 - 2024 - 2025 - 2026 - 2027 - 2028 - 2029 - - - April - August - December - - - - - - - - - - - - - - - - - - - - - - - - - - LTS - LTS - LTS - - - 4.2 - 5.0 - 5.1 - 5.2 - 6.0 - 6.1 - 6.2 - 7.0 - MainstreamSupport - - ExtendedSupport - - - + + + + + + + + + + + + + + + + + 2023 + + + + + + + + + + + + April + + + + + + + + + + August + + + + + + + + + + December + + + + + + + + 2024 + + + + + + + + + + + + + + + + + + + + + + + + + 2025 + + + + + + + + + + + + + + + + + + + + + + + + + 2026 + + + + + + + + + + + + + + + + + + + + + + + + + 2027 + + + + + + + + + + + + + + + + + + + + + + + + + 2028 + + + + + + + + + + + + + + + + + + + + + + + + + 2029 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 4.2 + + + + + LTS + + + + + + + + + + + + + 5.0 + + + + + + + + + + + + + + 5.1 + + + + + + + + + + + + + + 5.2 + + + + + LTS + + + + + + + + + + + + + 6.0 + + + + + + + + + + + + + + 6.1 + + + + + + + + + + + + + + 6.2 + + + + + LTS + + + + + + + + + + + + + 7.0 + + + + + + + + Mainstream Support + + + + + Extended Support + + + + + \ No newline at end of file diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index 705c77ddf7..197e9166c2 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -21,37 +21,18 @@ import argparse import calendar import datetime as dtime -import json import os - from jinja2 import Environment, FileSystemLoader + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + TEMPLATE_DIR = BASE_DIR + OUTPUT_FILE = os.path.join( BASE_DIR, "..", "djangoproject", "static", "img", "release-roadmap.svg" ) - -def load_release_data(json_file): - with open(json_file, encoding="utf-8") as f: - data = json.load(f) - - processed_data = [] - for item in data: - item["release_date"] = dtime.datetime.strptime( - item["release_date"], "%Y-%m-%d" - ).date() - item["mainstream_end"] = dtime.datetime.strptime( - item["mainstream_end"], "%Y-%m-%d" - ).date() - item["extended_end"] = dtime.datetime.strptime( - item["extended_end"], "%Y-%m-%d" - ).date() - processed_data.append(item) - return processed_data - - COLORS = { "mainstream": "#0C4B33", "extended": "#CBFDE9", @@ -83,7 +64,7 @@ def load_release_data(json_file): } -def get_chart_timeline(data, config): +def get_chart_timeline(data :list, config:dict) : start_year = data[0]["release_date"].year @@ -98,7 +79,7 @@ def get_chart_timeline(data, config): return start_year, end_year, int(svg_width) -def calculate_dimensions(config, num_releases): +def calculate_dimensions(config :dict, num_releases:int) -> int: chart_height = ( config["padding_top"] @@ -109,7 +90,7 @@ def calculate_dimensions(config, num_releases): return int(chart_height) -def date_to_x(date, start_year, config): +def date_to_x(date :dtime.date, start_year :int , config :dict ) -> float: pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 @@ -132,31 +113,91 @@ def date_to_x(date, start_year, config): return start_x + block_x_end -def generate_grids(start_year, end_year, config): +def generate_grids(start_year:int, end_year:int, config:dict) -> list: grid_lines = [] pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 - for i, year in enumerate(range(start_year, end_year + 1)): - year_x_start = config["padding_left"] + (i * pixels_per_year) - - for i in range(3): + # Month labels only for the VERY FIRST set of lines + FIRST_YEAR_MONTH_LABELS = { + 0: None, + 1: "April", + 2: "August", + 3: "December", + } + for year_index, year in enumerate(range(start_year, end_year)): + year_x_start = config["padding_left"] + (year_index * pixels_per_year) + + for line_index in range(4): + x = year_x_start + (line_index * pixels_per_block) + # Year label always on first line of each year + top_label = str(year) if line_index == 0 else None + # Month labels ONLY for the first year block + if year_index == 0: + bottom_label = FIRST_YEAR_MONTH_LABELS[line_index] + else: + bottom_label = None grid_lines.append( { - "x": year_x_start + (i * pixels_per_block), + "x": x, "width": ( config["year_line_width"] - if i == 0 + if line_index == 0 else config["month_line_width"] ), - "label": str(year) if i == 0 else None, + "top_label": top_label, + "bottom_label": bottom_label, } ) return grid_lines -def generate_releases(data, start_year, config): +def add_months(date: dtime.date, months: int) -> dtime.date: + year = date.year + (date.month - 1 + months) // 12 + month = (date.month - 1 + months) % 12 + 1 + day = min(date.day, calendar.monthrange(year, month)[1]) + return dtime.date(year, month, day) + +def generate_release_data(first_release: str, first_release_ym: str)->list : + """ + Generate 8 Django-style releases starting from a given first release. + first_release: "4.2" + first_release_ym: "2023-04" + """ + major, minor = map(int, first_release.split(".")) + # Parse YYYY-MM → date + release_date = dtime.datetime.strptime(first_release_ym, "%Y-%m").date() + releases = [] + for i in range(8): + curr_major = major + ((minor + i) // 3) + curr_minor = (minor + i) % 3 + version = f"{curr_major}.{curr_minor}" + is_lts = curr_minor == 2 + # Mainstream support lasts 8 months + mainstream_end = add_months(release_date, 8) + # Extended support + if is_lts: + # LTS = 28 months from release date + extended_end = add_months(release_date, 28) + else: + # Non-LTS = 8 months after mainstream ends + extended_end = add_months(mainstream_end, 8) + releases.append( + { + "name": version, + "is_lts": is_lts, + "release_date": release_date, + "mainstream_end": mainstream_end, + "extended_end": extended_end, + } + ) + # Next release starts 8 months later + release_date = add_months(release_date, 8) + return releases + + +def generate_releases(data:list , start_year:int, config:dict)-> list: releases_processed = [] for i, release in enumerate(data): @@ -210,9 +251,9 @@ def generate_releases(data, start_year, config): return releases_processed -def generate_legend(config, svg_height): +def generate_legend(config:dict )-> dict: - legend_y = svg_height - (config["padding_bottom"] / 2) + legend_y = config["padding_top"] + 200 # Fixed position for legend so that it doesn't conflict with month labels legend2_x = config["padding_left"] + config["legend_spacing"] legend = { @@ -239,12 +280,21 @@ def generate_legend(config, svg_height): "text": "Extended Support", }, } + return legend def render_svg(): - data = load_release_data("release-data.json") + parser = argparse.ArgumentParser(description="Generate Django release roadmap SVG.") + parser.add_argument( + "--first-release", required=True, help="First release number, e.g., 4.2" + ) + parser.add_argument( + "--date", required=True, help="Release date in YYYY-MM format, e.g., 2023-04" + ) + args = parser.parse_args() + data = generate_release_data(args.first_release, args.date) start_year, end_year, svg_width = get_chart_timeline(data, CONFIG) svg_height = calculate_dimensions(CONFIG, len(data)) @@ -252,9 +302,9 @@ def render_svg(): grid_lines = generate_grids(start_year, end_year, CONFIG) releases_processed = generate_releases(data, start_year, CONFIG) - legend = generate_legend(CONFIG, svg_height) + legend = generate_legend(CONFIG) - env = Environment(loader=FileSystemLoader(".")) + env = Environment(loader=FileSystemLoader(TEMPLATE_DIR)) template = env.get_template("template.svg.jinja") output_svg = template.render( @@ -267,9 +317,7 @@ def render_svg(): legend=legend, ) - outfile = "../djangoproject/static/img/release-roadmap.svg" - - with open(outfile, "w", encoding="utf-8") as f: + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: f.write(output_svg) diff --git a/tools/release-data.json b/tools/release-data.json deleted file mode 100644 index dc296f2733..0000000000 --- a/tools/release-data.json +++ /dev/null @@ -1,58 +0,0 @@ -[ - { - "name": "4.2", - "is_lts": true, - "release_date": "2023-04-03", - "mainstream_end": "2023-12-04", - "extended_end": "2026-04-01" - }, - { - "name": "5.0", - "is_lts": false, - "release_date": "2023-12-04", - "mainstream_end": "2024-08-01", - "extended_end": "2025-04-01" - }, - { - "name": "5.1", - "is_lts": false, - "release_date": "2024-08-01", - "mainstream_end": "2025-04-01", - "extended_end": "2025-12-01" - }, - { - "name": "5.2", - "is_lts": true, - "release_date": "2025-04-01", - "mainstream_end": "2025-12-01", - "extended_end": "2028-04-01" - }, - { - "name": "6.0", - "is_lts": false, - "release_date": "2025-12-01", - "mainstream_end": "2026-08-01", - "extended_end": "2027-04-01" - }, - { - "name": "6.1", - "is_lts": false, - "release_date": "2026-08-01", - "mainstream_end": "2027-04-01", - "extended_end": "2027-12-01" - }, - { - "name": "6.2", - "is_lts": true, - "release_date": "2027-04-01", - "mainstream_end": "2027-12-01", - "extended_end": "2030-04-01" - }, - { - "name": "7.0", - "is_lts": false, - "release_date": "2027-12-01", - "mainstream_end": "2028-08-01", - "extended_end": "2029-04-01" - } -] \ No newline at end of file diff --git a/tools/template.svg.jinja b/tools/template.svg.jinja index 0749d4b2f8..b946d32a36 100644 --- a/tools/template.svg.jinja +++ b/tools/template.svg.jinja @@ -1,94 +1,94 @@ - - - - - - - - - - - - {% for line in grid_lines %} - - {% if line.top_label %} - - - {{ line.top_label }} - - {% endif %} - - {% if line.bottom_label %} - - - {{ line.bottom_label }} - - {% endif %} - {% endfor %} - - {% for release in releases %} - {% if release.mainstream_bar.width > 0 %} - - {% endif %} - - {% if release.extended_bar.width > 0 %} - - {% endif %} - - - {{ release.version_text.text }} - - - {% if release.lts_text %} - - {{ release.lts_text.text }} - - {% endif %} - {% endfor %} - - - - {{ legend.mainstream_text.text }} - - - - - {{ legend.extended_text.text }} - - - - - + + + + + + + + + + + + {% for line in grid_lines %} + + {% if line.top_label %} + + + {{ line.top_label }} + + {% endif %} + + {% if line.bottom_label %} + + + {{ line.bottom_label }} + + {% endif %} + {% endfor %} + + {% for release in releases %} + {% if release.mainstream_bar.width > 0 %} + + {% endif %} + + {% if release.extended_bar.width > 0 %} + + {% endif %} + + + {{ release.version_text.text }} + + + {% if release.lts_text %} + + {{ release.lts_text.text }} + + {% endif %} + {% endfor %} + + + + {{ legend.mainstream_text.text }} + + + + + {{ legend.extended_text.text }} + + + + + From 8a06d6fd3e57af5874ab4bcba446ffe94e2847d5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 19:45:41 +0000 Subject: [PATCH 09/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tools/generate_release_roadmap.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index 197e9166c2..bec702e2b0 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -22,8 +22,8 @@ import calendar import datetime as dtime import os -from jinja2 import Environment, FileSystemLoader +from jinja2 import Environment, FileSystemLoader BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -64,7 +64,7 @@ } -def get_chart_timeline(data :list, config:dict) : +def get_chart_timeline(data: list, config: dict): start_year = data[0]["release_date"].year @@ -79,7 +79,7 @@ def get_chart_timeline(data :list, config:dict) : return start_year, end_year, int(svg_width) -def calculate_dimensions(config :dict, num_releases:int) -> int: +def calculate_dimensions(config: dict, num_releases: int) -> int: chart_height = ( config["padding_top"] @@ -90,7 +90,7 @@ def calculate_dimensions(config :dict, num_releases:int) -> int: return int(chart_height) -def date_to_x(date :dtime.date, start_year :int , config :dict ) -> float: +def date_to_x(date: dtime.date, start_year: int, config: dict) -> float: pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 @@ -113,13 +113,13 @@ def date_to_x(date :dtime.date, start_year :int , config :dict ) -> float: return start_x + block_x_end -def generate_grids(start_year:int, end_year:int, config:dict) -> list: +def generate_grids(start_year: int, end_year: int, config: dict) -> list: grid_lines = [] pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 - # Month labels only for the VERY FIRST set of lines + # Month labels only for the VERY FIRST set of lines FIRST_YEAR_MONTH_LABELS = { 0: None, 1: "April", @@ -159,7 +159,8 @@ def add_months(date: dtime.date, months: int) -> dtime.date: day = min(date.day, calendar.monthrange(year, month)[1]) return dtime.date(year, month, day) -def generate_release_data(first_release: str, first_release_ym: str)->list : + +def generate_release_data(first_release: str, first_release_ym: str) -> list: """ Generate 8 Django-style releases starting from a given first release. first_release: "4.2" @@ -197,7 +198,7 @@ def generate_release_data(first_release: str, first_release_ym: str)->list : return releases -def generate_releases(data:list , start_year:int, config:dict)-> list: +def generate_releases(data: list, start_year: int, config: dict) -> list: releases_processed = [] for i, release in enumerate(data): @@ -251,9 +252,11 @@ def generate_releases(data:list , start_year:int, config:dict)-> list: return releases_processed -def generate_legend(config:dict )-> dict: +def generate_legend(config: dict) -> dict: - legend_y = config["padding_top"] + 200 # Fixed position for legend so that it doesn't conflict with month labels + legend_y = ( + config["padding_top"] + 200 + ) # Fixed position for legend so that it doesn't conflict with month labels legend2_x = config["padding_left"] + config["legend_spacing"] legend = { From 7c5aa13e54a5104256bfed7925de9abba4108453 Mon Sep 17 00:00:00 2001 From: Bhargav <101399269+bhargav-j47@users.noreply.github.com> Date: Wed, 19 Nov 2025 02:14:41 +0530 Subject: [PATCH 10/18] Update generate_release_roadmap.py --- tools/generate_release_roadmap.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index bec702e2b0..11fdffaba0 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -1,12 +1,13 @@ """ -Generates an SVG roadmap of Django releases, showing mainstream and extended support periods. +Generates an SVG roadmap of Django releases, +showing mainstream and extended support periods. Usage: python generate_release_roadmap.py --first-release --date Arguments: - --first-release The first release number in Django versioning style, e.g., "4.2" - --date The release date of the first release in YYYY-MM format, e.g., "2023-04" + --first-release First release number in Django versioning style,e.g.,"4.2" + --date Release date of the first release in YYYY-MM format,e.g.,"2023-04" Behavior: - Automatically generates 8 consecutive Django releases: @@ -326,3 +327,4 @@ def render_svg(): if __name__ == "__main__": render_svg() + From 5e140e93653f20fde7b3b420662af9a009ee6084 Mon Sep 17 00:00:00 2001 From: bhargav Date: Wed, 19 Nov 2025 19:13:23 +0530 Subject: [PATCH 11/18] making svg more similar to png and adding fade --- djangoproject/static/img/release-roadmap.svg | 1009 ++++++++++------- .../templates/releases/download.html | 2 +- tools/generate_release_roadmap.py | 88 +- tools/template.svg.jinja | 109 +- 4 files changed, 704 insertions(+), 504 deletions(-) diff --git a/djangoproject/static/img/release-roadmap.svg b/djangoproject/static/img/release-roadmap.svg index 1ad327702e..750b9264c7 100644 --- a/djangoproject/static/img/release-roadmap.svg +++ b/djangoproject/static/img/release-roadmap.svg @@ -1,430 +1,581 @@ - - - - - - - - - - - - - - - - - 2023 - - - - - - - - - - - - April - - - - - - - - - - August - - - - - - - - - - December - - - - - - - - 2024 - - - - - - - - - - - - - - - - - - - - - - - - - 2025 - - - - - - - - - - - - - - - - - - - - - - - - - 2026 - - - - - - - - - - - - - - - - - - - - - - - - - 2027 - - - - - - - - - - - - - - - - - - - - - - - - - 2028 - - - - - - - - - - - - - - - - - - - - - - - - - 2029 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 4.2 - - - - - LTS - - - - - - - - - - - - - 5.0 - - - - - - - - - - - - - - 5.1 - - - - - - - - - - - - - - 5.2 - - - - - LTS - - - - - - - - - - - - - 6.0 - - - - - - - - - - - - - - 6.1 - - - - - - - - - - - - - - 6.2 - - - - - LTS - - - - - - - - - - - - - 7.0 - - - - - - - - Mainstream Support - - - - - Extended Support - - - - + + + + + + + + + + + + + + + + + + + 2023 + + + + + + + + + + + + Apr. + + + + + + + + + + Aug. + + + + + + + + + + Dec. + + + + + + + + 2024 + + + + + + + + + + + + + + + + + + + + + + + + + 2025 + + + + + + + + + + + + + + + + + + + + + + + + + 2026 + + + + + + + + + + + + + + + + + + + + + + + + + 2027 + + + + + + + + + + + + + + + + + + + + + + + + + 2028 + + + + + + + + + + + + + + + + + + + + + + + + + 2029 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 4.2 + + + + + LTS + + + + + + + + + + + + + 5.0 + + + + + + + + + + + + + + 5.1 + + + + + + + + + + + + + + 5.2 + + + + + LTS + + + + + + + + + + + + + 6.0 + + + + + + + + + + + + + + 6.1 + + + + + + + + + + + + + + 6.2 + + + + + LTS + + + + + + + + + + + + + 7.0 + + + + + + + + + + + Mainstream + + + + Support + + + + + + + + + + Extended + + + + Support + + + + + + \ No newline at end of file diff --git a/djangoproject/templates/releases/download.html b/djangoproject/templates/releases/download.html index b0e37e2155..40385978e4 100644 --- a/djangoproject/templates/releases/download.html +++ b/djangoproject/templates/releases/download.html @@ -6,7 +6,7 @@ {% block layout_class %}sidebar-right{% endblock %} {% block og_title %}Download Django{% endblock %} -{% block og_image %}{% static "img/release-roadmap.png" %}{% endblock %} +{% block og_image %}{% static "img/release-roadmap.svg" %}{% endblock %} {% block og_image_alt %}Django's release roadmap{% endblock %} {% block og_description %}The latest official version is {{ current.version }}{% if current.is_lts %} (LTS){% endif %}{% endblock %} {% block og_image_width %}1030{% endblock %} diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index 11fdffaba0..15c04b4523 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -1,13 +1,12 @@ """ -Generates an SVG roadmap of Django releases, -showing mainstream and extended support periods. +Generates an SVG roadmap of Django releases, showing mainstream and extended support periods. Usage: python generate_release_roadmap.py --first-release --date Arguments: - --first-release First release number in Django versioning style,e.g.,"4.2" - --date Release date of the first release in YYYY-MM format,e.g.,"2023-04" + --first-release The first release number in Django versioning style, e.g., "4.2" + --date The release date of the first release in YYYY-MM format, e.g., "2023-04" Behavior: - Automatically generates 8 consecutive Django releases: @@ -23,9 +22,9 @@ import calendar import datetime as dtime import os - from jinja2 import Environment, FileSystemLoader + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) TEMPLATE_DIR = BASE_DIR @@ -37,35 +36,35 @@ COLORS = { "mainstream": "#0C4B33", "extended": "#CBFDE9", - "grid": "#333333", - "text": "#FFFFFF", + "grid": "#000000", + "month-grid": "#666666", + "text": "#ffffff", + "legend_text": "#000000", "text_lts": "#0C4B33", - "bg": "#000000", + "bg": "none", } CONFIG = { - "pixels_per_year": 120, - "bar_height": 20, - "bar_v_spacing": 20, + "pixels_per_year": 180, + "bar_height": 28, + "bar_v_spacing": 10, "padding_top": 30, "padding_bottom": 20, "padding_left": 20, "padding_right": 10, "font_family": "'Segoe UI', 'Arial'", - "font_size": 12, - "font_size_small": 10, + "font_size": 18, "font_weight": "bold", "font_weight_lts": "600", "font_style_lts": "italic", - "legend_box_size": 14, - "legend_spacing": 150, + "legend_box_size": 16, + "legend_padding": 50, "text_padding_x": 10, "year_line_width": 3, "month_line_width": 1, } - -def get_chart_timeline(data: list, config: dict): +def get_chart_timeline(data :list, config:dict) : start_year = data[0]["release_date"].year @@ -79,8 +78,7 @@ def get_chart_timeline(data: list, config: dict): return start_year, end_year, int(svg_width) - -def calculate_dimensions(config: dict, num_releases: int) -> int: +def calculate_dimensions(config :dict, num_releases:int) -> int: chart_height = ( config["padding_top"] @@ -90,8 +88,7 @@ def calculate_dimensions(config: dict, num_releases: int) -> int: ) return int(chart_height) - -def date_to_x(date: dtime.date, start_year: int, config: dict) -> float: +def date_to_x(date :dtime.date, start_year :int , config :dict ) -> float: pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 @@ -113,19 +110,18 @@ def date_to_x(date: dtime.date, start_year: int, config: dict) -> float: return start_x + block_x_end - -def generate_grids(start_year: int, end_year: int, config: dict) -> list: +def generate_grids(start_year:int, end_year:int, config:dict) -> list: grid_lines = [] pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 - # Month labels only for the VERY FIRST set of lines + # Month labels only for the VERY FIRST set of lines FIRST_YEAR_MONTH_LABELS = { 0: None, - 1: "April", - 2: "August", - 3: "December", + 1: "Apr.", + 2: "Aug.", + 3: "Dec.", } for year_index, year in enumerate(range(start_year, end_year)): year_x_start = config["padding_left"] + (year_index * pixels_per_year) @@ -149,19 +145,18 @@ def generate_grids(start_year: int, end_year: int, config: dict) -> list: ), "top_label": top_label, "bottom_label": bottom_label, + "line-color": COLORS["grid"] if line_index == 0 else COLORS["month-grid"], } ) return grid_lines - def add_months(date: dtime.date, months: int) -> dtime.date: year = date.year + (date.month - 1 + months) // 12 month = (date.month - 1 + months) % 12 + 1 day = min(date.day, calendar.monthrange(year, month)[1]) return dtime.date(year, month, day) - -def generate_release_data(first_release: str, first_release_ym: str) -> list: +def generate_release_data(first_release: str, first_release_ym: str)->list : """ Generate 8 Django-style releases starting from a given first release. first_release: "4.2" @@ -198,8 +193,7 @@ def generate_release_data(first_release: str, first_release_ym: str) -> list: release_date = add_months(release_date, 8) return releases - -def generate_releases(data: list, start_year: int, config: dict) -> list: +def generate_releases(data:list , start_year:int, config:dict)-> list: releases_processed = [] for i, release in enumerate(data): @@ -252,42 +246,46 @@ def generate_releases(data: list, start_year: int, config: dict) -> list: ) return releases_processed +def generate_legend(config:dict )-> dict: -def generate_legend(config: dict) -> dict: + legend_y = config["padding_top"] + 200 # Fixed position for legend so that it doesn't conflict with month labels - legend_y = ( - config["padding_top"] + 200 - ) # Fixed position for legend so that it doesn't conflict with month labels - legend2_x = config["padding_left"] + config["legend_spacing"] + width=config["legend_box_size"] + 100 + height=config["legend_box_size"] + 24 legend = { "mainstream_box": { "x": config["padding_left"], "y": legend_y - config["legend_box_size"] + 2, "size": config["legend_box_size"], + "width": width, + "height": height, "fill": COLORS["mainstream"], }, "mainstream_text": { "x": config["padding_left"] + config["legend_box_size"] + 5, "y": legend_y, - "text": "Mainstream Support", + "fill" : "#ffffff", + "text": ["Mainstream" , "Support"], }, "extended_box": { - "x": legend2_x, + "x": config["padding_left"] + width, "y": legend_y - config["legend_box_size"] + 2, "size": config["legend_box_size"], + "width": width, + "height": height, "fill": COLORS["extended"], }, "extended_text": { - "x": legend2_x + config["legend_box_size"] + 5, + "x": config["padding_left"] + config["legend_box_size"] + width + 8, "y": legend_y, - "text": "Extended Support", + "fill" : "#000000", + "text": ["Extended" ,"Support"], }, } return legend - def render_svg(): parser = argparse.ArgumentParser(description="Generate Django release roadmap SVG.") @@ -320,11 +318,9 @@ def render_svg(): releases=releases_processed, legend=legend, ) - + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: f.write(output_svg) - if __name__ == "__main__": - render_svg() - + render_svg() \ No newline at end of file diff --git a/tools/template.svg.jinja b/tools/template.svg.jinja index b946d32a36..f0d24489e8 100644 --- a/tools/template.svg.jinja +++ b/tools/template.svg.jinja @@ -13,21 +13,22 @@ font-style: {{ config.font_style_lts }}; fill: {{ colors.text_lts }}; } - .year-label { - font-family: {{ config.font_family }}; - font-size: {{ config.font_size_small }}px; - fill: {{ colors.text }}; - } .legend-text { font-family: {{ config.font_family }}; font-size: {{ config.font_size }}px; - fill: {{ colors.text }}; + fill: {{ colors.legend_text }}; + } + .release-text{ + font-family: {{ config.font_family }}; + font-size: {{ config.font_size -2 }}px; } + - - + + + @@ -38,14 +39,14 @@ stroke="{{ colors.grid }}" stroke-width="{{ line.width }}" /> {% if line.top_label %} - + {{ line.top_label }} {% endif %} {% if line.bottom_label %} - + {{ line.bottom_label }} {% endif %} @@ -53,15 +54,27 @@ {% for release in releases %} {% if release.mainstream_bar.width > 0 %} - + {% endif %} {% if release.extended_bar.width > 0 %} - + {% endif %} @@ -75,20 +88,60 @@ {% endif %} {% endfor %} - - - {{ legend.mainstream_text.text }} - + + + + {% for line in legend.mainstream_text.text %} + + {{ line }} + + {% endfor %} - - - {{ legend.extended_text.text }} + + + {% for line in legend.extended_text.text %} + + {{ line }} + + {% endfor %} + - + + From 9feb2fc7f4a658283798a27e197b802ef30fb1ad Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 13:44:01 +0000 Subject: [PATCH 12/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tools/generate_release_roadmap.py | 52 ++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index 15c04b4523..2e0b007b40 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -22,8 +22,8 @@ import calendar import datetime as dtime import os -from jinja2 import Environment, FileSystemLoader +from jinja2 import Environment, FileSystemLoader BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -64,7 +64,8 @@ "month_line_width": 1, } -def get_chart_timeline(data :list, config:dict) : + +def get_chart_timeline(data: list, config: dict): start_year = data[0]["release_date"].year @@ -78,7 +79,8 @@ def get_chart_timeline(data :list, config:dict) : return start_year, end_year, int(svg_width) -def calculate_dimensions(config :dict, num_releases:int) -> int: + +def calculate_dimensions(config: dict, num_releases: int) -> int: chart_height = ( config["padding_top"] @@ -88,7 +90,8 @@ def calculate_dimensions(config :dict, num_releases:int) -> int: ) return int(chart_height) -def date_to_x(date :dtime.date, start_year :int , config :dict ) -> float: + +def date_to_x(date: dtime.date, start_year: int, config: dict) -> float: pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 @@ -110,13 +113,14 @@ def date_to_x(date :dtime.date, start_year :int , config :dict ) -> float: return start_x + block_x_end -def generate_grids(start_year:int, end_year:int, config:dict) -> list: + +def generate_grids(start_year: int, end_year: int, config: dict) -> list: grid_lines = [] pixels_per_year = config["pixels_per_year"] pixels_per_block = pixels_per_year / 3.0 - # Month labels only for the VERY FIRST set of lines + # Month labels only for the VERY FIRST set of lines FIRST_YEAR_MONTH_LABELS = { 0: None, 1: "Apr.", @@ -145,18 +149,22 @@ def generate_grids(start_year:int, end_year:int, config:dict) -> list: ), "top_label": top_label, "bottom_label": bottom_label, - "line-color": COLORS["grid"] if line_index == 0 else COLORS["month-grid"], + "line-color": ( + COLORS["grid"] if line_index == 0 else COLORS["month-grid"] + ), } ) return grid_lines + def add_months(date: dtime.date, months: int) -> dtime.date: year = date.year + (date.month - 1 + months) // 12 month = (date.month - 1 + months) % 12 + 1 day = min(date.day, calendar.monthrange(year, month)[1]) return dtime.date(year, month, day) -def generate_release_data(first_release: str, first_release_ym: str)->list : + +def generate_release_data(first_release: str, first_release_ym: str) -> list: """ Generate 8 Django-style releases starting from a given first release. first_release: "4.2" @@ -193,7 +201,8 @@ def generate_release_data(first_release: str, first_release_ym: str)->list : release_date = add_months(release_date, 8) return releases -def generate_releases(data:list , start_year:int, config:dict)-> list: + +def generate_releases(data: list, start_year: int, config: dict) -> list: releases_processed = [] for i, release in enumerate(data): @@ -246,12 +255,15 @@ def generate_releases(data:list , start_year:int, config:dict)-> list: ) return releases_processed -def generate_legend(config:dict )-> dict: - legend_y = config["padding_top"] + 200 # Fixed position for legend so that it doesn't conflict with month labels +def generate_legend(config: dict) -> dict: + + legend_y = ( + config["padding_top"] + 200 + ) # Fixed position for legend so that it doesn't conflict with month labels - width=config["legend_box_size"] + 100 - height=config["legend_box_size"] + 24 + width = config["legend_box_size"] + 100 + height = config["legend_box_size"] + 24 legend = { "mainstream_box": { @@ -265,8 +277,8 @@ def generate_legend(config:dict )-> dict: "mainstream_text": { "x": config["padding_left"] + config["legend_box_size"] + 5, "y": legend_y, - "fill" : "#ffffff", - "text": ["Mainstream" , "Support"], + "fill": "#ffffff", + "text": ["Mainstream", "Support"], }, "extended_box": { "x": config["padding_left"] + width, @@ -279,13 +291,14 @@ def generate_legend(config:dict )-> dict: "extended_text": { "x": config["padding_left"] + config["legend_box_size"] + width + 8, "y": legend_y, - "fill" : "#000000", - "text": ["Extended" ,"Support"], + "fill": "#000000", + "text": ["Extended", "Support"], }, } return legend + def render_svg(): parser = argparse.ArgumentParser(description="Generate Django release roadmap SVG.") @@ -318,9 +331,10 @@ def render_svg(): releases=releases_processed, legend=legend, ) - + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: f.write(output_svg) + if __name__ == "__main__": - render_svg() \ No newline at end of file + render_svg() From 90d5ba35ff6e59829787b40e13f302aa0c697ae4 Mon Sep 17 00:00:00 2001 From: bhargav Date: Wed, 19 Nov 2025 19:42:59 +0530 Subject: [PATCH 13/18] update generate_release_roadmap.py --- tools/generate_release_roadmap.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/generate_release_roadmap.py b/tools/generate_release_roadmap.py index 2e0b007b40..d6c3ba8faf 100644 --- a/tools/generate_release_roadmap.py +++ b/tools/generate_release_roadmap.py @@ -1,12 +1,13 @@ """ -Generates an SVG roadmap of Django releases, showing mainstream and extended support periods. +Generates an SVG roadmap of Django releases, +showing mainstream and extended support periods. Usage: python generate_release_roadmap.py --first-release --date Arguments: - --first-release The first release number in Django versioning style, e.g., "4.2" - --date The release date of the first release in YYYY-MM format, e.g., "2023-04" + --first-release First release number in Django versioning style, e.g.,"4.2" + --date Release date of first release in YYYY-MM format, e.g.,"2023-04" Behavior: - Automatically generates 8 consecutive Django releases: From c791ec1fa653e974b006d9a21c4b4d0885405813 Mon Sep 17 00:00:00 2001 From: bhargav Date: Wed, 19 Nov 2025 20:50:13 +0530 Subject: [PATCH 14/18] improving fade --- djangoproject/static/img/release-roadmap.svg | 7 +++---- tools/template.svg.jinja | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/djangoproject/static/img/release-roadmap.svg b/djangoproject/static/img/release-roadmap.svg index 750b9264c7..2c3ddd0702 100644 --- a/djangoproject/static/img/release-roadmap.svg +++ b/djangoproject/static/img/release-roadmap.svg @@ -1,4 +1,4 @@ - +