Skip to content
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ 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.8.0...HEAD)

### Added

- CLI support for `hatch.toml`.
[#27](https://github.com/hynek/hatch-fancy-pypi-readme/issues/27)


## [22.8.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.7.0...22.8.0) - 2022-10-02

### Added
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ Now *you* too can have fancy PyPI readmes – just by adding a few lines of conf

## Configuration

*hatch-fancy-pypi-readme* is, like [Hatch], configured in your project’s `pyproject.toml`.
*hatch-fancy-pypi-readme* is, like [Hatch], configured in your project’s `pyproject.toml`[^hatch-toml].

[^hatch-toml]: As with Hatch, you can also use `hatch.toml` for configuration options that start with `tool.hatch` and leave that prefix out.
That means `pyprojects.toml`’s `[tool.hatch.metadata.hooks.fancy-pypi-readme]` becomes `[metadata.hooks.fancy-pypi-readme]` when in `hatch.toml`.
To keep the documentation simple, the more common `pyproject.toml` syntax is used throughout.

First you add *hatch-fancy-pypi-readme* to your `[build-system]`:

Expand Down Expand Up @@ -254,14 +258,11 @@ with our [example configuration][example-config], you will get the following out
![rich-cli output](rich-cli-out.svg)

> **Warning**
> While the execution model is somewhat different from the [Hatch]-Python packaging pipeline, it uses the same configuration validator and text renderer, so the fidelity should be high.
>
> - The **CLI** currently doesn’t support `hatch.toml`.
> The **plugin** itself does.
> - While the execution model is somewhat different from the [Hatch]-Python packaging pipeline, it uses the same configuration validator and text renderer, so the fidelity should be high.
>
> It will **not** help you debug **packaging issues**, though.
> It will **not** help you debug **packaging issues**, though.
>
> To verify your PyPI readme using the full packaging pipeline, check out my [*build-and-inspect-python-package*](https://github.com/hynek/build-and-inspect-python-package) GitHub Action.
> To verify your PyPI readme using the full packaging pipeline, check out my [*build-and-inspect-python-package*](https://github.com/hynek/build-and-inspect-python-package) GitHub Action.


## Project Links
Expand Down
31 changes: 27 additions & 4 deletions src/hatch_fancy_pypi_readme/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import sys

from contextlib import closing
from pathlib import Path
from typing import TextIO

from ._cli import cli_run
Expand All @@ -21,7 +22,8 @@

def main() -> None:
parser = argparse.ArgumentParser(
description="Render a README from a pyproject.toml"
description="Render a README from a pyproject.toml & hatch.toml."
" If a hatch.toml is passed / detected, it's preferred."
)
parser.add_argument(
"pyproject_path",
Expand All @@ -31,21 +33,42 @@ def main() -> None:
help="Path to the pyproject.toml to use for rendering. "
"Default: pyproject.toml in current directory.",
)
parser.add_argument(
"--hatch-toml",
nargs="?",
metavar="PATH-TO-HATCH.TOML",
default=None,
help="Path to an additional hatch.toml to use for rendering. "
"Default: Auto-detect in the current directory.",
)
parser.add_argument(
"-o",
help="Target file for output. Default: standard out.",
metavar="TARGET-FILE-PATH",
)
args = parser.parse_args()

with open(args.pyproject_path, "rb") as fp:
cfg = tomllib.load(fp)
pyproject = tomllib.loads(Path(args.pyproject_path).read_text())
hatch_toml = _maybe_load_hatch_toml(args.hatch_toml)

out: TextIO
out = open(args.o, "w") if args.o else sys.stdout # noqa: SIM115

with closing(out):
cli_run(cfg, out)
cli_run(pyproject, hatch_toml, out)


def _maybe_load_hatch_toml(hatch_toml_arg: str | None) -> dict[str, object]:
"""
If hatch.toml is passed or detected, load it.
"""
if hatch_toml_arg:
return tomllib.loads(Path(hatch_toml_arg).read_text())

if Path("hatch.toml").exists():
return tomllib.loads(Path("hatch.toml").read_text())

return {}


if __name__ == "__main__":
Expand Down
37 changes: 29 additions & 8 deletions src/hatch_fancy_pypi_readme/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
from ._config import load_and_validate_config


def cli_run(pyproject: dict[str, Any], out: TextIO) -> None:
def cli_run(
pyproject: dict[str, Any], hatch_toml: dict[str, Any], out: TextIO
) -> None:
"""
Best-effort verify config and print resulting PyPI readme.
"""
Expand All @@ -27,14 +29,33 @@ def cli_run(pyproject: dict[str, Any], out: TextIO) -> None:
_fail("You must add 'readme' to 'project.dynamic'.")

try:
cfg = pyproject["tool"]["hatch"]["metadata"]["hooks"][
"fancy-pypi-readme"
]
if (
pyproject["tool"]["hatch"]["metadata"]["hooks"][
"fancy-pypi-readme"
]
and hatch_toml["metadata"]["hooks"]["fancy-pypi-readme"]
):
_fail(
"Both pyproject.toml and hatch.toml contain "
"hatch-fancy-pypi-readme configuration."
)
except KeyError:
_fail(
"Missing configuration "
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]`)",
)
pass

try:
cfg = hatch_toml["metadata"]["hooks"]["fancy-pypi-readme"]
except KeyError:
try:
cfg = pyproject["tool"]["hatch"]["metadata"]["hooks"][
"fancy-pypi-readme"
]
except KeyError:
_fail(
"Missing configuration "
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in"
" pyproject.toml or `[metadata.hooks.fancy-pypi-readme]`"
" in hatch.toml)",
)

try:
config = load_and_validate_config(cfg)
Expand Down
135 changes: 128 additions & 7 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import pytest

from hatch_fancy_pypi_readme.__main__ import tomllib
from hatch_fancy_pypi_readme.__main__ import _maybe_load_hatch_toml, tomllib
from hatch_fancy_pypi_readme._cli import cli_run

from .utils import run
Expand Down Expand Up @@ -42,7 +42,9 @@ def test_missing_config(self):

assert (
"Missing configuration "
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]`)\n" == out
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in"
" pyproject.toml or `[metadata.hooks.fancy-pypi-readme]` in"
" hatch.toml)\n" == out
)

def test_ok(self):
Expand Down Expand Up @@ -79,14 +81,59 @@ def test_ok_redirect(self, tmp_path):

assert out.read_text().startswith("# Level 1 Header")

def test_empty_explicit_hatch_toml(self, tmp_path):
"""
Explicit empty hatch.toml is ignored.
"""
hatch_toml = tmp_path / "hatch.toml"
hatch_toml.write_text("")

assert run(
"hatch_fancy_pypi_readme",
"tests/example_pyproject.toml",
f"--hatch-toml={hatch_toml.resolve()}",
).startswith("# Level 1 Header")

def test_config_in_hatch_toml(self, tmp_path, monkeypatch):
"""
Implicit empty hatch.toml is used.
"""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"""\
[build-system]
requires = ["hatchling", "hatch-fancy-pypi-readme"]
build-backend = "hatchling.build"

[project]
name = "my-pkg"
version = "1.0"
dynamic = ["readme"]
"""
)
hatch_toml = tmp_path / "hatch.toml"
hatch_toml.write_text(
"""\
[metadata.hooks.fancy-pypi-readme]
content-type = "text/markdown"

[[metadata.hooks.fancy-pypi-readme.fragments]]
text = '# Level 1 Header'
"""
)

