Skip to content

Commit eaada13

Browse files
authored
Add substitutions (#11)
1 parent 74d6193 commit eaada13

14 files changed

+292
-40
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ The **third number** is for emergencies when we need to start branches for older
1313

1414
## [Unreleased](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.3.0...HEAD)
1515

16+
### Added
17+
18+
- It is now possible to run regexp-based substitutions over the final readme.
19+
[#9](https://github.com/hynek/hatch-fancy-pypi-readme/issues/9)
20+
[#11](https://github.com/hynek/hatch-fancy-pypi-readme/issues/11)
21+
22+
1623
## [22.3.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.2.0...22.3.0) - 2022-08-06
1724

1825
### Added

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,38 @@ to your readme.
175175
For a complete example, please see our [example configuration][example-config].
176176

177177

178+
## Substitutions
179+
180+
After a readme is assembled out of fragments, it's possible to run an arbitrary number of [regexp](https://docs.python.org/3/library/re.html)-based substitutions over it:
181+
182+
```toml
183+
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
184+
pattern = "This is a (.*) that we'll replace later."
185+
replacement = "It was a '\\1'!"
186+
ignore_case = true # optional; false by default
187+
```
188+
189+
---
190+
191+
Substitutions are for instance useful for replacing relative links with absolute ones:
192+
193+
```toml
194+
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
195+
# Literal TOML strings (single quotes) need no escaping of backslashes.
196+
pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)'
197+
replacement = '[\1](https://github.com/hynek/hatch-fancy-pypi-readme/tree/main\g<2>)'
198+
```
199+
200+
or expanding GitHub issue/pull request IDs to links:
201+
202+
```toml
203+
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
204+
# Regular TOML strings (double quotes) do.
205+
pattern = "#(\\d+)"
206+
replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)"
207+
```
208+
209+
178210
## CLI Interface
179211

180212
For faster feedback loops, *hatch-fancy-pypi-readme* comes with a CLI interface that takes a `pyproject.toml` file as an argument and renders out the readme that would go into respective package.

rich-cli-out.svg

Lines changed: 31 additions & 29 deletions
Loading

src/hatch_fancy_pypi_readme/_builder.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,19 @@
55
from __future__ import annotations
66

77
from ._fragments import Fragment
8+
from ._substitutions import Substituter
89

910

10-
def build_text(fragments: list[Fragment]) -> str:
11+
def build_text(
12+
fragments: list[Fragment], substitutions: list[Substituter]
13+
) -> str:
1114
rv = []
1215
for f in fragments:
1316
rv.append(f.render())
1417

15-
return "".join(rv)
18+
text = "".join(rv)
19+
20+
for sub in substitutions:
21+
text = sub.substitute(text)
22+
23+
return text

src/hatch_fancy_pypi_readme/_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def cli_run(pyproject: dict[str, Any], out: TextIO) -> None:
4545
+ "\n".join(f"- {msg}" for msg in e.errors),
4646
)
4747

48-
print(build_text(config.fragments), file=out)
48+
print(build_text(config.fragments, config.substitutions), file=out)
4949

5050

5151
def _fail(msg: str) -> NoReturn:

src/hatch_fancy_pypi_readme/_config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
from typing import Any
99

1010
from ._fragments import Fragment, load_fragments
11+
from ._substitutions import Substituter, load_substitutions
1112
from .exceptions import ConfigurationError
1213

1314

1415
@dataclass
1516
class Config:
1617
content_type: str
1718
fragments: list[Fragment]
19+
substitutions: list[Substituter]
1820

1921

2022
def load_and_validate_config(config: dict[str, Any]) -> Config:
@@ -41,7 +43,12 @@ def load_and_validate_config(config: dict[str, Any]) -> Config:
4143
except ConfigurationError as e:
4244
errs.extend(e.errors)
4345

46+
try:
47+
subs = load_substitutions(config.get("substitutions", []))
48+
except ConfigurationError as e:
49+
errs.extend(e.errors)
50+
4451
if errs:
4552
raise ConfigurationError(errs)
4653

47-
return Config(config["content-type"], frags)
54+
return Config(config["content-type"], frags, subs)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# SPDX-FileCopyrightText: 2022 Hynek Schlawack <[email protected]>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from __future__ import annotations
6+
7+
import re
8+
9+
from dataclasses import dataclass
10+
11+
from hatch_fancy_pypi_readme.exceptions import ConfigurationError
12+
13+
14+
def load_substitutions(config: list[dict[str, str]]) -> list[Substituter]:
15+
errs = []
16+
subs = []
17+
18+
for cfg in config:
19+
try:
20+
subs.append(Substituter.from_config(cfg))
21+
except ConfigurationError as e:
22+
errs.extend(e.errors)
23+
24+
if errs:
25+
raise ConfigurationError([f"substitution: {e}" for e in errs])
26+
27+
return subs
28+
29+
30+
@dataclass
31+
class Substituter:
32+
pattern: re.Pattern[str]
33+
replacement: str
34+
35+
@classmethod
36+
def from_config(cls, cfg: dict[str, str]) -> Substituter:
37+
errs = []
38+
flags = 0
39+
40+
ignore_case = cfg.get("ignore_case", False)
41+
if not isinstance(ignore_case, bool):
42+
errs.append("`ignore_case` must be a bool.")
43+
if ignore_case:
44+
flags += re.IGNORECASE
45+
46+
try:
47+
pattern = re.compile(cfg["pattern"], flags=flags)
48+
except KeyError:
49+
errs.append("missing `pattern` key.")
50+
except re.error as e:
51+
errs.append(f"can't compile pattern: {e}")
52+
53+
try:
54+
replacement = cfg["replacement"]
55+
except KeyError:
56+
errs.append("missing `replacement` key.")
57+
58+
if errs:
59+
raise ConfigurationError(errs)
60+
61+
return cls(pattern, replacement)
62+
63+
def substitute(self, text: str) -> str:
64+
return self.pattern.sub(self.replacement, text)

src/hatch_fancy_pypi_readme/hooks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def update(self, metadata: dict[str, Any]) -> None:
2525

2626
metadata["readme"] = {
2727
"content-type": config.content_type,
28-
"text": build_text(config.fragments),
28+
"text": build_text(config.fragments, config.substitutions),
2929
}
3030

3131

tests/example_changelog.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,33 @@
33
This is a long-winded preamble that explains versioning and backwards-compatibility guarantees.
44
Your don't want this as part of your PyPI readme!
55

6+
Note that there's issue/PR IDs behind the changelog entries.
7+
Wouldn't it be nice if they were links in your PyPI readme?
8+
69
<!-- changelog follows -->
710

811

912
## 1.1.0 - 2022-08-04
1013

1114
### Added
1215

13-
- Neat features.
16+
- Neat features. #4
17+
- Here's a [GitHub-relative link](README.md) -- that would make no sense in a PyPI readme!
1418

1519
### Fixed
1620

17-
- Nasty bugs.
21+
- Nasty bugs. #3
1822

1923

2024
## 1.0.0 - 2021-12-16
2125

2226
### Added
2327

24-
- Everything.
28+
- Everything. #2
2529

2630

2731
## 0.0.1 - 2020-03-01
2832

2933
### Removed
3034

31-
- Precedency.
35+
- Precedency. #1

tests/example_pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,11 @@ pattern = "<!-- changelog follows -->\n\n\n(.*?)\n\n## "
4040

4141
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
4242
text = "\n---\n\nPretty **cool**, huh? ✨"
43+
44+
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
45+
pattern = "#(\\d+)"
46+
replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)"
47+
48+
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
49+
pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)'
50+
replacement = '[\1](https://github.com/hynek/hatch-fancy-pypi-readme/tree/main/\g<2>)'

