66
77import re
88import subprocess
9+ from contextlib import redirect_stdout
910from datetime import datetime , timezone
11+ from functools import partial
12+ from io import StringIO
1013from pathlib import Path
11- from typing import Callable
14+ from typing import Callable , NamedTuple
1215
1316import click
1417import tomli
1518from packaging .version import Version , parse
19+ from sphinx .ext import intersphinx
20+ from typing_extensions import TypeAlias
1621
1722BASE = Path (__file__ ).parent .parent .absolute ()
1823PYPROJECT = BASE / "pyproject.toml"
1924CHANGELOG = BASE / "docs" / "changelog.rst"
25+ DOCS = "https://beets.readthedocs.io/en/stable"
2026
21- MD_CHANGELOG_SECTION_LIST = re .compile (r"- .+?(?=\n\n###|$)" , re .DOTALL )
22- version_header = r"\d+\.\d+\.\d+ \([^)]+\)"
27+ VERSION_HEADER = r"\d+\.\d+\.\d+ \([^)]+\)"
2328RST_LATEST_CHANGES = re .compile (
24- rf"{ version_header } \n--+\s+(.+?)\n\n+{ version_header } " , re .DOTALL
29+ rf"{ VERSION_HEADER } \n--+\s+(.+?)\n\n+{ VERSION_HEADER } " , re .DOTALL
30+ )
31+
32+ Replacement : TypeAlias = "tuple[str, str | Callable[[re.Match[str]], str]]"
33+
34+
35+ class Ref (NamedTuple ):
36+ """A reference to documentation with ID, path, and optional title."""
37+
38+ id : str
39+ path : str | None
40+ title : str | None
41+
42+ @classmethod
43+ def from_line (cls , line : str ) -> Ref :
44+ """Create Ref from a Sphinx objects.inv line.
45+
46+ Each line has the following structure:
47+ <id> [optional title : ] <relative-url-path>
48+
49+ """
50+ if len (line_parts := line .split (" " , 1 )) == 1 :
51+ return cls (line , None , None )
52+
53+ id , path_with_name = line_parts
54+ parts = [p .strip () for p in path_with_name .split (":" , 1 )]
55+
56+ if len (parts ) == 1 :
57+ path , name = parts [0 ], None
58+ else :
59+ name , path = parts
60+
61+ return cls (id , path , name )
62+
63+ @property
64+ def url (self ) -> str :
65+ """Full documentation URL."""
66+ return f"{ DOCS } /{ self .path } "
67+
68+ @property
69+ def name (self ) -> str :
70+ """Display name (title if available, otherwise ID)."""
71+ return self .title or self .id
72+
73+
74+ def get_refs () -> dict [str , Ref ]:
75+ """Parse Sphinx objects.inv and return dict of documentation references."""
76+ objects_filepath = Path ("docs/_build/html/objects.inv" )
77+ if not objects_filepath .exists ():
78+ raise ValueError ("Documentation does not exist. Run 'poe docs' first." )
79+
80+ captured_output = StringIO ()
81+
82+ with redirect_stdout (captured_output ):
83+ intersphinx .inspect_main ([str (objects_filepath )])
84+
85+ return {
86+ r .id : r
87+ for ln in captured_output .getvalue ().split ("\n " )
88+ if ln .startswith ("\t " ) and (r := Ref .from_line (ln .strip ()))
89+ }
90+
91+
92+ def create_rst_replacements () -> list [Replacement ]:
93+ """Generate list of pattern replacements for RST changelog."""
94+ refs = get_refs ()
95+
96+ def make_ref_link (ref_id : str , name : str | None = None ) -> str :
97+ ref = refs [ref_id ]
98+ return rf"`{ name or ref .name } <{ ref .url } >`_"
99+
100+ commands = "|" .join (r .split ("-" )[0 ] for r in refs if r .endswith ("-cmd" ))
101+ plugins = "|" .join (
102+ r .split ("/" )[- 1 ] for r in refs if r .startswith ("plugins/" )
103+ )
104+ return [
105+ # Fix nested bullet points indent: use 2 spaces consistently
106+ (r"(?<=\n) {3,4}(?=\*)" , " " ),
107+ # Fix nested text indent: use 4 spaces consistently
108+ (r"(?<=\n) {5,6}(?=[\w:`])" , " " ),
109+ # Replace Sphinx :ref: and :doc: directives by documentation URLs
110+ # :ref:`/plugins/autobpm` -> [AutoBPM Plugin](DOCS/plugins/autobpm.html)
111+ (
112+ r":(?:ref|doc):`+(?:([^`<]+)<)?/?([\w./_-]+)>?`+" ,
113+ lambda m : make_ref_link (m [2 ], m [1 ]),
114+ ),
115+ # Convert command references to documentation URLs
116+ # `beet move` or `move` command -> [import](DOCS/reference/cli.html#import)
117+ (
118+ rf"`+beet ({ commands } )`+|`+({ commands } )`+(?= command)" ,
119+ lambda m : make_ref_link (f"{ m [1 ] or m [2 ]} -cmd" ),
120+ ),
121+ # Convert plugin references to documentation URLs
122+ # `fetchart` plugin -> [fetchart](DOCS/plugins/fetchart.html)
123+ (rf"`+({ plugins } )`+" , lambda m : make_ref_link (f"plugins/{ m [1 ]} " )),
124+ # Add additional backticks around existing backticked text to ensure it
125+ # is rendered as inline code in Markdown
126+ (r"(?<=[\s])(`[^`]+`)(?!_)" , r"`\1`" ),
127+ # Convert bug references to GitHub issue links
128+ (r":bug:`(\d+)`" , r":bug: (#\1)" ),
129+ # Convert user references to GitHub @mentions
130+ (r":user:`(\w+)`" , r"\@\1" ),
131+ ]
132+
133+
134+ MD_REPLACEMENTS : list [Replacement ] = [
135+ (r"^ (- )" , r"\1" ), # remove indent from top-level bullet points
136+ (r"^ +( - )" , r"\1" ), # adjust nested bullet points indent
137+ (r"^(\w[^\n]{,80}):(?=\n\n[^ ])" , r"### \1" ), # format section headers
138+ (r"^(\w[^\n]{81,}):(?=\n\n[^ ])" , r"**\1**" ), # and bolden too long ones
139+ (r"### [^\n]+\n+(?=### )" , "" ), # remove empty sections
140+ ]
141+ order_bullet_points = partial (
142+ re .compile ("(\n - .*?(?=\n (?! *- )|$))" , flags = re .DOTALL ).sub ,
143+ lambda m : "\n - " .join (sorted (m .group ().split ("\n - " ))),
25144)
26145
27146
@@ -41,8 +160,11 @@ def update_changelog(text: str, new: Version) -> str:
41160----------
42161
43162New features:
163+
44164Bug fixes:
165+
45166For packagers:
167+
46168Other changes:
47169
48170{ new_header }
@@ -95,50 +217,36 @@ def bump_version(new: Version) -> None:
95217
96218def rst2md (text : str ) -> str :
97219 """Use Pandoc to convert text from ReST to Markdown."""
98- # Other backslashes with verbatim ranges.
99- rst = re .sub (r"(?<=[\s(])`([^`]+)`(?=[^_])" , r"``\1``" , text )
100-
101- # Bug numbers.
102- rst = re .sub (r":bug:`(\d+)`" , r":bug: (#\1)" , rst )
103-
104- # Users.
105- rst = re .sub (r":user:`(\w+)`" , r"@\1" , rst )
106220 return (
107221 subprocess .check_output (
108- ["/usr/bin/ pandoc" , "--from=rst" , "--to=gfm" , "--wrap=none " ],
109- input = rst .encode (),
222+ ["pandoc" , "--from=rst" , "--to=gfm+hard_line_breaks " ],
223+ input = text .encode (),
110224 )
111225 .decode ()
112226 .strip ()
113227 )
114228
115229
116- def changelog_as_markdown () -> str :
117- """Get the latest changelog entry as hacked up Markdown."""
118- with CHANGELOG .open () as f :
119- contents = f .read ()
230+ def get_changelog_contents () -> str | None :
231+ if m := RST_LATEST_CHANGES .search (CHANGELOG .read_text ()):
232+ return m .group (1 )
120233
121- m = RST_LATEST_CHANGES .search (contents )
122- rst = m .group (1 ) if m else ""
234+ return None
123235
124- # Convert with Pandoc.
125- md = rst2md (rst )
126236
127- # Make sections stand out
128- md = re .sub (r"^(\w.+?):$" , r"### \1" , md , flags = re .M )
237+ def changelog_as_markdown (rst : str ) -> str :
238+ """Get the latest changelog entry as hacked up Markdown."""
239+ for pattern , repl in create_rst_replacements ():
240+ rst = re .sub (pattern , repl , rst , flags = re .M | re .DOTALL )
129241
130- # Highlight plugin names
131- md = re .sub (
132- r"^- `/?plugins/(\w+)`:?" , r"- Plugin **`\1`**:" , md , flags = re .M
133- )
242+ md = rst2md (rst )
134243
135- # Highlights command names.
136- md = re .sub (r"^- `(\w+)-cmd`:?" , r"- Command **`\1`**:" , md , flags = re .M )
244+ for pattern , repl in MD_REPLACEMENTS :
245+ md = re .sub (pattern , repl , md , flags = re .M | re . DOTALL )
137246
138- # sort list items alphabetically for each of the sections
139- return MD_CHANGELOG_SECTION_LIST .sub (
140- lambda m : "\n " .join (sorted (m .group ().splitlines ())), md
141- )
247+ # order bullet points in each of the lists alphabetically to
248+ # improve readability
249+ return order_bullet_points (md )
142250
143251
144252@click .group ()
@@ -156,7 +264,11 @@ def bump(version: Version) -> None:
156264@cli .command ()
157265def changelog ():
158266 """Get the most recent version's changelog as Markdown."""
159- print (changelog_as_markdown ())
267+ if changelog := get_changelog_contents ():
268+ try :
269+ print (changelog_as_markdown (changelog ))
270+ except ValueError as e :
271+ raise click .exceptions .UsageError (str (e ))
160272
161273
162274if __name__ == "__main__" :
0 commit comments