6
6
7
7
import re
8
8
import subprocess
9
+ from contextlib import redirect_stdout
9
10
from datetime import datetime , timezone
10
11
from functools import partial
12
+ from io import StringIO
11
13
from pathlib import Path
12
- from typing import Callable
14
+ from typing import Callable , NamedTuple
13
15
14
16
import click
15
17
import tomli
16
18
from packaging .version import Version , parse
19
+ from sphinx .ext import intersphinx
17
20
from typing_extensions import TypeAlias
18
21
19
22
BASE = Path (__file__ ).parent .parent .absolute ()
20
23
PYPROJECT = BASE / "pyproject.toml"
21
24
CHANGELOG = BASE / "docs" / "changelog.rst"
22
25
DOCS = "https://beets.readthedocs.io/en/stable"
23
26
24
- version_header = r"\d+\.\d+\.\d+ \([^)]+\)"
27
+ VERSION_HEADER = r"\d+\.\d+\.\d+ \([^)]+\)"
25
28
RST_LATEST_CHANGES = re .compile (
26
- rf"{ version_header } \n--+\s+(.+?)\n\n+{ version_header } " , re .DOTALL
29
+ rf"{ VERSION_HEADER } \n--+\s+(.+?)\n\n+{ VERSION_HEADER } " , re .DOTALL
27
30
)
31
+
28
32
Replacement : TypeAlias = "tuple[str, str | Callable[[re.Match[str]], str]]"
29
- RST_REPLACEMENTS : list [Replacement ] = [
30
- (r"(?<=\n) {3,4}(?=\*)" , " " ), # fix indent of nested bullet points ...
31
- (r"(?<=\n) {5,6}(?=[\w:`])" , " " ), # ... and align wrapped text indent
32
- (r"(?<=[\s(])(`[^`]+`)(?!_)" , r"`\1`" ), # double quotes for inline code
33
- (r":bug:`(\d+)`" , r":bug: (#\1)" ), # Issue numbers.
34
- (r":user:`(\w+)`" , r"\@\1" ), # Users.
35
- ]
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
+
36
134
MD_REPLACEMENTS : list [Replacement ] = [
37
135
(r"^ (- )" , r"\1" ), # remove indent from top-level bullet points
38
136
(r"^ +( - )" , r"\1" ), # adjust nested bullet points indent
39
137
(r"^(\w[^\n]{,80}):(?=\n\n[^ ])" , r"### \1" ), # format section headers
40
138
(r"^(\w[^\n]{81,}):(?=\n\n[^ ])" , r"**\1**" ), # and bolden too long ones
41
- (r"^- `/?plugins/(\w+)`:?" , rf"- Plugin [\1]({ DOCS } /plugins/\1.html):" ),
42
- (r"^- `(\w+)-cmd`:?" , rf"- Command [\1]({ DOCS } /reference/cli.html#\1):" ),
43
139
(r"### [^\n]+\n+(?=### )" , "" ), # remove empty sections
44
140
]
45
141
order_bullet_points = partial (
@@ -123,7 +219,7 @@ def rst2md(text: str) -> str:
123
219
"""Use Pandoc to convert text from ReST to Markdown."""
124
220
return (
125
221
subprocess .check_output (
126
- ["pandoc" , "--from=rst" , "--to=gfm" , "--wrap=none " ],
222
+ ["pandoc" , "--from=rst" , "--to=gfm+hard_line_breaks " ],
127
223
input = text .encode (),
128
224
)
129
225
.decode ()
@@ -132,7 +228,6 @@ def rst2md(text: str) -> str:
132
228
133
229
134
230
def get_changelog_contents () -> str | None :
135
- return CHANGELOG .read_text ()
136
231
if m := RST_LATEST_CHANGES .search (CHANGELOG .read_text ()):
137
232
return m .group (1 )
138
233
@@ -141,8 +236,8 @@ def get_changelog_contents() -> str | None:
141
236
142
237
def changelog_as_markdown (rst : str ) -> str :
143
238
"""Get the latest changelog entry as hacked up Markdown."""
144
- for pattern , repl in RST_REPLACEMENTS :
145
- rst = re .sub (pattern , repl , rst , flags = re .M )
239
+ for pattern , repl in create_rst_replacements () :
240
+ rst = re .sub (pattern , repl , rst , flags = re .M | re . DOTALL )
146
241
147
242
md = rst2md (rst )
148
243
@@ -170,7 +265,10 @@ def bump(version: Version) -> None:
170
265
def changelog ():
171
266
"""Get the most recent version's changelog as Markdown."""
172
267
if changelog := get_changelog_contents ():
173
- print (changelog_as_markdown (changelog ))
268
+ try :
269
+ print (changelog_as_markdown (changelog ))
270
+ except ValueError as e :
271
+ raise click .exceptions .UsageError (str (e ))
174
272
175
273
176
274
if __name__ == "__main__" :
0 commit comments