Skip to content

Commit eb557f7

Browse files
committed
Resolve all URLs for markdown
1 parent 555cf32 commit eb557f7

File tree

4 files changed

+123
-22
lines changed

4 files changed

+123
-22
lines changed

.github/workflows/ci.yaml

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ jobs:
3434
run: |
3535
sudo apt update
3636
sudo apt install ffmpeg gobject-introspection libgirepository1.0-dev pandoc
37-
poetry install --with=release --extras=replaygain --extras=reflink
37+
poetry install --with=release --extras=docs --extras=replaygain --extras=reflink
38+
poe docs
3839
3940
- name: Install Python dependencies
4041
run: poetry install --only=main,test --extras=autobpm

.github/workflows/make_release.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ jobs:
6565
- name: Obtain the changelog
6666
id: generate_changelog
6767
run: |
68+
poe docs
6869
{
6970
echo 'changelog<<EOF'
7071
poe --quiet changelog

extra/release.py

+115-17
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,136 @@
66

77
import re
88
import subprocess
9+
from contextlib import redirect_stdout
910
from datetime import datetime, timezone
1011
from functools import partial
12+
from io import StringIO
1113
from pathlib import Path
12-
from typing import Callable
14+
from typing import Callable, NamedTuple
1315

1416
import click
1517
import tomli
1618
from packaging.version import Version, parse
19+
from sphinx.ext import intersphinx
1720
from typing_extensions import TypeAlias
1821

1922
BASE = Path(__file__).parent.parent.absolute()
2023
PYPROJECT = BASE / "pyproject.toml"
2124
CHANGELOG = BASE / "docs" / "changelog.rst"
2225
DOCS = "https://beets.readthedocs.io/en/stable"
2326

24-
version_header = r"\d+\.\d+\.\d+ \([^)]+\)"
27+
VERSION_HEADER = r"\d+\.\d+\.\d+ \([^)]+\)"
2528
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
2730
)
31+
2832
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+
36134
MD_REPLACEMENTS: list[Replacement] = [
37135
(r"^ (- )", r"\1"), # remove indent from top-level bullet points
38136
(r"^ +( - )", r"\1"), # adjust nested bullet points indent
39137
(r"^(\w[^\n]{,80}):(?=\n\n[^ ])", r"### \1"), # format section headers
40138
(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):"),
43139
(r"### [^\n]+\n+(?=### )", ""), # remove empty sections
44140
]
45141
order_bullet_points = partial(
@@ -123,7 +219,7 @@ def rst2md(text: str) -> str:
123219
"""Use Pandoc to convert text from ReST to Markdown."""
124220
return (
125221
subprocess.check_output(
126-
["pandoc", "--from=rst", "--to=gfm", "--wrap=none"],
222+
["pandoc", "--from=rst", "--to=gfm+hard_line_breaks"],
127223
input=text.encode(),
128224
)
129225
.decode()
@@ -132,7 +228,6 @@ def rst2md(text: str) -> str:
132228

133229

134230
def get_changelog_contents() -> str | None:
135-
return CHANGELOG.read_text()
136231
if m := RST_LATEST_CHANGES.search(CHANGELOG.read_text()):
137232
return m.group(1)
138233

@@ -141,8 +236,8 @@ def get_changelog_contents() -> str | None:
141236

142237
def changelog_as_markdown(rst: str) -> str:
143238
"""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)
146241

147242
md = rst2md(rst)
148243

@@ -170,7 +265,10 @@ def bump(version: Version) -> None:
170265
def changelog():
171266
"""Get the most recent version's changelog as Markdown."""
172267
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))
174272

175273

176274
if __name__ == "__main__":

test/test_release.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
import pytest
88

9-
from extra.release import changelog_as_markdown
9+
release = pytest.importorskip("extra.release")
10+
1011

1112
pytestmark = pytest.mark.skipif(
1213
not (
@@ -69,8 +70,8 @@ def rst_changelog():
6970
def md_changelog():
7071
return r"""### New features
7172
72-
- Command [list](https://beets.readthedocs.io/en/stable/reference/cli.html#list): Update.
73-
- Plugin [substitute](https://beets.readthedocs.io/en/stable/plugins/substitute.html): Some substitute multi-line change. :bug: (\#5467)
73+
- [Substitute Plugin](https://beets.readthedocs.io/en/stable/plugins/substitute.html): Some substitute multi-line change. :bug: (\#5467)
74+
- [list](https://beets.readthedocs.io/en/stable/reference/cli.html#list-cmd) Update.
7475
7576
You can do something with this command:
7677
@@ -102,6 +103,6 @@ def md_changelog():
102103

103104

104105
def test_convert_rst_to_md(rst_changelog, md_changelog):
105-
actual = changelog_as_markdown(rst_changelog)
106+
actual = release.changelog_as_markdown(rst_changelog)
106107

107108
assert actual == md_changelog

0 commit comments

Comments
 (0)