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/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)