6
6
7
7
import re
8
8
import subprocess
9
+ from contextlib import redirect_stdout
9
10
from datetime import datetime , timezone
11
+ from functools import partial
12
+ from io import StringIO
10
13
from pathlib import Path
11
- from typing import Callable
14
+ from typing import Callable , NamedTuple
12
15
13
16
import click
14
17
import tomli
15
18
from packaging .version import Version , parse
19
+ from sphinx .ext import intersphinx
20
+ from typing_extensions import TypeAlias
16
21
17
22
BASE = Path (__file__ ).parent .parent .absolute ()
18
23
PYPROJECT = BASE / "pyproject.toml"
19
24
CHANGELOG = BASE / "docs" / "changelog.rst"
25
+ DOCS = "https://beets.readthedocs.io/en/stable"
20
26
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+ \([^)]+\)"
23
28
RST_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 - " ))),
25
144
)
26
145
27
146
@@ -41,8 +160,11 @@ def update_changelog(text: str, new: Version) -> str:
41
160
----------
42
161
43
162
New features:
163
+
44
164
Bug fixes:
165
+
45
166
For packagers:
167
+
46
168
Other changes:
47
169
48
170
{ new_header }
@@ -95,50 +217,36 @@ def bump_version(new: Version) -> None:
95
217
96
218
def rst2md (text : str ) -> str :
97
219
"""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 )
106
220
return (
107
221
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 (),
110
224
)
111
225
.decode ()
112
226
.strip ()
113
227
)
114
228
115
229
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 )
120
233
121
- m = RST_LATEST_CHANGES .search (contents )
122
- rst = m .group (1 ) if m else ""
234
+ return None
123
235
124
- # Convert with Pandoc.
125
- md = rst2md (rst )
126
236
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 )
129
241
130
- # Highlight plugin names
131
- md = re .sub (
132
- r"^- `/?plugins/(\w+)`:?" , r"- Plugin **`\1`**:" , md , flags = re .M
133
- )
242
+ md = rst2md (rst )
134
243
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 )
137
246
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 )
142
250
143
251
144
252
@click .group ()
@@ -156,7 +264,11 @@ def bump(version: Version) -> None:
156
264
@cli .command ()
157
265
def changelog ():
158
266
"""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 ))
160
272
161
273
162
274
if __name__ == "__main__" :
0 commit comments