496 lines
18 KiB
Python
496 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
##########################################################################
|
|
#
|
|
# pgAdmin 4 - PostgreSQL Tools
|
|
#
|
|
# Copyright (C) 2013 - 2025, The pgAdmin Development Team
|
|
# This software is released under the PostgreSQL Licence
|
|
#
|
|
##########################################################################
|
|
"""
|
|
Parses a pgAdmin release note file in ReStructuredText (RST) format
|
|
and converts it into Email (HTML), Markdown, and HTML (web) formats.
|
|
Issue links are always omitted from lists in all outputs.
|
|
Allows skipping specific issue numbers via command line.
|
|
The Email HTML output can optionally be saved to a file.
|
|
|
|
Usage:
|
|
python release_converter.py <input_rst_file_path> \\
|
|
[--output-email-html <output_path.html>] \\
|
|
[--skip-issues ISSUE_NUM [ISSUE_NUM ...]]
|
|
|
|
Examples:
|
|
# Default: No issue links, don't skip issues, print all to console
|
|
python release_converter.py path/to/notes.rst
|
|
|
|
# Skip issues 8602 and 8603
|
|
python release_converter.py path/to/notes.rst --skip-issues 8602 8603
|
|
|
|
# Save email output, default skip behavior
|
|
python release_converter.py path/to/notes.rst --output-email-html email.html
|
|
"""
|
|
|
|
import argparse
|
|
import html
|
|
import re
|
|
import sys
|
|
|
|
|
|
# --- PARSING FUNCTION (parse_rst_release_note) ---
|
|
|
|
|
|
def parse_rst_release_note(rst_text):
|
|
"""Parses the RST release note text to extract key information."""
|
|
data = {
|
|
'version': None,
|
|
'release_date': None,
|
|
'features': [],
|
|
'bug_fixes': [],
|
|
'housekeeping': []
|
|
}
|
|
|
|
version_match = re.search(r'Version\s+([^\n\*]+)', rst_text)
|
|
if version_match:
|
|
data['version'] = version_match.group(1).strip()
|
|
else:
|
|
print("Warning: Could not parse version.", file=sys.stderr)
|
|
|
|
date_match = re.search(r'Release date:\s*(\d{4}-\d{2}-\d{2})', rst_text)
|
|
if date_match:
|
|
data['release_date'] = date_match.group(1).strip()
|
|
else:
|
|
print("Warning: Could not parse release date.", file=sys.stderr)
|
|
|
|
current_section_list = None
|
|
lines = rst_text.splitlines()
|
|
|
|
for i, line_raw in enumerate(lines):
|
|
line_stripped = line_raw.strip()
|
|
if i > 0:
|
|
prev_line_stripped = lines[i - 1].strip()
|
|
# Check if the previous line looks like an RST section underline
|
|
if len(prev_line_stripped) > 3 and \
|
|
all(c == '*' for c in prev_line_stripped):
|
|
if i > 1:
|
|
# Header text is two lines above the current line
|
|
header_text_line = lines[i - 2].strip().lower()
|
|
if "new features" in header_text_line:
|
|
current_section_list = data['features']
|
|
elif "bug fixes" in header_text_line:
|
|
current_section_list = data['bug_fixes']
|
|
elif "housekeeping" in header_text_line:
|
|
current_section_list = data['housekeeping']
|
|
else:
|
|
# Not a section we track, keep previous section active
|
|
pass
|
|
# Skip the (usually blank) line after the underline
|
|
continue
|
|
|
|
# Parse items only if we are within a known section
|
|
if current_section_list is not None and line_stripped.startswith('|'):
|
|
line_to_parse = line_stripped
|
|
# Try matching the standard 'Issue #' format
|
|
item_match = re.match(
|
|
r'\|\s*`Issue\s+#(\d+)\s+<([^>]+)>`_\s*-\s*(.*)',
|
|
line_to_parse
|
|
)
|
|
if item_match:
|
|
issue_num, url, description = item_match.groups()
|
|
item_data = {
|
|
'issue': issue_num.strip(),
|
|
'url': url.strip(),
|
|
'description': description.strip().rstrip('.')
|
|
}
|
|
current_section_list.append(item_data)
|
|
else:
|
|
# Fallback for items starting with '|' but without Issue link
|
|
simple_match = re.match(r'\|\s*(.*)', line_to_parse)
|
|
simple_text = (simple_match.group(1).strip().rstrip('.')
|
|
if simple_match else None)
|
|
if simple_text:
|
|
item_data = {
|
|
'issue': None,
|
|
'url': None,
|
|
'description': simple_text
|
|
}
|
|
current_section_list.append(item_data)
|
|
|
|
# Combine bug fixes and housekeeping for unified output sections
|
|
data['bugs_housekeeping'] = data['bug_fixes'] + data['housekeeping']
|
|
|
|
# Add warnings if sections seem empty after parsing
|
|
if not data['features']:
|
|
print("Warning: No 'New features' items parsed.", file=sys.stderr)
|
|
if not data['bugs_housekeeping']:
|
|
print("Warning: No 'Bug fixes' or 'Housekeeping' items parsed.",
|
|
file=sys.stderr)
|
|
|
|
return data
|
|
|
|
|
|
# --- Helper for Plurals ---
|
|
|
|
|
|
def pluralize(count, singular, plural=None):
|
|
"""Adds 's' for pluralization if count is not 1."""
|
|
if count == 1:
|
|
return f"{count} {singular}"
|
|
else:
|
|
plural_form = plural if plural else singular + 's'
|
|
return f"{count} {plural_form}"
|
|
|
|
|
|
# --- Formatting Functions ---
|
|
|
|
|
|
def format_email_html(data, skip_issues_set=None):
|
|
"""Formats the extracted data into HTML suitable for email."""
|
|
if skip_issues_set is None:
|
|
skip_issues_set = set()
|
|
if not data.get('version'):
|
|
return "<p>Error: Version not found in parsed data.</p>"
|
|
|
|
version = data['version']
|
|
release_url = (
|
|
f"https://www.pgadmin.org/docs/pgadmin4/{version}/"
|
|
f"release_notes_{version.replace('.', '_')}.html"
|
|
)
|
|
download_url = "https://www.pgadmin.org/download/"
|
|
website_url = "https://www.pgadmin.org/"
|
|
|
|
# Filter lists first based on skip_issues_set
|
|
filtered_features = [
|
|
item for item in data.get('features', [])
|
|
if item.get('issue') not in skip_issues_set
|
|
]
|
|
filtered_bugs = [
|
|
item for item in data.get('bugs_housekeeping', [])
|
|
if item.get('issue') not in skip_issues_set
|
|
]
|
|
num_features = len(filtered_features)
|
|
num_bugs_housekeeping = len(filtered_bugs)
|
|
|
|
# Build HTML output
|
|
output = f"<h2><strong>pgAdmin 4 v{version} Released</strong></h2>\n"
|
|
output += (
|
|
f"<p>The pgAdmin Development Team is pleased to announce "
|
|
f"pgAdmin 4 version {version}.</p>\n"
|
|
)
|
|
output += (
|
|
f"<p>This release of pgAdmin 4 includes "
|
|
f"{pluralize(num_features, 'new feature')} and "
|
|
f"{pluralize(num_bugs_housekeeping, 'bug fix', 'bug fixes')}/"
|
|
f"housekeeping change"
|
|
f"{'s' if num_bugs_housekeeping != 1 else ''}. "
|
|
)
|
|
output += (
|
|
f'For more details please see the '
|
|
f'<a href="{release_url}">Release Notes</a>.</p>\n'
|
|
)
|
|
output += (
|
|
'<p>pgAdmin is the leading Open Source graphical management '
|
|
'tool for PostgreSQL. For more information, please see</p>\n'
|
|
)
|
|
# Note: Removed '	' (HTML tab) as it's non-standard; use CSS if needed
|
|
output += f'<p><a href="{website_url}">{website_url}</a></p>\n'
|
|
output += "<p>Notable changes in this release include:</p>\n"
|
|
|
|
# Add features section only if items remain after filtering
|
|
if filtered_features:
|
|
output += "<p><strong>Features</strong></p>\n<ul>\n"
|
|
for item in filtered_features:
|
|
desc = html.escape(item.get('description', 'N/A').strip())
|
|
output += f" <li>{desc}.</li>\n"
|
|
output += "</ul>\n"
|
|
|
|
# Add bugs section only if items remain after filtering
|
|
if filtered_bugs:
|
|
# Note: Heading was "Bugs/Housekeeping" in pasted code,
|
|
# changed to "Bug fixes" to match GDoc style mentioned earlier
|
|
output += "<p><strong>Bug fixes</strong></p>\n<ul>\n"
|
|
for item in filtered_bugs:
|
|
desc = html.escape(item.get('description', 'N/A').strip())
|
|
output += f" <li>{desc}.</li>\n"
|
|
output += "</ul>\n"
|
|
|
|
output += (
|
|
"<p>Builds for Windows and macOS are available now, along with "
|
|
"a Python Wheel,<br>"
|
|
)
|
|
output += (
|
|
"Docker Container, RPM, DEB Package, and source code tarball from:<br>"
|
|
)
|
|
output += f'<a href="{download_url}">{download_url}</a></p>\n'
|
|
# Note: Adjusted sign-off slightly from pasted code to match GDoc examples
|
|
output += "<p>--<br>The pgAdmin Team</p>\n"
|
|
|
|
return output
|
|
|
|
|
|
def format_markdown(data, skip_issues_set=None):
|
|
"""Formats the extracted data into Markdown (no issue links in lists)."""
|
|
if skip_issues_set is None:
|
|
skip_issues_set = set()
|
|
if not data.get('version'):
|
|
return "Error: Version not found in parsed data."
|
|
|
|
version = data['version']
|
|
release_url = (
|
|
f"https://www.pgadmin.org/docs/pgadmin4/{version}/"
|
|
f"release_notes_{version.replace('.', '_')}.html"
|
|
)
|
|
download_url = "https://www.pgadmin.org/download/"
|
|
website_url = "https://www.pgadmin.org/"
|
|
|
|
filtered_features = [
|
|
item for item in data.get('features', [])
|
|
if item.get('issue') not in skip_issues_set
|
|
]
|
|
filtered_bugs = [
|
|
item for item in data.get('bugs_housekeeping', [])
|
|
if item.get('issue') not in skip_issues_set
|
|
]
|
|
num_features = len(filtered_features)
|
|
num_bugs_housekeeping = len(filtered_bugs)
|
|
|
|
output = (
|
|
f"The pgAdmin Development Team is pleased to announce "
|
|
f"pgAdmin 4 version {version}. "
|
|
)
|
|
output += (
|
|
f"This release of pgAdmin 4 includes "
|
|
f"{pluralize(num_features, 'new feature')} and "
|
|
f"{pluralize(num_bugs_housekeeping, 'bug fix', 'bug fixes')}/"
|
|
f"housekeeping change"
|
|
f"{'s' if num_bugs_housekeeping != 1 else ''}. "
|
|
)
|
|
output += (f"For more details, "
|
|
f"please see the [release notes]({release_url}).")
|
|
# Ensure markdown paragraph break
|
|
output += "\n \n"
|
|
output += (
|
|
f"pgAdmin is the leading Open Source graphical management "
|
|
f"tool for PostgreSQL. For more information, please see "
|
|
f"[the website]({website_url})."
|
|
)
|
|
output += "\n\n"
|
|
output += "Notable changes in this release include:\n \n"
|
|
|
|
if filtered_features:
|
|
output += "### Features:\n"
|
|
for item in filtered_features:
|
|
desc = item.get('description', 'N/A').strip()
|
|
output += f"* {desc}.\n" # Link logic removed
|
|
output += "\n"
|
|
|
|
if filtered_bugs:
|
|
output += "### Bugs/Housekeeping:\n"
|
|
for item in filtered_bugs:
|
|
desc = item.get('description', 'N/A').strip()
|
|
output += f"* {desc}.\n" # Link logic removed
|
|
output += "\n"
|
|
|
|
output += (
|
|
f"Builds for Windows and macOS are available now, along with "
|
|
f"a Python Wheel, Docker Container, RPM, DEB Package, and "
|
|
f"source code tarball from the [download area]({download_url})."
|
|
)
|
|
|
|
return output
|
|
|
|
|
|
def format_html(data, skip_issues_set=None):
|
|
"""Formats extracted data into HTML for web news articles."""
|
|
if skip_issues_set is None:
|
|
skip_issues_set = set()
|
|
# Removed local import html, using global one
|
|
|
|
if not data.get('version'):
|
|
return "<p>Error: Version not found in parsed data.</p>"
|
|
|
|
version = data['version']
|
|
# Use relative paths for web article links
|
|
release_url = (
|
|
f"/docs/pgadmin4/{version}/"
|
|
f"release_notes_{version.replace('.', '_')}.html"
|
|
)
|
|
download_url = "/download"
|
|
|
|
filtered_features = [
|
|
item for item in data.get('features', [])
|
|
if item.get('issue') not in skip_issues_set
|
|
]
|
|
filtered_bugs = [
|
|
item for item in data.get('bugs_housekeeping', [])
|
|
if item.get('issue') not in skip_issues_set
|
|
]
|
|
num_features = len(filtered_features)
|
|
num_bugs_housekeeping = len(filtered_bugs)
|
|
|
|
output = (
|
|
f"<p>The pgAdmin Development Team is pleased to announce "
|
|
f"pgAdmin 4 version {version}. "
|
|
)
|
|
output += (
|
|
f"This release of pgAdmin 4 includes "
|
|
f"{pluralize(num_features, 'new feature')} and "
|
|
f"{pluralize(num_bugs_housekeeping, 'bug fix', 'bug fixes')}/"
|
|
f"housekeeping change"
|
|
f"{'s' if num_bugs_housekeeping != 1 else ''}. "
|
|
)
|
|
output += (
|
|
f'For more details, please see the '
|
|
f'<a href="{release_url}">release notes</a>.</p>\n'
|
|
)
|
|
output += "<p>Notable changes in this release include:</p>\n"
|
|
|
|
if filtered_features:
|
|
output += "<p><strong>Features:</strong></p>\n<ul>\n"
|
|
for item in filtered_features:
|
|
desc = html.escape(item.get('description', 'N/A').strip())
|
|
# Link logic removed, keep bolding for features in news format
|
|
output += f" <li><strong>{desc}.</strong></li>\n"
|
|
output += "</ul>\n"
|
|
|
|
if filtered_bugs:
|
|
output += "<p><strong>Bugs/Housekeeping:</strong></p>\n<ul>\n"
|
|
for item in filtered_bugs:
|
|
desc = html.escape(item.get('description', 'N/A').strip())
|
|
# Link logic removed, no bolding for bugs in news format
|
|
output += f" <li>{desc}.</li>\n"
|
|
output += "</ul>\n"
|
|
|
|
output += f'<p><a href="{download_url}">Download</a> your copy now!</p>'
|
|
|
|
return output
|
|
|
|
|
|
# --- Main Execution ---
|
|
if __name__ == "__main__":
|
|
# --- Setup Argument Parser ---
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Converts pgAdmin RST release notes to Email (HTML), Markdown, "
|
|
"and HTML (web) formats.\nIssue links are omitted from lists. "
|
|
"Allows skipping specific issues."
|
|
),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=(
|
|
"Examples:\n"
|
|
" # Default: Don't skip issues\n"
|
|
" python release_converter.py path/to/notes.rst\n\n"
|
|
" # Skip issues 8602 and 8603\n"
|
|
" python release_converter.py path/to/notes.rst "
|
|
"--skip-issues 8602 8603\n\n"
|
|
" # Save email output, default skip behavior\n"
|
|
" python release_converter.py path/to/notes.rst "
|
|
"--output-email-html email.html"
|
|
)
|
|
)
|
|
parser.add_argument(
|
|
"input_file",
|
|
metavar="<input_rst_file_path>",
|
|
type=str,
|
|
help="Path to the input ReStructuredText (.rst) release note file."
|
|
)
|
|
parser.add_argument(
|
|
"--output-email-html",
|
|
metavar="<email_output_path.html>",
|
|
type=str,
|
|
default=None, # Use None as default for optional args
|
|
help="Optional path to save the Email HTML output to a file."
|
|
)
|
|
parser.add_argument(
|
|
"--skip-issues",
|
|
metavar="ISSUE_NUM",
|
|
type=str,
|
|
nargs='+', # Expect 1 or more arguments
|
|
default=None, # Default to None if not provided
|
|
help="List of issue numbers (e.g., 8602 8603) to skip from lists."
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# --- Read Input File ---
|
|
input_rst_content = ""
|
|
try:
|
|
with open(args.input_file, "r", encoding="utf-8") as f:
|
|
input_rst_content = f.read()
|
|
print(f"Successfully read file: {args.input_file}", file=sys.stderr)
|
|
except FileNotFoundError:
|
|
print(f"Error: Input file not found at '{args.input_file}'",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Error reading file '{args.input_file}': {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# --- Parse the input data ---
|
|
print("Parsing release notes...", file=sys.stderr)
|
|
parsed_data = parse_rst_release_note(input_rst_content)
|
|
|
|
if not parsed_data.get('version'):
|
|
print("\nError: Parsing failed to find version. Cannot proceed.",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# --- Create skip set ---
|
|
# Handle default=None for skip_issues
|
|
skip_issues_set = set(args.skip_issues) if args.skip_issues else set()
|
|
if skip_issues_set:
|
|
print(f"Attempting to skip issues: "
|
|
f"{', '.join(sorted(list(skip_issues_set)))}", file=sys.stderr)
|
|
|
|
# --- Generate the different formats ---
|
|
print("Generating output formats...", file=sys.stderr)
|
|
email_html_output = format_email_html(
|
|
parsed_data, skip_issues_set=skip_issues_set
|
|
)
|
|
markdown_output = format_markdown(
|
|
parsed_data, skip_issues_set=skip_issues_set
|
|
)
|
|
news_html_output = format_html(
|
|
parsed_data, skip_issues_set=skip_issues_set
|
|
)
|
|
print("Format generation complete.", file=sys.stderr)
|
|
|
|
# --- Handle Outputs ---
|
|
if args.output_email_html:
|
|
try:
|
|
output_filename = args.output_email_html
|
|
# Recommend .html extension, but allow user override
|
|
if not output_filename.lower().endswith(('.html', '.htm')):
|
|
print(f"Warning: Output file '{output_filename}' does not end "
|
|
f"with .html or .htm. The content is HTML.",
|
|
file=sys.stderr)
|
|
|
|
with open(output_filename, "w", encoding="utf-8") as f:
|
|
f.write(email_html_output)
|
|
print(f"Email HTML output successfully "
|
|
f"saved to: {output_filename}",
|
|
file=sys.stderr)
|
|
except Exception as e:
|
|
print(f"Error writing Email HTML output to file "
|
|
f"'{args.output_email_html}': {e}", file=sys.stderr)
|
|
# Print to console as fallback if write fails
|
|
print("\n--- Email HTML Output ---")
|
|
print(email_html_output)
|
|
print("\n---------------------------------\n")
|
|
else:
|
|
# Default: Print email HTML to console
|
|
print("\n--- Email HTML Output ---")
|
|
print(email_html_output)
|
|
print("\n---------------------------------\n")
|
|
|
|
# --- Output Other Formats (always to console) ---
|
|
print("--- Markdown Output ---")
|
|
print(markdown_output)
|
|
print("\n---------------------------------\n")
|
|
|
|
print("--- News Article HTML Output ---")
|
|
print(news_html_output)
|
|
print("\n---------------------------------\n")
|
|
|
|
print("Script finished.", file=sys.stderr)
|