Skip to content

Commit d02c9c8

Browse files
authored
Add support for hatch.toml to CLI (#27)
1 parent 36b2cd0 commit d02c9c8

File tree

5 files changed

+198
-26
lines changed

5 files changed

+198
-26
lines changed

CHANGELOG.md

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

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

15+
### Added
16+
17+
- CLI support for `hatch.toml`.
18+
[#27](https://github.com/hynek/hatch-fancy-pypi-readme/issues/27)
19+
20+
1521
## [22.8.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.7.0...22.8.0) - 2022-10-02
1622

1723
### Added

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ Now *you* too can have fancy PyPI readmes – just by adding a few lines of conf
5757

5858
## Configuration
5959

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

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

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

256260
> **Warning**
261+
> 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.
257262
>
258-
> - The **CLI** currently doesn’t support `hatch.toml`.
259-
> The **plugin** itself does.
260-
> - 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.
261-
>
262-
> It will **not** help you debug **packaging issues**, though.
263+
> It will **not** help you debug **packaging issues**, though.
263264
>
264-
> 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.
265+
> 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.
265266
266267

267268
## Project Links

src/hatch_fancy_pypi_readme/__main__.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import sys
99

1010
from contextlib import closing
11+
from pathlib import Path
1112
from typing import TextIO
1213

1314
from ._cli import cli_run
@@ -21,7 +22,8 @@
2122

2223
def main() -> None:
2324
parser = argparse.ArgumentParser(
24-
description="Render a README from a pyproject.toml"
25+
description="Render a README from a pyproject.toml & hatch.toml."
26+
" If a hatch.toml is passed / detected, it's preferred."
2527
)
2628
parser.add_argument(
2729
"pyproject_path",
@@ -31,21 +33,42 @@ def main() -> None:
3133
help="Path to the pyproject.toml to use for rendering. "
3234
"Default: pyproject.toml in current directory.",
3335
)
36+
parser.add_argument(
37+
"--hatch-toml",
38+
nargs="?",
39+
metavar="PATH-TO-HATCH.TOML",
40+
default=None,
41+
help="Path to an additional hatch.toml to use for rendering. "
42+
"Default: Auto-detect in the current directory.",
43+
)
3444
parser.add_argument(
3545
"-o",
3646
help="Target file for output. Default: standard out.",
3747
metavar="TARGET-FILE-PATH",
3848
)
3949
args = parser.parse_args()
4050

41-
with open(args.pyproject_path, "rb") as fp:
42-
cfg = tomllib.load(fp)
51+
pyproject = tomllib.loads(Path(args.pyproject_path).read_text())
52+
hatch_toml = _maybe_load_hatch_toml(args.hatch_toml)
4353

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

4757
with closing(out):
48-
cli_run(cfg, out)
58+
cli_run(pyproject, hatch_toml, out)
59+
60+
61+
def _maybe_load_hatch_toml(hatch_toml_arg: str | None) -> dict[str, object]:
62+
"""
63+
If hatch.toml is passed or detected, load it.
64+
"""
65+
if hatch_toml_arg:
66+
return tomllib.loads(Path(hatch_toml_arg).read_text())
67+
68+
if Path("hatch.toml").exists():
69+
return tomllib.loads(Path("hatch.toml").read_text())
70+
71+
return {}
4972

5073

5174
if __name__ == "__main__":

src/hatch_fancy_pypi_readme/_cli.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
from ._config import load_and_validate_config
1616

1717

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

2931
try:
30-
cfg = pyproject["tool"]["hatch"]["metadata"]["hooks"][
31-
"fancy-pypi-readme"
32-
]
32+
if (
33+
pyproject["tool"]["hatch"]["metadata"]["hooks"][
34+
"fancy-pypi-readme"
35+
]
36+
and hatch_toml["metadata"]["hooks"]["fancy-pypi-readme"]
37+
):
38+
_fail(
39+
"Both pyproject.toml and hatch.toml contain "
40+
"hatch-fancy-pypi-readme configuration."
41+
)
3342
except KeyError:
34-
_fail(
35-
"Missing configuration "
36-
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]`)",
37-
)
43+
pass
44+
45+
try:
46+
cfg = hatch_toml["metadata"]["hooks"]["fancy-pypi-readme"]
47+
except KeyError:
48+
try:
49+
cfg = pyproject["tool"]["hatch"]["metadata"]["hooks"][
50+
"fancy-pypi-readme"
51+
]
52+
except KeyError:
53+
_fail(
54+
"Missing configuration "
55+
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in"
56+
" pyproject.toml or `[metadata.hooks.fancy-pypi-readme]`"
57+
" in hatch.toml)",
58+
)
3859

3960
try:
4061
config = load_and_validate_config(cfg)

tests/test_cli.py

Lines changed: 128 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import pytest
1111

12-
from hatch_fancy_pypi_readme.__main__ import tomllib
12+
from hatch_fancy_pypi_readme.__main__ import _maybe_load_hatch_toml, tomllib
1313
from hatch_fancy_pypi_readme._cli import cli_run
1414

1515
from .utils import run
@@ -42,7 +42,9 @@ def test_missing_config(self):
4242

4343
assert (
4444
"Missing configuration "
45-
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]`)\n" == out
45+
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in"
46+
" pyproject.toml or `[metadata.hooks.fancy-pypi-readme]` in"
47+
" hatch.toml)\n" == out
4648
)
4749

4850
def test_ok(self):
@@ -79,14 +81,59 @@ def test_ok_redirect(self, tmp_path):
7981

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