monkeypatch.chdir(tmp_path)

assert run("hatch_fancy_pypi_readme").startswith("# Level 1 Header")


class TestCLI:
def test_cli_run_missing_dynamic(self, capfd):
"""
Missing readme in dynamic is caught and gives helpful advice.
"""
with pytest.raises(SystemExit):
cli_run({}, sys.stdout)
cli_run({}, {}, sys.stdout)

out, err = capfd.readouterr()

Expand All @@ -99,14 +146,49 @@ def test_cli_run_missing_config(self, capfd):
"""
with pytest.raises(SystemExit):
cli_run(
{"project": {"dynamic": ["foo", "readme", "bar"]}}, sys.stdout
{"project": {"dynamic": ["foo", "readme", "bar"]}},
{},
sys.stdout,
)

out, err = capfd.readouterr()

assert (
"Missing configuration "
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]`)\n" == err
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in"
" pyproject.toml or `[metadata.hooks.fancy-pypi-readme]` in"
" hatch.toml)\n" == err
)
assert "" == out

def test_cli_run_two_configs(self, capfd):
"""
Ambiguous two configs.
"""
meta = {
"metadata": {
"hooks": {
"fancy-pypi-readme": {"content-type": "text/markdown"}
}
}
}
with pytest.raises(SystemExit):
cli_run(
{
"project": {
"dynamic": ["foo", "readme", "bar"],
},
"tool": {"hatch": meta},
},
meta,
sys.stdout,
)

out, err = capfd.readouterr()

assert (
"Both pyproject.toml and hatch.toml contain "
"hatch-fancy-pypi-readme configuration.\n" == err
)
assert "" == out

Expand All @@ -115,7 +197,7 @@ def test_cli_run_config_error(self, capfd, empty_pyproject):
Configuration errors are detected and give helpful advice.
"""
with pytest.raises(SystemExit):
cli_run(empty_pyproject, sys.stdout)
cli_run(empty_pyproject, {}, sys.stdout)

out, err = capfd.readouterr()

Expand All @@ -134,10 +216,49 @@ def test_cli_run_ok(self, capfd, pyproject):
"""
sio = StringIO()

cli_run(pyproject, sio)
cli_run(pyproject, {}, sio)

out, err = capfd.readouterr()

assert "" == err
assert "" == out
assert sio.getvalue().startswith("# Level 1 Header")


class TestMaybeLoadHatchToml:
def test_none(self, tmp_path, monkeypatch):
"""
If nothing is passed and not hatch.toml is found, return empty dict.
"""
monkeypatch.chdir(tmp_path)

assert {} == _maybe_load_hatch_toml(None)

def test_explicit(self, tmp_path, monkeypatch):
"""
If one is passed, return its parsed content and ignore files called
hatch.toml.
"""
monkeypatch.chdir(tmp_path)

hatch_toml = tmp_path / "hatch.toml"
hatch_toml.write_text("gibberish")

not_hatch_toml = tmp_path / "not-hatch.toml"
not_hatch_toml.write_text("[foo]\nbar='qux'")

assert {"foo": {"bar": "qux"}} == _maybe_load_hatch_toml(
str(not_hatch_toml)
)

def test_implicit(self, tmp_path, monkeypatch):
"""
If none is passed, but a hatch.toml is present in current dir, parse
it.
"""
monkeypatch.chdir(tmp_path)

hatch_toml = tmp_path / "hatch.toml"
hatch_toml.write_text("[foo]\nbar='qux'")

assert {"foo": {"bar": "qux"}} == _maybe_load_hatch_toml(None)