Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add substitutions #11

Merged
merged 1 commit into from
Sep 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ The **third number** is for emergencies when we need to start branches for older

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

### Added

- It is now possible to run regexp-based substitutions over the final readme.
[#9](https://github.com/hynek/hatch-fancy-pypi-readme/issues/9)
[#11](https://github.com/hynek/hatch-fancy-pypi-readme/issues/11)


## [22.3.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.2.0...22.3.0) - 2022-08-06

### Added
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,38 @@ to your readme.
For a complete example, please see our [example configuration][example-config].


## Substitutions

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:

```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
pattern = "This is a (.*) that we'll replace later."
replacement = "It was a '\\1'!"
ignore_case = true # optional; false by default
```

---

Substitutions are for instance useful for replacing relative links with absolute ones:

```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
# Literal TOML strings (single quotes) need no escaping of backslashes.
pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)'
replacement = '[\1](https://github.com/hynek/hatch-fancy-pypi-readme/tree/main\g<2>)'
```

or expanding GitHub issue/pull request IDs to links:

```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
# Regular TOML strings (double quotes) do.
pattern = "#(\\d+)"
replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)"
```


## CLI Interface

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.
Expand Down
60 changes: 31 additions & 29 deletions rich-cli-out.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 10 additions & 2 deletions src/hatch_fancy_pypi_readme/_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@
from __future__ import annotations

from ._fragments import Fragment
from ._substitutions import Substituter


def build_text(fragments: list[Fragment]) -> str:
def build_text(
fragments: list[Fragment], substitutions: list[Substituter]
) -> str:
rv = []
for f in fragments:
rv.append(f.render())

return "".join(rv)
text = "".join(rv)

for sub in substitutions:
text = sub.substitute(text)

return text
2 changes: 1 addition & 1 deletion src/hatch_fancy_pypi_readme/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def cli_run(pyproject: dict[str, Any], out: TextIO) -> None:
+ "\n".join(f"- {msg}" for msg in e.errors),
)

print(build_text(config.fragments), file=out)
print(build_text(config.fragments, config.substitutions), file=out)


def _fail(msg: str) -> NoReturn:
Expand Down
9 changes: 8 additions & 1 deletion src/hatch_fancy_pypi_readme/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
from typing import Any

from ._fragments import Fragment, load_fragments
from ._substitutions import Substituter, load_substitutions
from .exceptions import ConfigurationError


@dataclass
class Config:
content_type: str
fragments: list[Fragment]
substitutions: list[Substituter]


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

try:
subs = load_substitutions(config.get("substitutions", []))
except ConfigurationError as e:
errs.extend(e.errors)

if errs:
raise ConfigurationError(errs)

return Config(config["content-type"], frags)
return Config(config["content-type"], frags, subs)
64 changes: 64 additions & 0 deletions src/hatch_fancy_pypi_readme/_substitutions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SPDX-FileCopyrightText: 2022 Hynek Schlawack <[email protected]>
#
# SPDX-License-Identifier: MIT

from __future__ import annotations

import re

from dataclasses import dataclass

from hatch_fancy_pypi_readme.exceptions import ConfigurationError


def load_substitutions(config: list[dict[str, str]]) -> list[Substituter]:
errs = []
subs = []

for cfg in config:
try:
subs.append(Substituter.from_config(cfg))
except ConfigurationError as e:
errs.extend(e.errors)

if errs:
raise ConfigurationError([f"substitution: {e}" for e in errs])

return subs


@dataclass
class Substituter:
pattern: re.Pattern[str]
replacement: str

@classmethod
def from_config(cls, cfg: dict[str, str]) -> Substituter:
errs = []
flags = 0

ignore_case = cfg.get("ignore_case", False)
if not isinstance(ignore_case, bool):
errs.append("`ignore_case` must be a bool.")
if ignore_case:
flags += re.IGNORECASE

try:
pattern = re.compile(cfg["pattern"], flags=flags)
except KeyError:
errs.append("missing `pattern` key.")
except re.error as e:
errs.append(f"can't compile pattern: {e}")

try:
replacement = cfg["replacement"]
except KeyError:
errs.append("missing `replacement` key.")

if errs:
raise ConfigurationError(errs)

return cls(pattern, replacement)

def substitute(self, text: str) -> str:
return self.pattern.sub(self.replacement, text)
2 changes: 1 addition & 1 deletion src/hatch_fancy_pypi_readme/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def update(self, metadata: dict[str, Any]) -> None:

metadata["readme"] = {
"content-type": config.content_type,
"text": build_text(config.fragments),
"text": build_text(config.fragments, config.substitutions),
}


Expand Down
12 changes: 8 additions & 4 deletions tests/example_changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,33 @@
This is a long-winded preamble that explains versioning and backwards-compatibility guarantees.
Your don't want this as part of your PyPI readme!

Note that there's issue/PR IDs behind the changelog entries.
Wouldn't it be nice if they were links in your PyPI readme?

<!-- changelog follows -->


## 1.1.0 - 2022-08-04

### Added

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

### Fixed

- Nasty bugs.
- Nasty bugs. #3


## 1.0.0 - 2021-12-16

### Added

- Everything.
- Everything. #2


## 0.0.1 - 2020-03-01

### Removed

- Precedency.
- Precedency. #1
8 changes: 8 additions & 0 deletions tests/example_pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,11 @@ pattern = "<!-- changelog follows -->\n\n\n(.*?)\n\n## "

[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
text = "\n---\n\nPretty **cool**, huh? ✨"

[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
pattern = "#(\\d+)"
replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)"

[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)'
replacement = '[\1](https://github.com/hynek/hatch-fancy-pypi-readme/tree/main/\g<2>)'
5 changes: 3 additions & 2 deletions tests/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def test_single_text_fragment(self):
A single text fragment becomes the readme.
"""
assert "This is the README!" == build_text(
[TextFragment("This is the README!")]
[TextFragment("This is the README!")], []
)

def test_multiple_text_fragment(self):
Expand All @@ -24,5 +24,6 @@ def test_multiple_text_fragment(self):
[
TextFragment("# Level 1\n\n"),
TextFragment("This is the README!"),
]
],
[],
)
10 changes: 10 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ def test_ok(self):
assert out.startswith("# Level 1 Header")
assert "1.0.0" not in out

# Check substitutions
assert (
"[GitHub-relative link](https://github.com/hynek/"
"hatch-fancy-pypi-readme/tree/main/README.md)" in out
)
assert (
"Neat features. [#4](https://github.com/hynek/"
"hatch-fancy-pypi-readme/issues/4)" in out
)

def test_ok_redirect(self, tmp_path):
"""
It's possible to redirect output into a file.
Expand Down
18 changes: 18 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,21 @@ def test_missing_fragments(self):
== ei.value.errors
== ei.value.args[0]
)

def test_invalid_substitution(self):
"""
Invalid substitutions are caught and reported.
"""
with pytest.raises(ConfigurationError) as ei:
load_and_validate_config(
{
"content-type": "text/markdown",
"fragments": [{"text": "foo"}],
"substitutions": [{"foo": "bar"}],
}
)

assert [
"substitution: missing `pattern` key.",
"substitution: missing `replacement` key.",
] == ei.value.errors
91 changes: 91 additions & 0 deletions tests/test_substitutions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# SPDX-FileCopyrightText: 2022 Hynek Schlawack <[email protected]>
#
# SPDX-License-Identifier: MIT

from __future__ import annotations

import pytest

from hatch_fancy_pypi_readme._substitutions import (
Substituter,
load_substitutions,
)
from hatch_fancy_pypi_readme.exceptions import ConfigurationError


class TestLoadSubstitutions:
def test_empty(self):
"""
Having no substitutions is fine.
"""
assert [] == load_substitutions([])

def test_error(self):
"""
Invalid substitutions are caught and reported.
"""
with pytest.raises(ConfigurationError) as ei:
load_substitutions([{"in": "valid"}])

assert [
"substitution: missing `pattern` key.",
"substitution: missing `replacement` key.",
] == ei.value.errors


VALID = {"pattern": "f(o)o", "replacement": r"bar\g<1>bar"}


def cow_valid(**kw):
d = VALID.copy()
d.update(**kw)

return d


class TestSubstituter:
def test_ok(self):
"""
Valid pattern leads to correct behavior.
"""
sub = Substituter.from_config(VALID)

assert "xxx barobar yyy" == sub.substitute("xxx foo yyy")

@pytest.mark.parametrize(
"cfg, errs",
[
({}, ["missing `pattern` key.", "missing `replacement` key."]),
(cow_valid(ignore_case=42), ["`ignore_case` must be a bool."]),
(
cow_valid(pattern="???"),
["can't compile pattern: nothing to repeat at position 0"],
),
],
)
def test_catches_all_errors(self, cfg, errs):
"""
All errors are caught and reported.
"""
with pytest.raises(ConfigurationError) as ei:
Substituter.from_config(cfg)

assert errs == ei.value.errors

def test_twisted(self):
"""
Twisted example works.

https://github.com/twisted/twisted/blob/eda9d29dc7fe34e7b207781e5674dc92f798bffe/setup.py#L19-L24
"""
assert (
"For information on changes in this release, see the `NEWS <https://github.com/twisted/twisted/blob/trunk/NEWS.rst>`_ file." # noqa
) == Substituter.from_config(
{
"pattern": r"`([^`]+)\s+<(?!https?://)([^>]+)>`_",
"replacement": r"`\1 <https://github.com/twisted/twisted/blob/trunk/\2>`_", # noqa
"ignore_case": True,
}
).substitute(
"For information on changes in this release, see the `NEWS <NEWS.rst>`_ file." # noqa
)