readwright 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- readwright/__init__.py +8 -0
- readwright/badges.py +323 -0
- readwright/changelog.py +46 -0
- readwright/cli.py +397 -0
- readwright/config.py +230 -0
- readwright/helpers.py +494 -0
- readwright/images.py +217 -0
- readwright/metadata.py +292 -0
- readwright/py.typed +0 -0
- readwright/renderer.py +183 -0
- readwright/templates/base.md.j2 +21 -0
- readwright/templates/partials/badges.md.j2 +1 -0
- readwright/templates/partials/contributing.md.j2 +7 -0
- readwright/templates/partials/donate.md.j2 +1 -0
- readwright/templates/partials/header.md.j2 +10 -0
- readwright/templates/partials/install.md.j2 +46 -0
- readwright/templates/partials/license.md.j2 +5 -0
- readwright/templates/partials/screenshots.md.j2 +5 -0
- readwright/templates/partials/usage.md.j2 +5 -0
- readwright/toc.py +60 -0
- readwright-0.3.0.dist-info/METADATA +188 -0
- readwright-0.3.0.dist-info/RECORD +25 -0
- readwright-0.3.0.dist-info/WHEEL +4 -0
- readwright-0.3.0.dist-info/entry_points.txt +2 -0
- readwright-0.3.0.dist-info/licenses/LICENSE +21 -0
readwright/cli.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""readwright command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
import yaml
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from readwright import __version__
|
|
17
|
+
from readwright.badges import BadgeRegistry
|
|
18
|
+
from readwright.config import (
|
|
19
|
+
DEFAULT_CONFIG_NAME,
|
|
20
|
+
DEFAULT_TEMPLATE,
|
|
21
|
+
Config,
|
|
22
|
+
ProjectInfo,
|
|
23
|
+
config_sources,
|
|
24
|
+
deep_merge,
|
|
25
|
+
load_user_config,
|
|
26
|
+
resolve,
|
|
27
|
+
)
|
|
28
|
+
from readwright.renderer import BASE_TEMPLATE, MARKER_PREFIX, Renderer, RenderResult
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
help="Render GitHub READMEs from Jinja2 templates.",
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
add_completion=False,
|
|
34
|
+
)
|
|
35
|
+
out = Console()
|
|
36
|
+
err = Console(stderr=True)
|
|
37
|
+
|
|
38
|
+
RootOpt = Annotated[Path, typer.Option("--root", "-C", help="Repository root.")]
|
|
39
|
+
ConfigOpt = Annotated[Path | None, typer.Option("--config", "-c", help="Config file path.")]
|
|
40
|
+
UserConfigOpt = Annotated[
|
|
41
|
+
bool,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--user-config", help="Merge ~/.config/readwright/config.yaml under the repo config."
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
StrictOpt = Annotated[bool, typer.Option("--strict", help="Missing screenshots are errors.")]
|
|
47
|
+
VerboseOpt = Annotated[
|
|
48
|
+
bool, typer.Option("--verbose", "-v", help="Show which loader served each template.")
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def version_callback(value: bool) -> None:
|
|
53
|
+
if value:
|
|
54
|
+
out.print(f"readwright {__version__}")
|
|
55
|
+
raise typer.Exit()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.callback()
|
|
59
|
+
def main(
|
|
60
|
+
version: Annotated[
|
|
61
|
+
bool, typer.Option("--version", callback=version_callback, is_eager=True)
|
|
62
|
+
] = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def warn(message: str) -> None:
|
|
68
|
+
err.print("[yellow]warning:[/] ", end="")
|
|
69
|
+
err.print(message, markup=False, highlight=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fail(message: str, code: int = 1) -> None:
|
|
73
|
+
err.print("[red]error:[/] ", end="")
|
|
74
|
+
err.print(message, markup=False, highlight=False)
|
|
75
|
+
raise typer.Exit(code)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load(root: Path, config: Path | None, user_config: bool, strict: bool) -> Config:
|
|
79
|
+
if config is None and len(sources := config_sources(root)) > 1:
|
|
80
|
+
warn(f"both {' and '.join(sources)} exist; using {sources[0]}")
|
|
81
|
+
try:
|
|
82
|
+
cfg = resolve(root, config_path=config, use_user_config=user_config)
|
|
83
|
+
except ValueError as exc:
|
|
84
|
+
fail(str(exc))
|
|
85
|
+
return cfg.model_copy(update={"strict": True}) if strict else cfg
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_with(root: Path, cfg: Config, verbose: bool) -> RenderResult:
|
|
89
|
+
try:
|
|
90
|
+
result = Renderer(root, cfg, warn=warn).render()
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
fail(f"{type(exc).__name__}: {exc}")
|
|
93
|
+
if verbose:
|
|
94
|
+
for name, source in result.sources.items():
|
|
95
|
+
err.print(f"[dim]template {name} <- {source}[/]")
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _render(
|
|
100
|
+
root: Path, config: Path | None, user_config: bool, strict: bool, verbose: bool = False
|
|
101
|
+
) -> tuple[Config, RenderResult]:
|
|
102
|
+
cfg = _load(root, config, user_config, strict)
|
|
103
|
+
return cfg, _render_with(root, cfg, verbose)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_managed(path: Path) -> bool:
|
|
107
|
+
return path.read_text().startswith(MARKER_PREFIX)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _write_output(root: Path, cfg: Config, result: RenderResult, force: bool) -> None:
|
|
111
|
+
target = root / cfg.output
|
|
112
|
+
if target.is_file():
|
|
113
|
+
if target.read_text() == result.text:
|
|
114
|
+
out.print(f"{cfg.output} unchanged")
|
|
115
|
+
return
|
|
116
|
+
if not force and not _is_managed(target):
|
|
117
|
+
fail(
|
|
118
|
+
f"{cfg.output} exists and is not managed by readwright; "
|
|
119
|
+
"use --force to overwrite or `readwright init --from-readme` to adopt it"
|
|
120
|
+
)
|
|
121
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
target.write_text(result.text)
|
|
123
|
+
out.print(f"{cfg.output} updated from {result.template_name}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def watch_paths(root: Path, cfg: Config) -> list[Path]:
|
|
127
|
+
candidates = [
|
|
128
|
+
root / cfg.template,
|
|
129
|
+
root / DEFAULT_CONFIG_NAME,
|
|
130
|
+
root / "pyproject.toml",
|
|
131
|
+
root / "templates",
|
|
132
|
+
root / cfg.screenshots.dir,
|
|
133
|
+
root / "CHANGELOG.md",
|
|
134
|
+
]
|
|
135
|
+
candidates += [root / t for t in cfg.templates if not t.startswith("pkg:")]
|
|
136
|
+
return [p for p in candidates if p.exists()]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.command()
|
|
140
|
+
def render(
|
|
141
|
+
root: RootOpt = Path("."),
|
|
142
|
+
config: ConfigOpt = None,
|
|
143
|
+
user_config: UserConfigOpt = False,
|
|
144
|
+
strict: StrictOpt = False,
|
|
145
|
+
verbose: VerboseOpt = False,
|
|
146
|
+
output: Annotated[
|
|
147
|
+
str | None, typer.Option("--output", "-o", help="Output path, or '-' for stdout.")
|
|
148
|
+
] = None,
|
|
149
|
+
force: Annotated[
|
|
150
|
+
bool, typer.Option("--force", "-f", help="Overwrite an unmanaged existing README.")
|
|
151
|
+
] = False,
|
|
152
|
+
watch: Annotated[
|
|
153
|
+
bool, typer.Option("--watch", "-w", help="Re-render whenever inputs change.")
|
|
154
|
+
] = False,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Render the README template to the output file."""
|
|
157
|
+
cfg = _load(root, config, user_config, strict)
|
|
158
|
+
if output:
|
|
159
|
+
cfg = cfg.model_copy(update={"output": output})
|
|
160
|
+
result = _render_with(root, cfg, verbose)
|
|
161
|
+
if cfg.output == "-":
|
|
162
|
+
sys.stdout.write(result.text)
|
|
163
|
+
return
|
|
164
|
+
_write_output(root, cfg, result, force)
|
|
165
|
+
if watch:
|
|
166
|
+
_watch_loop(root, config, user_config, strict, verbose, output, force)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _watch_loop(
|
|
170
|
+
root: Path,
|
|
171
|
+
config: Path | None,
|
|
172
|
+
user_config: bool,
|
|
173
|
+
strict: bool,
|
|
174
|
+
verbose: bool,
|
|
175
|
+
output: str | None,
|
|
176
|
+
force: bool,
|
|
177
|
+
) -> None:
|
|
178
|
+
from watchfiles import watch as watch_files
|
|
179
|
+
|
|
180
|
+
cfg = _load(root, config, user_config, strict)
|
|
181
|
+
paths = watch_paths(root, cfg)
|
|
182
|
+
err.print(
|
|
183
|
+
f"[dim]watching {', '.join(str(p.relative_to(root)) for p in paths)} (ctrl-c to stop)[/]"
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
for _changes in watch_files(*paths):
|
|
187
|
+
try:
|
|
188
|
+
cfg = _load(root, config, user_config, strict)
|
|
189
|
+
if output:
|
|
190
|
+
cfg = cfg.model_copy(update={"output": output})
|
|
191
|
+
_write_output(root, cfg, _render_with(root, cfg, verbose), force)
|
|
192
|
+
except typer.Exit:
|
|
193
|
+
continue
|
|
194
|
+
except KeyboardInterrupt:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command()
|
|
199
|
+
def check(
|
|
200
|
+
root: RootOpt = Path("."),
|
|
201
|
+
config: ConfigOpt = None,
|
|
202
|
+
user_config: UserConfigOpt = False,
|
|
203
|
+
strict: StrictOpt = False,
|
|
204
|
+
verbose: VerboseOpt = False,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Exit 1 if the output file is out of date with its template."""
|
|
207
|
+
cfg, result = _render(root, config, user_config, strict, verbose)
|
|
208
|
+
for name in result.user_templates:
|
|
209
|
+
warn(f"user-level template '{name}' was used; CI renders will differ")
|
|
210
|
+
target = root / cfg.output
|
|
211
|
+
if not target.is_file():
|
|
212
|
+
fail(f"{cfg.output} does not exist; run `readwright render`")
|
|
213
|
+
current = target.read_text()
|
|
214
|
+
if not current.startswith(MARKER_PREFIX):
|
|
215
|
+
warn(f"{cfg.output} is not managed by readwright (no marker); skipping")
|
|
216
|
+
return
|
|
217
|
+
if current == result.text:
|
|
218
|
+
out.print(f"{cfg.output} is up to date")
|
|
219
|
+
return
|
|
220
|
+
diff = difflib.unified_diff(
|
|
221
|
+
current.splitlines(keepends=True),
|
|
222
|
+
result.text.splitlines(keepends=True),
|
|
223
|
+
fromfile=cfg.output,
|
|
224
|
+
tofile=f"{cfg.output} (rendered)",
|
|
225
|
+
)
|
|
226
|
+
out.print("".join(diff), end="", highlight=False, markup=False)
|
|
227
|
+
fail(f"{cfg.output} is out of date; run `readwright render`")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
INIT_TEMPLATE = """\
|
|
231
|
+
{{% extends "base.md.j2" %}}
|
|
232
|
+
|
|
233
|
+
{{% block usage %}}
|
|
234
|
+
## Usage
|
|
235
|
+
|
|
236
|
+
Describe how to use {name} here.
|
|
237
|
+
{{% endblock %}}
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
ADOPT_TEMPLATE = """\
|
|
241
|
+
{{% extends "base.md.j2" %}}
|
|
242
|
+
|
|
243
|
+
{{# Existing README content, moved here by `readwright init --from-readme`.
|
|
244
|
+
Trim sections now provided by the base template (install, contributing, license),
|
|
245
|
+
remove the raw tags to start using helpers like screenshot() and badge(). #}}
|
|
246
|
+
{{% block usage %}}
|
|
247
|
+
{open}{body}{close}
|
|
248
|
+
{{% endblock %}}
|
|
249
|
+
|
|
250
|
+
{{% block screenshots %}}{{% endblock %}}
|
|
251
|
+
{{% block install %}}{{% endblock %}}
|
|
252
|
+
{{% block contributing %}}{{% endblock %}}
|
|
253
|
+
{{% block license %}}{{% endblock %}}
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
CONFIG_HEADER = """\
|
|
257
|
+
# readwright configuration. All keys are optional; autodetected values are shown.
|
|
258
|
+
# Keys: template, templates, output, strict, allow_exec, screenshots{dir,width,style}, badges,
|
|
259
|
+
# badges_style, badges_custom, donate, donate_handles, related, project{...}, vars.
|
|
260
|
+
# Run `readwright badges` / `readwright blocks` to list badge presets and template blocks.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
H1 = re.compile(r"^#\s+.+\n+", re.MULTILINE)
|
|
264
|
+
JINJA_SYNTAX = re.compile(r"{[{%#]")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _init_config_data(root: Path) -> dict:
|
|
268
|
+
cfg = resolve(root)
|
|
269
|
+
data = cfg.model_dump(exclude_defaults=True, exclude_none=True)
|
|
270
|
+
if (user := load_user_config()) is not None:
|
|
271
|
+
data = deep_merge(user.model_dump(exclude_defaults=True, exclude_none=True), data)
|
|
272
|
+
data.setdefault("badges", [])
|
|
273
|
+
project = data.setdefault("project", {})
|
|
274
|
+
for key in ("python_versions", "project_type", "version"):
|
|
275
|
+
project.pop(key, None)
|
|
276
|
+
return data
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _adopted_body(readme: Path) -> str:
|
|
280
|
+
body = H1.sub("", readme.read_text(), count=1).strip("\n") + "\n"
|
|
281
|
+
if JINJA_SYNTAX.search(body):
|
|
282
|
+
return ADOPT_TEMPLATE.format(open="{% raw %}\n", body=body, close="{% endraw +%}\n")
|
|
283
|
+
return ADOPT_TEMPLATE.format(open="", body=body, close="")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _write_pyproject_config(root: Path, data: dict) -> None:
|
|
287
|
+
import tomli_w
|
|
288
|
+
|
|
289
|
+
path = root / "pyproject.toml"
|
|
290
|
+
text = path.read_text() if path.is_file() else ""
|
|
291
|
+
if "[tool.readme]" in text:
|
|
292
|
+
fail("pyproject.toml already has a [tool.readme] section; refusing to overwrite")
|
|
293
|
+
section = tomli_w.dumps({"tool": {"readme": data}})
|
|
294
|
+
path.write_text(text.rstrip("\n") + "\n\n" + section if text else section)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command()
|
|
298
|
+
def init(
|
|
299
|
+
root: RootOpt = Path("."),
|
|
300
|
+
from_readme: Annotated[
|
|
301
|
+
bool,
|
|
302
|
+
typer.Option("--from-readme", help="Move an existing README.md body into the template."),
|
|
303
|
+
] = False,
|
|
304
|
+
pyproject: Annotated[
|
|
305
|
+
bool,
|
|
306
|
+
typer.Option("--pyproject", help="Write config to [tool.readme] instead of readme.yaml."),
|
|
307
|
+
] = False,
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Scaffold readme.yaml and README.md.j2 in the repository."""
|
|
310
|
+
config_path = root / DEFAULT_CONFIG_NAME
|
|
311
|
+
template_path = root / DEFAULT_TEMPLATE
|
|
312
|
+
existing = [
|
|
313
|
+
p for p in ((template_path,) if pyproject else (config_path, template_path)) if p.exists()
|
|
314
|
+
]
|
|
315
|
+
if existing:
|
|
316
|
+
fail(f"{existing[0].name} already exists; refusing to overwrite")
|
|
317
|
+
if pyproject and config_path.exists():
|
|
318
|
+
fail(f"{DEFAULT_CONFIG_NAME} already exists; refusing to overwrite")
|
|
319
|
+
data = _init_config_data(root)
|
|
320
|
+
name = data.get("project", {}).get("name") or root.resolve().name
|
|
321
|
+
readme = root / "README.md"
|
|
322
|
+
if from_readme:
|
|
323
|
+
if not readme.is_file():
|
|
324
|
+
fail("--from-readme given but README.md does not exist")
|
|
325
|
+
if _is_managed(readme):
|
|
326
|
+
fail("README.md is already managed by readwright")
|
|
327
|
+
template_path.write_text(_adopted_body(readme))
|
|
328
|
+
else:
|
|
329
|
+
template_path.write_text(INIT_TEMPLATE.format(name=name))
|
|
330
|
+
if pyproject:
|
|
331
|
+
_write_pyproject_config(root, data)
|
|
332
|
+
config_name = "pyproject.toml [tool.readme]"
|
|
333
|
+
else:
|
|
334
|
+
config_path.write_text(CONFIG_HEADER + yaml.safe_dump(data, sort_keys=False))
|
|
335
|
+
config_name = DEFAULT_CONFIG_NAME
|
|
336
|
+
if from_readme:
|
|
337
|
+
cfg = _load(root, None, False, False)
|
|
338
|
+
_write_output(root, cfg, _render_with(root, cfg, False), force=True)
|
|
339
|
+
out.print(f"created {config_name} and {DEFAULT_TEMPLATE}; README.md is now managed")
|
|
340
|
+
return
|
|
341
|
+
out.print(f"created {config_name} and {DEFAULT_TEMPLATE}; run `readwright render`")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@app.command()
|
|
345
|
+
def badges(root: RootOpt = Path("."), config: ConfigOpt = None) -> None:
|
|
346
|
+
"""List available badge presets."""
|
|
347
|
+
try:
|
|
348
|
+
cfg = resolve(root, config_path=config)
|
|
349
|
+
except ValueError:
|
|
350
|
+
cfg = Config(project=ProjectInfo())
|
|
351
|
+
registry = BadgeRegistry(cfg)
|
|
352
|
+
table = Table(show_lines=False)
|
|
353
|
+
table.add_column("preset", no_wrap=True)
|
|
354
|
+
table.add_column("example", overflow="fold")
|
|
355
|
+
for name in registry.names():
|
|
356
|
+
try:
|
|
357
|
+
example = registry.render(name)
|
|
358
|
+
except ValueError as exc:
|
|
359
|
+
example = f"[dim]{exc}[/]"
|
|
360
|
+
table.add_row(name, example)
|
|
361
|
+
out.print(table)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@app.command()
|
|
365
|
+
def blocks(root: RootOpt = Path("."), config: ConfigOpt = None) -> None:
|
|
366
|
+
"""List the blocks of base.md.j2 and the partials that can be shadowed."""
|
|
367
|
+
cfg = _load(root, config, False, False)
|
|
368
|
+
renderer = Renderer(root, cfg)
|
|
369
|
+
template = renderer.env.get_template(BASE_TEMPLATE)
|
|
370
|
+
out.print(f"[bold]blocks in {BASE_TEMPLATE}[/]")
|
|
371
|
+
for name in template.blocks:
|
|
372
|
+
out.print(f" {name}")
|
|
373
|
+
out.print("[bold]partials[/] (shadow with templates/partials/<name> in the repo)")
|
|
374
|
+
for name in sorted(renderer.env.list_templates()):
|
|
375
|
+
if name.startswith("partials/"):
|
|
376
|
+
out.print(f" {name} [dim]<- {renderer.source_label(name)}[/]")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@app.command()
|
|
380
|
+
def show(
|
|
381
|
+
name: Annotated[str, typer.Argument(help="Template name, e.g. base.md.j2")],
|
|
382
|
+
root: RootOpt = Path("."),
|
|
383
|
+
config: ConfigOpt = None,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Print a template's source (useful for copying a partial to override it)."""
|
|
386
|
+
cfg = _load(root, config, False, False)
|
|
387
|
+
renderer = Renderer(root, cfg)
|
|
388
|
+
try:
|
|
389
|
+
source, _, _ = renderer.env.loader.get_source(renderer.env, name)
|
|
390
|
+
except Exception:
|
|
391
|
+
fail(f"template '{name}' not found")
|
|
392
|
+
err.print(f"[dim]# {name} <- {renderer.source_label(name)}[/]")
|
|
393
|
+
sys.stdout.write(source)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
if __name__ == "__main__":
|
|
397
|
+
app()
|
readwright/config.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Configuration models and loading (readme.yaml, [tool.readme], user-level config)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator
|
|
12
|
+
|
|
13
|
+
from readwright.metadata import detect
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG_NAME = "readme.yaml"
|
|
16
|
+
DEFAULT_TEMPLATE = "README.md.j2"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StrictModel(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProjectInfo(StrictModel):
|
|
24
|
+
name: str | None = None
|
|
25
|
+
owner: str | None = None
|
|
26
|
+
repo: str | None = None
|
|
27
|
+
tagline: str | None = None
|
|
28
|
+
version: str | None = None
|
|
29
|
+
pypi: str | None = None
|
|
30
|
+
npm: str | None = None
|
|
31
|
+
crate: str | None = None
|
|
32
|
+
go_module: str | None = None
|
|
33
|
+
nuget: str | None = None
|
|
34
|
+
mod_id: str | None = None
|
|
35
|
+
minecraft_version: str | None = None
|
|
36
|
+
modrinth: str | None = None
|
|
37
|
+
curseforge: str | None = None
|
|
38
|
+
ha_min_version: str | None = None
|
|
39
|
+
flow_plugin: str | None = None
|
|
40
|
+
license: str | None = None
|
|
41
|
+
ci_workflow: str | None = None
|
|
42
|
+
python_versions: list[str] = Field(default_factory=list)
|
|
43
|
+
project_type: str = "generic"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def url(self) -> str | None:
|
|
47
|
+
if self.owner and self.repo:
|
|
48
|
+
return f"https://github.com/{self.owner}/{self.repo}"
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ScreenshotsConfig(StrictModel):
|
|
53
|
+
dir: str = "docs/screenshots"
|
|
54
|
+
width: int | None = 720
|
|
55
|
+
style: Literal["markdown", "html"] = "markdown"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CustomBadge(StrictModel):
|
|
59
|
+
label: str
|
|
60
|
+
message: str
|
|
61
|
+
color: str = "blue"
|
|
62
|
+
link: str | None = None
|
|
63
|
+
logo: str | None = None
|
|
64
|
+
style: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BadgeSpec(StrictModel):
|
|
68
|
+
preset: str | None = None
|
|
69
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
shield: CustomBadge | None = None
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def coerce(cls, value: Any) -> Any:
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
return {"preset": value}
|
|
77
|
+
if isinstance(value, dict) and "shield" in value and "preset" not in value:
|
|
78
|
+
return {"shield": value["shield"]}
|
|
79
|
+
if isinstance(value, dict) and "options" not in value and "shield" not in value:
|
|
80
|
+
value = dict(value)
|
|
81
|
+
preset = value.pop("preset", None)
|
|
82
|
+
if preset is None:
|
|
83
|
+
raise ValueError("badge entry needs 'preset' or 'shield'")
|
|
84
|
+
return {"preset": preset, "options": value}
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
@model_serializer
|
|
88
|
+
def compact(self) -> Any:
|
|
89
|
+
if self.shield is not None:
|
|
90
|
+
return {"shield": self.shield.model_dump(exclude_defaults=True, exclude_none=True)}
|
|
91
|
+
if self.options:
|
|
92
|
+
return {"preset": self.preset, **self.options}
|
|
93
|
+
return self.preset
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class BannerConfig(StrictModel):
|
|
97
|
+
unsplash: str | None = None
|
|
98
|
+
image: str | None = None
|
|
99
|
+
alt: str | None = None
|
|
100
|
+
width: int | None = 1200
|
|
101
|
+
height: int | None = None
|
|
102
|
+
credit: str | None = None
|
|
103
|
+
user: str | None = None
|
|
104
|
+
photo_id: str | None = None
|
|
105
|
+
link: str | None = None
|
|
106
|
+
html: bool = False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RelatedRepo(StrictModel):
|
|
110
|
+
repo: str
|
|
111
|
+
description: str = ""
|
|
112
|
+
url: str | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Config(StrictModel):
|
|
116
|
+
template: str = DEFAULT_TEMPLATE
|
|
117
|
+
templates: list[str] = Field(default_factory=list)
|
|
118
|
+
output: str = "README.md"
|
|
119
|
+
strict: bool = False
|
|
120
|
+
allow_exec: bool = False
|
|
121
|
+
badges_style: str | None = None
|
|
122
|
+
related: list[RelatedRepo] = Field(default_factory=list)
|
|
123
|
+
banner: BannerConfig | None = None
|
|
124
|
+
screenshots: ScreenshotsConfig = Field(default_factory=ScreenshotsConfig)
|
|
125
|
+
badges: list[BadgeSpec] = Field(default_factory=list)
|
|
126
|
+
badges_custom: dict[str, CustomBadge] = Field(default_factory=dict)
|
|
127
|
+
donate: list[BadgeSpec] = Field(default_factory=list)
|
|
128
|
+
donate_handles: dict[str, str | None] = Field(default_factory=dict)
|
|
129
|
+
project: ProjectInfo = Field(default_factory=ProjectInfo)
|
|
130
|
+
vars: dict[str, Any] = Field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
134
|
+
merged = dict(base)
|
|
135
|
+
for key, value in override.items():
|
|
136
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
137
|
+
merged[key] = deep_merge(merged[key], value)
|
|
138
|
+
else:
|
|
139
|
+
merged[key] = value
|
|
140
|
+
return merged
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate(data: dict[str, Any], source: str) -> Config:
|
|
144
|
+
try:
|
|
145
|
+
return Config.model_validate(data)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
raise ValueError(f"invalid config in {source}: {exc}") from exc
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _read_yaml(path: Path) -> dict[str, Any]:
|
|
151
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
raise ValueError(f"invalid config in {path}: expected a mapping")
|
|
154
|
+
return data
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _read_tool_readme(root: Path) -> dict[str, Any] | None:
|
|
158
|
+
path = root / "pyproject.toml"
|
|
159
|
+
if not path.is_file():
|
|
160
|
+
return None
|
|
161
|
+
data = tomllib.loads(path.read_text())
|
|
162
|
+
section = data.get("tool", {}).get("readme")
|
|
163
|
+
return dict(section) if isinstance(section, dict) else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def find_config_path(root: Path) -> Path | None:
|
|
167
|
+
path = root / DEFAULT_CONFIG_NAME
|
|
168
|
+
return path if path.is_file() else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def config_sources(root: Path) -> list[str]:
|
|
172
|
+
found = []
|
|
173
|
+
if find_config_path(root) is not None:
|
|
174
|
+
found.append(DEFAULT_CONFIG_NAME)
|
|
175
|
+
if _read_tool_readme(root) is not None:
|
|
176
|
+
found.append("pyproject.toml [tool.readme]")
|
|
177
|
+
return found
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_config_data(root: Path, config_path: Path | None = None) -> tuple[dict[str, Any], str]:
|
|
181
|
+
if config_path is not None:
|
|
182
|
+
return _read_yaml(config_path), str(config_path)
|
|
183
|
+
if (path := find_config_path(root)) is not None:
|
|
184
|
+
return _read_yaml(path), str(path)
|
|
185
|
+
if (section := _read_tool_readme(root)) is not None:
|
|
186
|
+
return section, str(root / "pyproject.toml [tool.readme]")
|
|
187
|
+
return {}, "<defaults>"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_config(root: Path, config_path: Path | None = None) -> Config:
|
|
191
|
+
data, source = load_config_data(root, config_path)
|
|
192
|
+
return _validate(data, source)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def user_config_path() -> Path:
|
|
196
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
197
|
+
return Path(base) / "readwright" / "config.yaml"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def user_templates_dir() -> Path:
|
|
201
|
+
return user_config_path().parent / "templates"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def load_user_config() -> Config | None:
|
|
205
|
+
path = user_config_path()
|
|
206
|
+
if not path.is_file():
|
|
207
|
+
return None
|
|
208
|
+
return _validate(_read_yaml(path), str(path))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _dump(model: BaseModel) -> dict[str, Any]:
|
|
212
|
+
return model.model_dump(exclude_defaults=True, exclude_none=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def resolve(
|
|
216
|
+
root: Path,
|
|
217
|
+
config_path: Path | None = None,
|
|
218
|
+
use_user_config: bool = False,
|
|
219
|
+
) -> Config:
|
|
220
|
+
detected = detect(root)
|
|
221
|
+
layers: list[dict[str, Any]] = [{"project": _dump(ProjectInfo(**detected.__dict__))}]
|
|
222
|
+
if use_user_config and (user := load_user_config()) is not None:
|
|
223
|
+
layers.append(_dump(user))
|
|
224
|
+
repo_data, source = load_config_data(root, config_path)
|
|
225
|
+
_validate(repo_data, source)
|
|
226
|
+
layers.append(repo_data)
|
|
227
|
+
merged: dict[str, Any] = {}
|
|
228
|
+
for layer in layers:
|
|
229
|
+
merged = deep_merge(merged, layer)
|
|
230
|
+
return _validate(merged, source)
|