diff --git a/CHANGELOG.md b/CHANGELOG.md index af751d6..9fdc65c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2d9507f..3992b49 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/rich-cli-out.svg b/rich-cli-out.svg index fc4dd1c..b316de1 100644 --- a/rich-cli-out.svg +++ b/rich-cli-out.svg @@ -1,30 +1,32 @@ -Rich╔═════════════════════════════════════════════════════════════════════════════╗ -Level 1 Header -╚═════════════════════════════════════════════════════════════════════════════╝ - -This is Markdown in a literal string. - -Let's import AUTHORS.md without its header and last paragraph next: - -hatch-fancy-pypi-readme is written and maintained by Hynek Schlawack. - -The development is kindly supported by Variomedia AG and all my amazing GitHub  -Sponsors. - -─────────────────────────────────────────────────────────────────────────────── -Now let's add an extract from tests/example_changelog.md: - - -1.1.0 - 2022-08-04 - -Added - - • Neat features. - -Fixed - - • Nasty bugs. - -─────────────────────────────────────────────────────────────────────────────── -Pretty cool, huh? ✨ +Rich╔═════════════════════════════════════════════════════════════════════════════╗ +Level 1 Header +╚═════════════════════════════════════════════════════════════════════════════╝ + +This is Markdown in a literal string. + +Let's import AUTHORS.md without its header and last paragraph next: + +hatch-fancy-pypi-readme is written and maintained by Hynek Schlawack and  +released under the MIT license. + +The development is kindly supported by Variomedia AG and all my amazing GitHub  +Sponsors. + +─────────────────────────────────────────────────────────────────────────────── +Now let's add an extract from tests/example_changelog.md: + + +1.1.0 - 2022-08-04 + +Added + + • Neat features. #4 + • Here's a GitHub-relative link -- that would make no sense in a PyPI readme! + +Fixed + + • Nasty bugs. #3 + +─────────────────────────────────────────────────────────────────────────────── +Pretty cool, huh? ✨ \ No newline at end of file diff --git a/src/hatch_fancy_pypi_readme/_builder.py b/src/hatch_fancy_pypi_readme/_builder.py index 4e99927..b8e34b0 100644 --- a/src/hatch_fancy_pypi_readme/_builder.py +++ b/src/hatch_fancy_pypi_readme/_builder.py @@ -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 diff --git a/src/hatch_fancy_pypi_readme/_cli.py b/src/hatch_fancy_pypi_readme/_cli.py index a7e1faa..70a4dda 100644 --- a/src/hatch_fancy_pypi_readme/_cli.py +++ b/src/hatch_fancy_pypi_readme/_cli.py @@ -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: diff --git a/src/hatch_fancy_pypi_readme/_config.py b/src/hatch_fancy_pypi_readme/_config.py index 74c935d..e0dc124 100644 --- a/src/hatch_fancy_pypi_readme/_config.py +++ b/src/hatch_fancy_pypi_readme/_config.py @@ -8,6 +8,7 @@ from typing import Any from ._fragments import Fragment, load_fragments +from ._substitutions import Substituter, load_substitutions from .exceptions import ConfigurationError @@ -15,6 +16,7 @@ class Config: content_type: str fragments: list[Fragment] + substitutions: list[Substituter] def load_and_validate_config(config: dict[str, Any]) -> Config: @@ -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) diff --git a/src/hatch_fancy_pypi_readme/_substitutions.py b/src/hatch_fancy_pypi_readme/_substitutions.py new file mode 100644 index 0000000..291d42e --- /dev/null +++ b/src/hatch_fancy_pypi_readme/_substitutions.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2022 Hynek Schlawack +# +# 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) diff --git a/src/hatch_fancy_pypi_readme/hooks.py b/src/hatch_fancy_pypi_readme/hooks.py index a939507..9162873 100644 --- a/src/hatch_fancy_pypi_readme/hooks.py +++ b/src/hatch_fancy_pypi_readme/hooks.py @@ -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), } diff --git a/tests/example_changelog.md b/tests/example_changelog.md index 40e9775..3169da5 100644 --- a/tests/example_changelog.md +++ b/tests/example_changelog.md @@ -3,6 +3,9 @@ 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? + @@ -10,22 +13,23 @@ Your don't want this as part of your PyPI readme! ### 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 diff --git a/tests/example_pyproject.toml b/tests/example_pyproject.toml index 61d5638..978283b 100644 --- a/tests/example_pyproject.toml +++ b/tests/example_pyproject.toml @@ -40,3 +40,11 @@ pattern = "\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>)' diff --git a/tests/test_builder.py b/tests/test_builder.py index 6fa0f3d..e821469 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -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): @@ -24,5 +24,6 @@ def test_multiple_text_fragment(self): [ TextFragment("# Level 1\n\n"), TextFragment("This is the README!"), - ] + ], + [], ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 22f9d24..b814852 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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. diff --git a/tests/test_config.py b/tests/test_config.py index b6c68d0..77919aa 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_substitutions.py b/tests/test_substitutions.py new file mode 100644 index 0000000..a1cec8c --- /dev/null +++ b/tests/test_substitutions.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2022 Hynek Schlawack +# +# 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 `_ file." # noqa + ) == Substituter.from_config( + { + "pattern": r"`([^`]+)\s+<(?!https?://)([^>]+)>`_", + "replacement": r"`\1 `_", # noqa + "ignore_case": True, + } + ).substitute( + "For information on changes in this release, see the `NEWS `_ file." # noqa + )