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>	<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 ("\n Error: 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