Skip to content

Commit 9df7275

Browse files
Add a python script to parse a release note file and convert to Email, Markdown and HTML formats required for pgAdmin release
1 parent 829876c commit 9df7275

1 file changed

Lines changed: 368 additions & 0 deletions

File tree

tools/release_converter.py

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
##########################################################################
5+
#
6+
# pgAdmin 4 - PostgreSQL Tools
7+
#
8+
# Copyright (C) 2013 - 2025, The pgAdmin Development Team
9+
# This software is released under the PostgreSQL Licence
10+
#
11+
##########################################################################
12+
"""
13+
Parses a pgAdmin release note file in ReStructuredText (RST) format
14+
and converts it into Email (HTML), Markdown, and HTML (web) formats.
15+
Issue links are always omitted from lists in all outputs.
16+
Allows skipping specific issue numbers via command line.
17+
The Email HTML output can optionally be saved to a file.
18+
19+
Usage:
20+
python release_converter.py <input_rst_file_path> \\
21+
[--output-email-html <output_path.html>] \\
22+
[--skip-issues ISSUE_NUM [ISSUE_NUM ...]]
23+
24+
Examples:
25+
# Default: No issue links, don't skip issues, print all to console
26+
python release_converter.py path/to/notes.rst
27+
28+
# Skip issues 8602 and 8603
29+
python release_converter.py path/to/notes.rst --skip-issues 8602 8603
30+
31+
# Save email output, default skip behavior
32+
python release_converter.py path/to/notes.rst --output-email-html email.html
33+
"""
34+
35+
import re
36+
import argparse
37+
import sys
38+
import html
39+
40+
41+
# --- PARSING FUNCTION (parse_rst_release_note) ---
42+
def parse_rst_release_note(rst_text):
43+
"""Parses the RST release note text to extract key information."""
44+
data = {
45+
'version': None,
46+
'release_date': None,
47+
'features': [],
48+
'bug_fixes': [],
49+
'housekeeping': []
50+
}
51+
52+
version_match = re.search(r'Version\s+([^\n\*]+)', rst_text)
53+
if version_match:
54+
data['version'] = version_match.group(1).strip()
55+
else:
56+
print("Warning: Could not parse version.", file=sys.stderr)
57+
58+
date_match = re.search(r'Release date:\s*(\d{4}-\d{2}-\d{2})', rst_text)
59+
if date_match:
60+
data['release_date'] = date_match.group(1).strip()
61+
else:
62+
print("Warning: Could not parse release date.", file=sys.stderr)
63+
64+
current_section_list = None
65+
lines = rst_text.splitlines()
66+
67+
for i, line_raw in enumerate(lines):
68+
line_stripped = line_raw.strip()
69+
if i > 0:
70+
prev_line_stripped = lines[i-1].strip()
71+
if len(prev_line_stripped) > 3 and all(c == '*' for c in prev_line_stripped):
72+
if i > 1:
73+
header_text_line = lines[i-2].strip().lower()
74+
if "new features" in header_text_line:
75+
current_section_list = data['features']
76+
elif "bug fixes" in header_text_line:
77+
current_section_list = data['bug_fixes']
78+
elif "housekeeping" in header_text_line:
79+
current_section_list = data['housekeeping']
80+
else:
81+
pass
82+
continue
83+
84+
if current_section_list is not None and line_stripped.startswith('|'):
85+
line_to_parse = line_stripped
86+
item_match = re.match(r'\|\s*`Issue\s+#(\d+)\s+<([^>]+)>`_\s*-\s*(.*)', line_to_parse)
87+
if item_match:
88+
issue_num, url, description = item_match.groups()
89+
item_data = {
90+
'issue': issue_num.strip(),
91+
'url': url.strip(),
92+
'description': description.strip().rstrip('.')
93+
}
94+
current_section_list.append(item_data)
95+
else:
96+
simple_match = re.match(r'\|\s*(.*)', line_to_parse)
97+
simple_text = simple_match.group(1).strip().rstrip('.') if simple_match else None
98+
if simple_text:
99+
item_data = {
100+
'issue': None,
101+
'url': None,
102+
'description': simple_text
103+
}
104+
current_section_list.append(item_data)
105+
106+
data['bugs_housekeeping'] = data['bug_fixes'] + data['housekeeping']
107+
108+
if not data['features']:
109+
print("Warning: No 'New features' items parsed.", file=sys.stderr)
110+
if not data['bugs_housekeeping']:
111+
print("Warning: No 'Bug fixes' or 'Housekeeping' items parsed.", file=sys.stderr)
112+
113+
return data
114+
115+
116+
# --- Helper for Plurals ---
117+
def pluralize(count, singular, plural=None):
118+
"""Adds 's' for pluralization if count is not 1."""
119+
if count == 1:
120+
return f"{count} {singular}"
121+
else:
122+
plural_form = plural if plural else singular + 's'
123+
return f"{count} {plural_form}"
124+
125+
126+
# --- Formatting Functions ---
127+
def format_email_html(data, skip_issues_set=None):
128+
"""Formats the extracted data into HTML suitable for email (Google Doc style)."""
129+
if skip_issues_set is None:
130+
skip_issues_set = set()
131+
if not data.get('version'):
132+
return "<p>Error: Version not found in parsed data.</p>"
133+
134+
version = data['version']
135+
release_url = f"https://www.pgadmin.org/docs/pgadmin4/{version}/release_notes_{version.replace('.', '_')}.html"
136+
download_url = "https://www.pgadmin.org/download/"
137+
website_url = "https://www.pgadmin.org/"
138+
139+
filtered_features = [item for item in data.get('features', []) if item.get('issue') not in skip_issues_set]
140+
filtered_bugs = [item for item in data.get('bugs_housekeeping', []) if item.get('issue') not in skip_issues_set]
141+
num_features = len(filtered_features)
142+
num_bugs_housekeeping = len(filtered_bugs)
143+
144+
output = f"<h2><strong>pgAdmin 4 v{version} Released</strong></h2>\n"
145+
output += f"<p>The pgAdmin Development Team is pleased to announce pgAdmin 4 version {version}.</p>\n"
146+
output += (f"<p>This release of pgAdmin 4 includes {pluralize(num_features, 'new feature')} "
147+
f"and {pluralize(num_bugs_housekeeping, 'bug fix', 'bug fixes')}/housekeeping change{'s' if num_bugs_housekeeping != 1 else ''}. ")
148+
output += f'For more details please see the <a href="{release_url}">Release Notes</a>.</p>\n'
149+
output += '<p>pgAdmin is the leading Open Source graphical management tool for PostgreSQL. For more information, please see</p>\n'
150+
output += f'<p>&#9;<a href="{website_url}">{website_url}</a></p>\n'
151+
output += "<p>Notable changes in this release include:</p>\n"
152+
153+
if filtered_features:
154+
output += "<p><strong>Features</strong></p>\n<ul>\n"
155+
for item in filtered_features:
156+
desc = html.escape(item.get('description', 'N/A').strip())
157+
output += f" <li>{desc}.</li>\n"
158+
output += "</ul>\n"
159+
160+
if filtered_bugs:
161+
output += "<p><strong>Bugs/Housekeeping</strong></p>\n<ul>\n"
162+
for item in filtered_bugs:
163+
desc = html.escape(item.get('description', 'N/A').strip())
164+
output += f" <li>{desc}.</li>\n"
165+
output += "</ul>\n"
166+
167+
output += "<p>Builds for Windows and macOS are available now, along with a Python Wheel,<br>"
168+
output += "Docker Container, RPM, DEB Package, and source code tarball from:<br>"
169+
output += f'<a href="{download_url}">{download_url}</a></p>\n'
170+
output += "<p>--<br>Release Manager<br>pgAdmin Project</p>\n"
171+
172+
return output
173+
174+
175+
def format_markdown(data, skip_issues_set=None):
176+
"""Formats the extracted data into Markdown (no issue links in lists)."""
177+
if skip_issues_set is None:
178+
skip_issues_set = set()
179+
if not data.get('version'):
180+
return "Error: Version not found in parsed data."
181+
182+
version = data['version']
183+
release_url = f"https://www.pgadmin.org/docs/pgadmin4/{version}/release_notes_{version.replace('.', '_')}.html"
184+
download_url = "https://www.pgadmin.org/download/"
185+
website_url = "https://www.pgadmin.org/"
186+
187+
filtered_features = [item for item in data.get('features', []) if item.get('issue') not in skip_issues_set]
188+
filtered_bugs = [item for item in data.get('bugs_housekeeping', []) if item.get('issue') not in skip_issues_set]
189+
num_features = len(filtered_features)
190+
num_bugs_housekeeping = len(filtered_bugs)
191+
192+
output = f"The pgAdmin Development Team is pleased to announce pgAdmin 4 version {version}. "
193+
output += (f"This release of pgAdmin 4 includes {pluralize(num_features, 'new feature')} "
194+
f"and {pluralize(num_bugs_housekeeping, 'bug fix', 'bug fixes')}/housekeeping change{'s' if num_bugs_housekeeping != 1 else ''}. ")
195+
output += f"For more details, please see the [release notes]({release_url}).\n \n"
196+
output += f"pgAdmin is the leading Open Source graphical management tool for PostgreSQL. For more information, please see [the website]({website_url}).\n\n"
197+
output += "Notable changes in this release include:\n \n"
198+
199+
if filtered_features:
200+
output += "### Features:\n"
201+
for item in filtered_features:
202+
desc = item.get('description', 'N/A').strip()
203+
# Always omit the link
204+
output += f"* {desc}.\n"
205+
output += "\n"
206+
207+
if filtered_bugs:
208+
output += "### Bugs/Housekeeping:\n"
209+
for item in filtered_bugs:
210+
desc = item.get('description', 'N/A').strip()
211+
# Always omit the link
212+
output += f"* {desc}.\n"
213+
output += "\n"
214+
215+
output += f"Builds for Windows and macOS are available now, along with a Python Wheel, Docker Container, RPM, DEB Package, and source code tarball from the [download area]({download_url})."
216+
217+
return output
218+
219+
220+
def format_html(data, skip_issues_set=None):
221+
"""Formats the extracted data into HTML for web news articles (no issue links in lists)."""
222+
if skip_issues_set is None:
223+
skip_issues_set = set()
224+
import html
225+
226+
if not data.get('version'):
227+
return "<p>Error: Version not found in parsed data.</p>"
228+
229+
version = data['version']
230+
release_url = f"/docs/pgadmin4/{version}/release_notes_{version.replace('.', '_')}.html"
231+
download_url = "/download"
232+
233+
filtered_features = [item for item in data.get('features', []) if item.get('issue') not in skip_issues_set]
234+
filtered_bugs = [item for item in data.get('bugs_housekeeping', []) if item.get('issue') not in skip_issues_set]
235+
num_features = len(filtered_features)
236+
num_bugs_housekeeping = len(filtered_bugs)
237+
238+
output = f"<p>The pgAdmin Development Team is pleased to announce pgAdmin 4 version {version}. "
239+
output += (f"This release of pgAdmin 4 includes {pluralize(num_features, 'new feature')} "
240+
f"and {pluralize(num_bugs_housekeeping, 'bug fix', 'bug fixes')}/housekeeping change{'s' if num_bugs_housekeeping != 1 else ''}. ")
241+
output += f'For more details, please see the <a href="{release_url}">release notes</a>.</p>\n'
242+
output += "<p>Notable changes in this release include:</p>\n"
243+
244+
if filtered_features:
245+
output += "<p><strong>Features:</strong></p>\n<ul>\n"
246+
for item in filtered_features:
247+
desc = html.escape(item.get('description', 'N/A').strip())
248+
# Always omit the link, keep bolding
249+
output += f" <li><strong>{desc}.</strong></li>\n"
250+
output += "</ul>\n"
251+
252+
if filtered_bugs:
253+
output += "<p><strong>Bugs/Housekeeping:</strong></p>\n<ul>\n"
254+
for item in filtered_bugs:
255+
desc = html.escape(item.get('description', 'N/A').strip())
256+
# Always omit the link
257+
output += f" <li>{desc}.</li>\n"
258+
output += "</ul>\n"
259+
260+
output += f'<p><a href="{download_url}">Download</a> your copy now!</p>'
261+
262+
return output
263+
264+
265+
# --- Main Execution ---
266+
if __name__ == "__main__":
267+
# --- Setup Argument Parser ---
268+
parser = argparse.ArgumentParser(
269+
# ***MODIFIED: Updated description***
270+
description="Converts pgAdmin RST release notes to Email (HTML), Markdown, and HTML (web) formats.\n"
271+
"Issue links are omitted from lists. Allows skipping specific issues.",
272+
formatter_class=argparse.RawDescriptionHelpFormatter,
273+
# ***MODIFIED: Updated examples***
274+
epilog="Examples:\n"
275+
" # Default: Don't skip issues\n"
276+
" python release_converter.py path/to/notes.rst\n\n"
277+
" # Skip issues 8602 and 8603\n"
278+
" python release_converter.py path/to/notes.rst --skip-issues 8602 8603\n\n"
279+
" # Save email output, default skip behavior\n"
280+
" python release_converter.py path/to/notes.rst --output-email-html email.html"
281+
)
282+
parser.add_argument(
283+
"input_file",
284+
metavar="<input_rst_file_path>",
285+
type=str,
286+
help="Path to the input ReStructuredText (.rst) release note file."
287+
)
288+
parser.add_argument(
289+
"--output-email-html",
290+
metavar="<email_output_path.html>",
291+
type=str,
292+
default=None,
293+
help="Optional path to save the Email HTML output to a file."
294+
)
295+
parser.add_argument(
296+
"--skip-issues",
297+
metavar="ISSUE_NUM",
298+
type=str,
299+
nargs='+',
300+
default=[],
301+
help="List of issue numbers (e.g., 8602 8603) to skip from output lists."
302+
)
303+
args = parser.parse_args()
304+
305+
# --- Read Input File ---
306+
input_rst_content = ""
307+
try:
308+
with open(args.input_file, "r", encoding="utf-8") as f:
309+
input_rst_content = f.read()
310+
print(f"Successfully read file: {args.input_file}", file=sys.stderr)
311+
except FileNotFoundError:
312+
print(f"Error: Input file not found at '{args.input_file}'", file=sys.stderr)
313+
sys.exit(1)
314+
except Exception as e:
315+
print(f"Error reading file '{args.input_file}': {e}", file=sys.stderr)
316+
sys.exit(1)
317+
318+
# --- Parse the input data ---
319+
print("Parsing release notes...", file=sys.stderr)
320+
parsed_data = parse_rst_release_note(input_rst_content)
321+
322+
if not parsed_data.get('version'):
323+
print("\nError: Parsing failed to find version. Cannot proceed.", file=sys.stderr)
324+
sys.exit(1)
325+
326+
# --- Create skip set ---
327+
skip_issues_set = set(args.skip_issues)
328+
if skip_issues_set:
329+
print(f"Attempting to skip issues: {', '.join(sorted(list(skip_issues_set)))}", file=sys.stderr)
330+
331+
# --- Generate the different formats ---
332+
print("Generating output formats...", file=sys.stderr)
333+
# ***MODIFIED: Removed include_links from calls***
334+
email_html_output = format_email_html(parsed_data, skip_issues_set=skip_issues_set)
335+
markdown_output = format_markdown(parsed_data, skip_issues_set=skip_issues_set)
336+
news_html_output = format_html(parsed_data, skip_issues_set=skip_issues_set)
337+
print("Format generation complete.", file=sys.stderr)
338+
339+
# --- Handle Outputs ---
340+
if args.output_email_html:
341+
try:
342+
output_filename = args.output_email_html
343+
if not output_filename.lower().endswith(('.html', '.htm')):
344+
print(f"Warning: Output file '{output_filename}' does not end with .html or .htm. The content is HTML.", file=sys.stderr)
345+
346+
with open(output_filename, "w", encoding="utf-8") as f:
347+
f.write(email_html_output)
348+
print(f"Email HTML output successfully saved to: {output_filename}", file=sys.stderr)
349+
except Exception as e:
350+
print(f"Error writing Email HTML output to file '{args.output_email_html}': {e}", file=sys.stderr)
351+
print("\n--- Email HTML Output ---")
352+
print(email_html_output)
353+
print("\n---------------------------------\n")
354+
else:
355+
print("\n--- Email HTML Output ---")
356+
print(email_html_output)
357+
print("\n---------------------------------\n")
358+
359+
# --- Output Other Formats (still to console) ---
360+
print("--- Markdown Output ---")
361+
print(markdown_output)
362+
print("\n---------------------------------\n")
363+
364+
print("--- News Article HTML Output ---")
365+
print(news_html_output)
366+
print("\n---------------------------------\n")
367+
368+
print("Script finished.", file=sys.stderr)

0 commit comments

Comments
 (0)