tests/test_builder.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def test_single_text_fragment(self):
1212
A single text fragment becomes the readme.
1313
"""
1414
assert "This is the README!" == build_text(
15-
[TextFragment("This is the README!")]
15+
[TextFragment("This is the README!")], []
1616
)
1717

1818
def test_multiple_text_fragment(self):
@@ -24,5 +24,6 @@ def test_multiple_text_fragment(self):
2424
[
2525
TextFragment("# Level 1\n\n"),
2626
TextFragment("This is the README!"),
27-
]
27+
],
28+
[],
2829
)

tests/test_cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ def test_ok(self):
5454
assert out.startswith("# Level 1 Header")
5555
assert "1.0.0" not in out
5656

57+
# Check substitutions
58+
assert (
59+
"[GitHub-relative link](https://github.com/hynek/"
60+
"hatch-fancy-pypi-readme/tree/main/README.md)" in out
61+
)
62+
assert (
63+
"Neat features. [#4](https://github.com/hynek/"
64+
"hatch-fancy-pypi-readme/issues/4)" in out
65+
)
66+
5767
def test_ok_redirect(self, tmp_path):
5868
"""
5969
It's possible to redirect output into a file.

tests/test_config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,21 @@ def test_missing_fragments(self):
6868
== ei.value.errors
6969
== ei.value.args[0]
7070
)
71+
72+
def test_invalid_substitution(self):
73+
"""
74+
Invalid substitutions are caught and reported.
75+
"""
76+
with pytest.raises(ConfigurationError) as ei:
77+
load_and_validate_config(
78+
{
79+
"content-type": "text/markdown",
80+
"fragments": [{"text": "foo"}],
81+
"substitutions": [{"foo": "bar"}],
82+
}
83+
)
84+
85+
assert [
86+
"substitution: missing `pattern` key.",
87+
"substitution: missing `replacement` key.",
88+
] == ei.value.errors