84+
def test_empty_explicit_hatch_toml(self, tmp_path):
85+
"""
86+
Explicit empty hatch.toml is ignored.
87+
"""
88+
hatch_toml = tmp_path / "hatch.toml"
89+
hatch_toml.write_text("")
90+
91+
assert run(
92+
"hatch_fancy_pypi_readme",
93+
"tests/example_pyproject.toml",
94+
f"--hatch-toml={hatch_toml.resolve()}",
95+
).startswith("# Level 1 Header")
96+
97+
def test_config_in_hatch_toml(self, tmp_path, monkeypatch):
98+
"""
99+
Implicit empty hatch.toml is used.
100+
"""
101+
pyproject = tmp_path / "pyproject.toml"
102+
pyproject.write_text(
103+
"""\
104+
[build-system]
105+
requires = ["hatchling", "hatch-fancy-pypi-readme"]
106+
build-backend = "hatchling.build"
107+
108+
[project]
109+
name = "my-pkg"
110+
version = "1.0"
111+
dynamic = ["readme"]
112+
"""
113+
)
114+
hatch_toml = tmp_path / "hatch.toml"
115+
hatch_toml.write_text(
116+
"""\
117+
[metadata.hooks.fancy-pypi-readme]
118+
content-type = "text/markdown"
119+
120+
[[metadata.hooks.fancy-pypi-readme.fragments]]
121+
text = '# Level 1 Header'
122+
"""
123+
)
124+
125+
monkeypatch.chdir(tmp_path)
126+
127+
assert run("hatch_fancy_pypi_readme").startswith("# Level 1 Header")
128+
82129

83130
class TestCLI:
84131
def test_cli_run_missing_dynamic(self, capfd):
85132
"""
86133
Missing readme in dynamic is caught and gives helpful advice.
87134
"""
88135
with pytest.raises(SystemExit):
89-
cli_run({}, sys.stdout)
136+
cli_run({}, {}, sys.stdout)
90137

91138
out, err = capfd.readouterr()
92139

@@ -99,14 +146,49 @@ def test_cli_run_missing_config(self, capfd):
99146
"""
100147
with pytest.raises(SystemExit):
101148
cli_run(
102-
{"project": {"dynamic": ["foo", "readme", "bar"]}}, sys.stdout
149+
{"project": {"dynamic": ["foo", "readme", "bar"]}},
150+
{},
151+
sys.stdout,
103152
)
104153

105154
out, err = capfd.readouterr()
106155

107156
assert (
108157
"Missing configuration "
109-
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]`)\n" == err
158+
"(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in"
159+
" pyproject.toml or `[metadata.hooks.fancy-pypi-readme]` in"
160+
" hatch.toml)\n" == err
161+
)
162+
assert "" == out
163+
164+
def test_cli_run_two_configs(self, capfd):
165+
"""
166+
Ambiguous two configs.
167+
"""
168+
meta = {
169+
"metadata": {
170+
"hooks": {
171+
"fancy-pypi-readme": {"content-type": "text/markdown"}
172+
}
173+
}
174+
}
175+
with pytest.raises(SystemExit):
176+
cli_run(
177+
{
178+
"project": {
179+
"dynamic": ["foo", "readme", "bar"],
180+
},
181+
"tool": {"hatch": meta},
182+
},
183+
meta,
184+
sys.stdout,
185+
)
186+
187+
out, err = capfd.readouterr()
188+
189+
assert (
190+
"Both pyproject.toml and hatch.toml contain "
191+
"hatch-fancy-pypi-readme configuration.\n" == err
110192
)
111193
assert "" == out
112194

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

120202
out, err = capfd.readouterr()
121203

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

137-
cli_run(pyproject, sio)
219+
cli_run(pyproject, {}, sio)
138220

139221
out, err = capfd.readouterr()
140222

141223
assert "" == err
142224
assert "" == out
143225
assert sio.getvalue().startswith("# Level 1 Header")
226+
227+
228+
class TestMaybeLoadHatchToml:
229+
def test_none(self, tmp_path, monkeypatch):
230+
"""
231+
If nothing is passed and not hatch.toml is found, return empty dict.
232+
"""
233+
monkeypatch.chdir(tmp_path)
234+
235+
assert {} == _maybe_load_hatch_toml(None)
236+
237+
def test_explicit(self, tmp_path, monkeypatch):
238+
"""
239+
If one is passed, return its parsed content and ignore files called
240+
hatch.toml.
241+
"""
242+
monkeypatch.chdir(tmp_path)
243+
244+
hatch_toml = tmp_path / "hatch.toml"
245+
hatch_toml.write_text("gibberish")
246+
247+
not_hatch_toml = tmp_path / "not-hatch.toml"
248+
not_hatch_toml.write_text("[foo]\nbar='qux'")
249+
250+
assert {"foo": {"bar": "qux"}} == _maybe_load_hatch_toml(
251+
str(not_hatch_toml)
252+
)
253+
254+
def test_implicit(self, tmp_path, monkeypatch):
255+
"""
256+
If none is passed, but a hatch.toml is present in current dir, parse
257+
it.
258+
"""
259+
monkeypatch.chdir(tmp_path)
260+
261+
hatch_toml = tmp_path / "hatch.toml"
262+
hatch_toml.write_text("[foo]\nbar='qux'")
263+
264+
assert {"foo": {"bar": "qux"}} == _maybe_load_hatch_toml(None)

0 commit comments

Comments
 (0)