Skip to content

Commit

Permalink
Add support for hatch.toml to CLI
Browse files Browse the repository at this point in the history
  • Loading branch information
hynek committed May 19, 2023
1 parent 40e9ff0 commit 9b7d805
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 26 deletions.
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.
<!-- end docs -->

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)

0 comments on commit 9b7d805

Please sign in to comment.