tests/test_substitutions.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# SPDX-FileCopyrightText: 2022 Hynek Schlawack <[email protected]>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from __future__ import annotations
6+
7+
import pytest
8+
9+
from hatch_fancy_pypi_readme._substitutions import (
10+
Substituter,
11+
load_substitutions,
12+
)
13+
from hatch_fancy_pypi_readme.exceptions import ConfigurationError
14+
15+
16+
class TestLoadSubstitutions:
17+
def test_empty(self):
18+
"""
19+
Having no substitutions is fine.
20+
"""
21+
assert [] == load_substitutions([])
22+
23+
def test_error(self):
24+
"""
25+
Invalid substitutions are caught and reported.
26+
"""
27+
with pytest.raises(ConfigurationError) as ei:
28+
load_substitutions([{"in": "valid"}])
29+
30+
assert [
31+
"substitution: missing `pattern` key.",
32+
"substitution: missing `replacement` key.",
33+
] == ei.value.errors
34+
35+
36+
VALID = {"pattern": "f(o)o", "replacement": r"bar\g<1>bar"}
37+
38+
39+
def cow_valid(**kw):
40+
d = VALID.copy()
41+
d.update(**kw)
42+
43+
return d
44+
45+
46+
class TestSubstituter:
47+
def test_ok(self):
48+
"""
49+
Valid pattern leads to correct behavior.
50+
"""
51+
sub = Substituter.from_config(VALID)
52+
53+
assert "xxx barobar yyy" == sub.substitute("xxx foo yyy")
54+
55+
@pytest.mark.parametrize(
56+
"cfg, errs",
57+
[
58+
({}, ["missing `pattern` key.", "missing `replacement` key."]),
59+
(cow_valid(ignore_case=42), ["`ignore_case` must be a bool."]),
60+
(
61+
cow_valid(pattern="???"),
62+
["can't compile pattern: nothing to repeat at position 0"],
63+
),
64+
],
65+
)
66+
def test_catches_all_errors(self, cfg, errs):
67+
"""
68+
All errors are caught and reported.
69+
"""
70+
with pytest.raises(ConfigurationError) as ei:
71+
Substituter.from_config(cfg)
72+
73+
assert errs == ei.value.errors
74+
75+
def test_twisted(self):
76+
"""
77+
Twisted example works.
78+
79+
https://github.com/twisted/twisted/blob/eda9d29dc7fe34e7b207781e5674dc92f798bffe/setup.py#L19-L24
80+
"""
81+
assert (
82+
"For information on changes in this release, see the `NEWS <https://github.com/twisted/twisted/blob/trunk/NEWS.rst>`_ file." # noqa
83+
) == Substituter.from_config(
84+
{
85+
"pattern": r"`([^`]+)\s+<(?!https?://)([^>]+)>`_",
86+
"replacement": r"`\1 <https://github.com/twisted/twisted/blob/trunk/\2>`_", # noqa
87+
"ignore_case": True,
88+
}
89+
).substitute(
90+
"For information on changes in this release, see the `NEWS <NEWS.rst>`_ file." # noqa
91+
)

0 commit comments

Comments
 (0)