zenith-template 0.2.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.
Files changed (92) hide show
  1. zenith/__init__.py +6 -0
  2. zenith/cli.py +439 -0
  3. zenith/generator.py +109 -0
  4. zenith/models.py +178 -0
  5. zenith/template/cookiecutter.json +71 -0
  6. zenith/template/hooks/__init__.py +1 -0
  7. zenith/template/hooks/post_gen_project.py +470 -0
  8. zenith/template/hooks/pre_gen_project.py +117 -0
  9. zenith/template/{{cookiecutter.project_slug}}/.aider.conf.yml +16 -0
  10. zenith/template/{{cookiecutter.project_slug}}/.claude/settings.json +37 -0
  11. zenith/template/{{cookiecutter.project_slug}}/.codex/config.toml +11 -0
  12. zenith/template/{{cookiecutter.project_slug}}/.coveragerc +50 -0
  13. zenith/template/{{cookiecutter.project_slug}}/.cursor/rules/zenith.mdc +9 -0
  14. zenith/template/{{cookiecutter.project_slug}}/.devin/config.json +16 -0
  15. zenith/template/{{cookiecutter.project_slug}}/.editorconfig +31 -0
  16. zenith/template/{{cookiecutter.project_slug}}/.gemini/settings.json +3 -0
  17. zenith/template/{{cookiecutter.project_slug}}/.gitattributes +25 -0
  18. zenith/template/{{cookiecutter.project_slug}}/.github/ISSUE_TEMPLATE/bug_report.yml +56 -0
  19. zenith/template/{{cookiecutter.project_slug}}/.github/ISSUE_TEMPLATE/config.yml +5 -0
  20. zenith/template/{{cookiecutter.project_slug}}/.github/ISSUE_TEMPLATE/feature_request.yml +39 -0
  21. zenith/template/{{cookiecutter.project_slug}}/.github/PULL_REQUEST_TEMPLATE.md +38 -0
  22. zenith/template/{{cookiecutter.project_slug}}/.github/copilot-instructions.md +4 -0
  23. zenith/template/{{cookiecutter.project_slug}}/.github/dependabot.yml +31 -0
  24. zenith/template/{{cookiecutter.project_slug}}/.github/workflows/dependabot-automerge.yaml +32 -0
  25. zenith/template/{{cookiecutter.project_slug}}/.github/workflows/docs.yaml +56 -0
  26. zenith/template/{{cookiecutter.project_slug}}/.github/workflows/guard-dependencies.yaml +61 -0
  27. zenith/template/{{cookiecutter.project_slug}}/.github/workflows/publish.yaml +101 -0
  28. zenith/template/{{cookiecutter.project_slug}}/.github/workflows/python_tests.yaml +199 -0
  29. zenith/template/{{cookiecutter.project_slug}}/.github/workflows/test_redistribute.yaml +53 -0
  30. zenith/template/{{cookiecutter.project_slug}}/.gitignore +87 -0
  31. zenith/template/{{cookiecutter.project_slug}}/.pi/settings.json +8 -0
  32. zenith/template/{{cookiecutter.project_slug}}/.prettierrc.yaml +16 -0
  33. zenith/template/{{cookiecutter.project_slug}}/.python-version +1 -0
  34. zenith/template/{{cookiecutter.project_slug}}/.taplo.toml +26 -0
  35. zenith/template/{{cookiecutter.project_slug}}/.vscode/extensions.json +8 -0
  36. zenith/template/{{cookiecutter.project_slug}}/.zed/settings.json +18 -0
  37. zenith/template/{{cookiecutter.project_slug}}/.zed/tasks.json +14 -0
  38. zenith/template/{{cookiecutter.project_slug}}/.zizmor.yml +12 -0
  39. zenith/template/{{cookiecutter.project_slug}}/AGENTS.md +180 -0
  40. zenith/template/{{cookiecutter.project_slug}}/AI_POLICY.md +75 -0
  41. zenith/template/{{cookiecutter.project_slug}}/CODE_OF_CONDUCT.md +125 -0
  42. zenith/template/{{cookiecutter.project_slug}}/CONTRIBUTING.md +87 -0
  43. zenith/template/{{cookiecutter.project_slug}}/LICENSE +7 -0
  44. zenith/template/{{cookiecutter.project_slug}}/README.md +224 -0
  45. zenith/template/{{cookiecutter.project_slug}}/SECURITY.md +66 -0
  46. zenith/template/{{cookiecutter.project_slug}}/complexipy.toml +20 -0
  47. zenith/template/{{cookiecutter.project_slug}}/cosmic-ray.toml +17 -0
  48. zenith/template/{{cookiecutter.project_slug}}/docs/adr/0001-record-architecture-decisions.md +88 -0
  49. zenith/template/{{cookiecutter.project_slug}}/docs/adr/index.md +42 -0
  50. zenith/template/{{cookiecutter.project_slug}}/docs/adr/template.md +66 -0
  51. zenith/template/{{cookiecutter.project_slug}}/docs/assets/python-logo.svg +1 -0
  52. zenith/template/{{cookiecutter.project_slug}}/docs/assets/python-logo.typ +11 -0
  53. zenith/template/{{cookiecutter.project_slug}}/docs/changelog.md +20 -0
  54. zenith/template/{{cookiecutter.project_slug}}/docs/getting-started.md +168 -0
  55. zenith/template/{{cookiecutter.project_slug}}/docs/index.md +157 -0
  56. zenith/template/{{cookiecutter.project_slug}}/docs/javascripts/mathjax.js +19 -0
  57. zenith/template/{{cookiecutter.project_slug}}/docs/reference.md +37 -0
  58. zenith/template/{{cookiecutter.project_slug}}/docs/stylesheets/custom.css +145 -0
  59. zenith/template/{{cookiecutter.project_slug}}/docs/stylesheets/palette.css +64 -0
  60. zenith/template/{{cookiecutter.project_slug}}/opencode.json +24 -0
  61. zenith/template/{{cookiecutter.project_slug}}/poe_tasks.toml +435 -0
  62. zenith/template/{{cookiecutter.project_slug}}/prek.toml +135 -0
  63. zenith/template/{{cookiecutter.project_slug}}/pyproject.toml +132 -0
  64. zenith/template/{{cookiecutter.project_slug}}/pyrefly.toml +16 -0
  65. zenith/template/{{cookiecutter.project_slug}}/pytest.ini +35 -0
  66. zenith/template/{{cookiecutter.project_slug}}/ruff.toml +65 -0
  67. zenith/template/{{cookiecutter.project_slug}}/rumdl.toml +170 -0
  68. zenith/template/{{cookiecutter.project_slug}}/scripts/check_skills.py +362 -0
  69. zenith/template/{{cookiecutter.project_slug}}/scripts/verify_wheel.py +644 -0
  70. zenith/template/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/.agents/skills/{{cookiecutter.project_slug}}/SKILL.md +27 -0
  71. zenith/template/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py +24 -0
  72. zenith/template/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__main__.py +10 -0
  73. zenith/template/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/_lazy.py +91 -0
  74. zenith/template/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli.py +72 -0
  75. zenith/template/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/core.py +28 -0
  76. zenith/template/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/py.typed +1 -0
  77. zenith/template/{{cookiecutter.project_slug}}/tach.toml +53 -0
  78. zenith/template/{{cookiecutter.project_slug}}/tests/__init__.py +3 -0
  79. zenith/template/{{cookiecutter.project_slug}}/tests/__snapshots__/test_core.ambr +4 -0
  80. zenith/template/{{cookiecutter.project_slug}}/tests/conftest.py +34 -0
  81. zenith/template/{{cookiecutter.project_slug}}/tests/test_cli.py +99 -0
  82. zenith/template/{{cookiecutter.project_slug}}/tests/test_core.py +40 -0
  83. zenith/template/{{cookiecutter.project_slug}}/typos.toml +22 -0
  84. zenith/template/{{cookiecutter.project_slug}}/zensical.toml +188 -0
  85. zenith/template.py +64 -0
  86. zenith/ui/__init__.py +3 -0
  87. zenith/ui/app.py +629 -0
  88. zenith_template-0.2.0.dist-info/METADATA +140 -0
  89. zenith_template-0.2.0.dist-info/RECORD +92 -0
  90. zenith_template-0.2.0.dist-info/WHEEL +4 -0
  91. zenith_template-0.2.0.dist-info/entry_points.txt +3 -0
  92. zenith_template-0.2.0.dist-info/licenses/LICENSE +21 -0
zenith/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Zenith: An opinionated template and interactive project generator for Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
6
+ __all__ = ["__version__"]
zenith/cli.py ADDED
@@ -0,0 +1,439 @@
1
+ """Command-line interface entry points for Zenith."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shlex
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from rich import box
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+
15
+ from zenith import __version__
16
+ from zenith.generator import generate_project
17
+ from zenith.models import (
18
+ AI_TOOL_PRESETS,
19
+ PRESET_RECOMMENDED,
20
+ SUPPORTED_LICENSES,
21
+ SUPPORTED_PYTHON_VERSIONS,
22
+ ZenithConfig,
23
+ slugify,
24
+ )
25
+ from zenith.ui.app import ZenithApp
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Sequence
29
+
30
+ console = Console()
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ """Build the command-line argument parser for Zenith.
35
+
36
+ Returns:
37
+ Configured ArgumentParser instance.
38
+
39
+ """
40
+ parser = argparse.ArgumentParser(
41
+ prog="zenith-template",
42
+ description="Scaffold modern Python 3.12+ projects with Zenith.",
43
+ )
44
+ parser.add_argument(
45
+ "output_dir",
46
+ nargs="?",
47
+ default=".",
48
+ help="Directory where the new project folder will be created (default: .)",
49
+ )
50
+ parser.add_argument(
51
+ "--output-dir",
52
+ dest="output_dir_opt",
53
+ default=None,
54
+ help="Directory where the new project folder will be created.",
55
+ )
56
+ parser.add_argument(
57
+ "-v",
58
+ "--version",
59
+ action="version",
60
+ version=f"%(prog)s {__version__}",
61
+ help="Show program version number and exit.",
62
+ )
63
+ parser.add_argument(
64
+ "--no-input",
65
+ action="store_true",
66
+ help="Do not prompt for parameters; use configuration defaults or flags.",
67
+ )
68
+ parser.add_argument(
69
+ "--overwrite",
70
+ action="store_true",
71
+ help="Overwrite destination directory contents if it already exists.",
72
+ )
73
+ parser.add_argument(
74
+ "--name",
75
+ dest="project_name",
76
+ default=None,
77
+ help="Project Name (human-readable title).",
78
+ )
79
+ parser.add_argument(
80
+ "--slug",
81
+ dest="project_slug",
82
+ default=None,
83
+ help="Project Slug (directory and package name).",
84
+ )
85
+ parser.add_argument(
86
+ "--package",
87
+ dest="package_name",
88
+ default=None,
89
+ help="Python Package Name (import identifier).",
90
+ )
91
+ parser.add_argument(
92
+ "--description",
93
+ dest="description",
94
+ default=None,
95
+ help="Short project description.",
96
+ )
97
+ parser.add_argument(
98
+ "--author",
99
+ dest="author_name",
100
+ default=None,
101
+ help="Author full name.",
102
+ )
103
+ parser.add_argument(
104
+ "--email",
105
+ dest="author_email",
106
+ default=None,
107
+ help="Author email address.",
108
+ )
109
+ parser.add_argument(
110
+ "--github",
111
+ dest="github_username",
112
+ default=None,
113
+ help="GitHub username or organization.",
114
+ )
115
+ parser.add_argument(
116
+ "--python-version",
117
+ choices=SUPPORTED_PYTHON_VERSIONS,
118
+ default="3.12",
119
+ help="Target Python version (default: 3.12).",
120
+ )
121
+ parser.add_argument(
122
+ "--license",
123
+ choices=SUPPORTED_LICENSES,
124
+ default="MIT",
125
+ help="Open source license identifier (default: MIT).",
126
+ )
127
+ parser.add_argument(
128
+ "--ai-tools",
129
+ dest="ai_tools",
130
+ default=None,
131
+ help=(
132
+ "AI tool preset name (all, recommended, ide-only, cli-only, none)"
133
+ " or comma-separated tool names."
134
+ ),
135
+ )
136
+ parser.add_argument(
137
+ "--include-cli",
138
+ "--cli",
139
+ dest="include_cli",
140
+ action=argparse.BooleanOptionalAction,
141
+ default=True,
142
+ help="Generate Typer command-line interface.",
143
+ )
144
+ parser.add_argument(
145
+ "--include-docs",
146
+ "--docs",
147
+ dest="include_docs",
148
+ action=argparse.BooleanOptionalAction,
149
+ default=True,
150
+ help="Include documentation website (docs/ & zensical.toml).",
151
+ )
152
+ parser.add_argument(
153
+ "--include-mutation",
154
+ "--mutation",
155
+ dest="include_mutation_testing",
156
+ action=argparse.BooleanOptionalAction,
157
+ default=True,
158
+ help="Include mutation testing tooling (cosmic-ray & mutmut).",
159
+ )
160
+ parser.add_argument(
161
+ "--include-ci",
162
+ "--ci",
163
+ dest="include_ci",
164
+ action=argparse.BooleanOptionalAction,
165
+ default=True,
166
+ help="Generate GitHub Actions workflows for testing and publishing.",
167
+ )
168
+ parser.add_argument(
169
+ "--include-skills",
170
+ "--skills",
171
+ dest="include_skills",
172
+ action=argparse.BooleanOptionalAction,
173
+ default=True,
174
+ help="Include agent skills suite (.agents/).",
175
+ )
176
+ parser.add_argument(
177
+ "--init-git",
178
+ "--git",
179
+ dest="init_git",
180
+ action=argparse.BooleanOptionalAction,
181
+ default=True,
182
+ help="Initialize Git repository in scaffolded project.",
183
+ )
184
+ parser.add_argument(
185
+ "--run-uv-sync",
186
+ "--sync",
187
+ dest="run_uv_sync",
188
+ action=argparse.BooleanOptionalAction,
189
+ default=False,
190
+ help="Run uv sync after scaffolding.",
191
+ )
192
+ parser.add_argument(
193
+ "--install-git-hooks",
194
+ "--hooks",
195
+ dest="install_git_hooks",
196
+ action=argparse.BooleanOptionalAction,
197
+ default=False,
198
+ help="Install Git hooks (prek install) after scaffolding.",
199
+ )
200
+ return parser
201
+
202
+
203
+ def _resolve_ai_tools(raw: str | None) -> tuple[str, ...]:
204
+ """Parse raw string or preset into a tuple of AI tool identifiers.
205
+
206
+ Args:
207
+ raw: Raw preset name or comma-separated tool keys.
208
+
209
+ Returns:
210
+ Tuple of active tool identifiers.
211
+
212
+ """
213
+ if raw is None:
214
+ return PRESET_RECOMMENDED
215
+
216
+ normalized = raw.strip().lower()
217
+ if normalized in AI_TOOL_PRESETS:
218
+ return AI_TOOL_PRESETS[normalized]
219
+
220
+ items = [t.strip() for t in normalized.replace(";", ",").split(",") if t.strip()]
221
+ return tuple(items)
222
+
223
+
224
+ def config_from_args(args: argparse.Namespace) -> ZenithConfig:
225
+ """Build a ZenithConfig dataclass instance from parsed command-line arguments.
226
+
227
+ Args:
228
+ args: Parsed command-line namespace.
229
+
230
+ Returns:
231
+ Populated ZenithConfig instance.
232
+
233
+ """
234
+ defaults = ZenithConfig()
235
+ raw_dir = getattr(args, "output_dir_opt", None) or getattr(
236
+ args,
237
+ "output_dir",
238
+ None,
239
+ )
240
+ output_dir = raw_dir if raw_dir and raw_dir != "." else defaults.output_dir
241
+ name = args.project_name or defaults.project_name
242
+ fallback_slug = slugify(name) if args.project_name else defaults.project_slug
243
+ slug = args.project_slug or fallback_slug
244
+ fallback_pkg = (
245
+ slugify(name, separator="_") if args.project_name else defaults.package_name
246
+ )
247
+ package = args.package_name or fallback_pkg
248
+
249
+ return ZenithConfig(
250
+ output_dir=output_dir,
251
+ project_name=name,
252
+ project_slug=slug,
253
+ package_name=package,
254
+ description=args.description or defaults.description,
255
+ author_name=args.author_name or defaults.author_name,
256
+ author_email=args.author_email or defaults.author_email,
257
+ github_username=args.github_username or defaults.github_username,
258
+ python_version=args.python_version,
259
+ license=args.license,
260
+ include_cli=args.include_cli,
261
+ include_ci_testing=args.include_ci,
262
+ include_ci_publishing=args.include_ci,
263
+ include_agent_skills=args.include_skills,
264
+ include_docs=getattr(args, "include_docs", defaults.include_docs),
265
+ include_mutation_testing=getattr(
266
+ args,
267
+ "include_mutation_testing",
268
+ defaults.include_mutation_testing,
269
+ ),
270
+ init_git=getattr(args, "init_git", defaults.init_git),
271
+ run_uv_sync=getattr(args, "run_uv_sync", defaults.run_uv_sync),
272
+ install_git_hooks=getattr(
273
+ args,
274
+ "install_git_hooks",
275
+ defaults.install_git_hooks,
276
+ ),
277
+ ai_tools=_resolve_ai_tools(args.ai_tools),
278
+ )
279
+
280
+
281
+ def _build_feature_flags(config: ZenithConfig) -> list[str]:
282
+ """Collect non-default command-line flags for project features and lifecycle.
283
+
284
+ Args:
285
+ config: Configuration holding feature toggles.
286
+
287
+ Returns:
288
+ List of CLI flag strings.
289
+
290
+ """
291
+ has_custom_dir = Path(config.output_dir).resolve() != Path.cwd().resolve()
292
+ flag_conditions = (
293
+ (has_custom_dir, f"--output-dir {shlex.quote(config.output_dir)}"),
294
+ (not config.include_cli, "--no-cli"),
295
+ (not config.include_docs, "--no-docs"),
296
+ (not config.include_mutation_testing, "--no-mutation"),
297
+ (not (config.include_ci_testing or config.include_ci_publishing), "--no-ci"),
298
+ (not config.include_agent_skills, "--no-skills"),
299
+ (not config.init_git, "--no-git"),
300
+ (config.run_uv_sync, "--sync"),
301
+ (config.install_git_hooks, "--hooks"),
302
+ )
303
+ return [flag for condition, flag in flag_conditions if condition]
304
+
305
+
306
+ def _resolve_ai_tools_arg(ai_tools: tuple[str, ...]) -> str:
307
+ """Determine the CLI argument value representing selected AI tools.
308
+
309
+ Args:
310
+ ai_tools: Tuple of selected tool identifier strings.
311
+
312
+ Returns:
313
+ Preset name or comma-separated list of tools.
314
+
315
+ """
316
+ if not ai_tools:
317
+ return "none"
318
+ for preset_name, preset_tools in AI_TOOL_PRESETS.items():
319
+ if set(ai_tools) == set(preset_tools):
320
+ return preset_name
321
+ return ",".join(ai_tools)
322
+
323
+
324
+ def build_reproduce_command(config: ZenithConfig) -> str:
325
+ """Build the exact command to recreate this project via CLI.
326
+
327
+ Args:
328
+ config: Configuration used to render the project.
329
+
330
+ Returns:
331
+ Shell-quoted CLI command string.
332
+
333
+ """
334
+ parts = [
335
+ "uvx zenith-template --no-input",
336
+ f"--name {shlex.quote(config.project_name)}",
337
+ f"--slug {shlex.quote(config.project_slug)}",
338
+ f"--package {shlex.quote(config.package_name)}",
339
+ f"--description {shlex.quote(config.description)}",
340
+ f"--author {shlex.quote(config.author_name)}",
341
+ f"--email {shlex.quote(config.author_email)}",
342
+ f"--github {shlex.quote(config.github_username)}",
343
+ f"--python-version {config.python_version}",
344
+ f"--license {config.license}",
345
+ ]
346
+ parts.extend(_build_feature_flags(config))
347
+ tools_arg = _resolve_ai_tools_arg(config.ai_tools)
348
+ parts.append(f"--ai-tools {tools_arg}")
349
+ return " ".join(parts)
350
+
351
+
352
+ def render_summary(project_dir: Path, config: ZenithConfig) -> None:
353
+ """Print a success confirmation card with next steps.
354
+
355
+ Args:
356
+ project_dir: Path to the generated project directory.
357
+ config: Configuration used to render the project.
358
+
359
+ """
360
+ tools_str = ", ".join(config.ai_tools) if config.ai_tools else "None"
361
+ recreate_cmd = build_reproduce_command(config)
362
+
363
+ next_steps: list[str] = [f"cd {config.project_slug}"]
364
+ if not config.run_uv_sync:
365
+ next_steps.append("uv sync")
366
+ next_steps.extend(["uv run poe test", "uv run poe code-quality"])
367
+ steps_formatted = "\n".join(
368
+ f" [#38bdf8]{idx}.[/] [#e0f2fe]{step}[/]"
369
+ for idx, step in enumerate(next_steps, 1)
370
+ )
371
+
372
+ summary_text = (
373
+ f"[bold #38bdf8]✦ Project successfully scaffolded at:[/]\n"
374
+ f" [#e0f2fe]{project_dir.resolve()}[/]\n\n"
375
+ f"[bold #38bdf8]✦ Active AI Tool Integrations:[/]\n"
376
+ f" [dim #94a3b8]{tools_str}[/]\n\n"
377
+ f"[bold #38bdf8]✦ Recreate via CLI:[/]\n"
378
+ f" [#38bdf8]{recreate_cmd}[/]\n\n"
379
+ f"[bold #38bdf8]✦ Next Steps:[/]\n"
380
+ f"{steps_formatted}"
381
+ )
382
+ panel = Panel(
383
+ summary_text,
384
+ title="[bold #38bdf8]✦ Zenith Project Initialized ✦[/]",
385
+ border_style="#38bdf8",
386
+ box=box.ROUNDED,
387
+ padding=(1, 2),
388
+ )
389
+ console.print(panel)
390
+
391
+
392
+ def main(argv: Sequence[str] | None = None) -> int:
393
+ """Run the Zenith CLI entry point.
394
+
395
+ Args:
396
+ argv: Optional command-line argument vector.
397
+
398
+ Returns:
399
+ Process exit status code.
400
+
401
+ """
402
+ parser = build_parser()
403
+ args = parser.parse_args(argv if argv is not None else sys.argv[1:])
404
+
405
+ if not args.no_input and sys.stdin.isatty():
406
+ initial = config_from_args(args)
407
+ app = ZenithApp(initial_config=initial)
408
+ app.run()
409
+ if app.result is None:
410
+ console.print("[yellow]Project generation cancelled.[/yellow]")
411
+ return 130
412
+ config = app.result
413
+ else:
414
+ config = config_from_args(args)
415
+
416
+ try:
417
+ with console.status(
418
+ "[bold #38bdf8]✦ Initializing Zenith project...[/]",
419
+ spinner="dots",
420
+ ) as status:
421
+ project_dir = generate_project(
422
+ config,
423
+ output_dir=config.output_dir,
424
+ overwrite=args.overwrite,
425
+ on_step=lambda msg: status.update(f"[bold #38bdf8]✦ {msg}[/]"),
426
+ )
427
+ except (FileExistsError, FileNotFoundError) as err:
428
+ console.print(f"[bold red]Error:[/bold red] {err}")
429
+ return 1
430
+ except KeyboardInterrupt:
431
+ console.print("\n[yellow]Operation aborted by user.[/yellow]")
432
+ return 130
433
+ else:
434
+ render_summary(project_dir, config)
435
+ return 0
436
+
437
+
438
+ if __name__ == "__main__":
439
+ sys.exit(main())
zenith/generator.py ADDED
@@ -0,0 +1,109 @@
1
+ """Execution of Cookiecutter project generation using programmatic API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import subprocess # ruff: ignore[suspicious-subprocess-import] -- post-scaffold lifecycle automation
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from cookiecutter.main import cookiecutter
11
+
12
+ from zenith.template import get_template_path
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from zenith.models import ZenithConfig
18
+
19
+
20
+ def _noop_step(_message: str) -> None:
21
+ """Ignore step status notifications when no callback is provided."""
22
+
23
+
24
+ def run_cmd(cmd: list[str], cwd: Path) -> None:
25
+ """Safely run a system command in the generated project directory.
26
+
27
+ Args:
28
+ cmd: Command argument vector to execute.
29
+ cwd: Working directory for command execution.
30
+
31
+ """
32
+ with contextlib.suppress(OSError, subprocess.SubprocessError):
33
+ subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] -- safe execution with argument list
34
+ cmd,
35
+ cwd=cwd,
36
+ check=False,
37
+ capture_output=True,
38
+ )
39
+
40
+
41
+ def _run_post_scaffold(
42
+ project_dir: Path,
43
+ config: ZenithConfig,
44
+ on_step: Callable[[str], None] = _noop_step,
45
+ ) -> None:
46
+ """Execute optional post-scaffolding lifecycle automation steps.
47
+
48
+ Args:
49
+ project_dir: Path to the generated project directory.
50
+ config: Configuration containing lifecycle flags.
51
+ on_step: Callback invoked with a human-readable description before each step.
52
+
53
+ """
54
+ if config.init_git and not (project_dir / ".git").is_dir():
55
+ on_step("Initializing Git repository (git init)...")
56
+ run_cmd(["git", "init"], cwd=project_dir)
57
+
58
+ if config.run_uv_sync:
59
+ on_step("Synchronizing Python environment (uv sync)...")
60
+ run_cmd(["uv", "sync"], cwd=project_dir)
61
+
62
+ if config.install_git_hooks and (project_dir / ".git").is_dir():
63
+ on_step("Installing Git hooks (prek install)...")
64
+ run_cmd(["uv", "run", "prek", "install"], cwd=project_dir)
65
+
66
+
67
+ def generate_project(
68
+ config: ZenithConfig,
69
+ output_dir: Path | str = ".",
70
+ *,
71
+ overwrite: bool = False,
72
+ on_step: Callable[[str], None] = _noop_step,
73
+ ) -> Path:
74
+ """Generate a new Zenith project from the bundled template.
75
+
76
+ Args:
77
+ config: Project configuration settings.
78
+ output_dir: Destination directory where project folder will be created.
79
+ overwrite: Whether to overwrite existing files in destination directory.
80
+ on_step: Callback invoked with a human-readable description before each step.
81
+
82
+ Returns:
83
+ Path to the scaffolded project directory.
84
+
85
+ Raises:
86
+ FileExistsError: If the target directory exists and overwrite is False.
87
+
88
+ """
89
+ out_path = Path(output_dir).resolve()
90
+ target_dir = out_path / config.project_slug
91
+
92
+ if target_dir.is_dir() and not overwrite and any(target_dir.iterdir()):
93
+ msg = f"Target directory '{target_dir}' already exists and is not empty."
94
+ raise FileExistsError(msg)
95
+
96
+ template_path = get_template_path()
97
+ context = config.to_cookiecutter_context()
98
+
99
+ on_step(f"Scaffolding project template into {target_dir.name}...")
100
+ result = cookiecutter(
101
+ template=str(template_path),
102
+ extra_context=context,
103
+ no_input=True,
104
+ output_dir=str(out_path),
105
+ overwrite_if_exists=overwrite,
106
+ )
107
+ project_path = Path(result)
108
+ _run_post_scaffold(project_path, config, on_step=on_step)
109
+ return project_path