austial-cli 0.1.5__tar.gz → 0.2.1__tar.gz

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 (62) hide show
  1. {austial_cli-0.1.5 → austial_cli-0.2.1}/.gitignore +4 -0
  2. {austial_cli-0.1.5 → austial_cli-0.2.1}/PKG-INFO +2 -2
  3. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/generators/__init__.py +6 -0
  4. austial_cli-0.2.1/src/austial/cli/generators/agent_files.py +45 -0
  5. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/generators/base.py +27 -0
  6. austial_cli-0.2.1/src/austial/cli/generators/component_gen.py +55 -0
  7. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/generators/module_gen.py +2 -2
  8. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/generators/new_project.py +12 -1
  9. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/generators/resource_gen.py +2 -2
  10. austial_cli-0.2.1/src/austial/cli/generators/workspace_gen.py +42 -0
  11. austial_cli-0.2.1/src/austial/cli/main.py +317 -0
  12. austial_cli-0.2.1/src/austial/cli/templates/app/app_module.py.j2 +6 -0
  13. austial_cli-0.2.1/src/austial/cli/templates/app/main.py.j2 +7 -0
  14. austial_cli-0.2.1/src/austial/cli/templates/class/class.py.j2 +2 -0
  15. austial_cli-0.2.1/src/austial/cli/templates/decorator/decorator.py.j2 +11 -0
  16. austial_cli-0.2.1/src/austial/cli/templates/filter/filter.py.j2 +11 -0
  17. austial_cli-0.2.1/src/austial/cli/templates/guard/guard.py.j2 +8 -0
  18. austial_cli-0.2.1/src/austial/cli/templates/interceptor/interceptor.py.j2 +12 -0
  19. austial_cli-0.2.1/src/austial/cli/templates/library/module.py.j2 +8 -0
  20. austial_cli-0.2.1/src/austial/cli/templates/library/service.py.j2 +6 -0
  21. austial_cli-0.2.1/src/austial/cli/templates/middleware/middleware.py.j2 +11 -0
  22. austial_cli-0.2.1/src/austial/cli/templates/pipe/pipe.py.j2 +9 -0
  23. austial_cli-0.2.1/src/austial/cli/templates/project/agents_md.j2 +162 -0
  24. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/readme.j2 +9 -0
  25. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/resource/controller.py.j2 +5 -5
  26. austial_cli-0.2.1/src/austial/cli/templates/resource/entity.py.j2 +7 -0
  27. austial_cli-0.2.1/src/austial/cli/templates/resource/module.py.j2 +15 -0
  28. austial_cli-0.2.1/src/austial/cli/templates/resource/service.py.j2 +33 -0
  29. austial_cli-0.2.1/src/austial/cli/tools.py +91 -0
  30. austial_cli-0.2.1/test/cli_tools_spec.py +134 -0
  31. austial_cli-0.2.1/test/generate_components_spec.py +150 -0
  32. austial_cli-0.2.1/test/new_project_spec.py +68 -0
  33. austial_cli-0.1.5/src/austial/cli/main.py +0 -133
  34. austial_cli-0.1.5/src/austial/cli/templates/resource/entity.py.j2 +0 -6
  35. austial_cli-0.1.5/src/austial/cli/templates/resource/module.py.j2 +0 -9
  36. austial_cli-0.1.5/src/austial/cli/templates/resource/service.py.j2 +0 -39
  37. {austial_cli-0.1.5 → austial_cli-0.2.1}/pyproject.toml +0 -0
  38. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/__init__.py +0 -0
  39. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/generators/app_module_patcher.py +0 -0
  40. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/generators/controller_gen.py +0 -0
  41. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/generators/service_gen.py +0 -0
  42. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/naming.py +0 -0
  43. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/controller/controller.py.j2 +0 -0
  44. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/module/module.py.j2 +0 -0
  45. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/app_controller.py.j2 +0 -0
  46. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/app_module.py.j2 +0 -0
  47. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/app_service.py.j2 +0 -0
  48. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/env_example.j2 +0 -0
  49. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/gitignore.j2 +0 -0
  50. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/main.py.j2 +0 -0
  51. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/modules/health/guards/api_key_guard.py.j2 +0 -0
  52. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/modules/health/health_controller.py.j2 +0 -0
  53. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/modules/health/health_dto.py.j2 +0 -0
  54. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/modules/health/health_module.py.j2 +0 -0
  55. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/modules/health/health_service.py.j2 +0 -0
  56. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/pre_commit_config.yaml.j2 +0 -0
  57. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/pyproject.toml.j2 +0 -0
  58. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/tests/e2e/app_e2e_spec.py.j2 +0 -0
  59. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/project/tests/unit/health_service_spec.py.j2 +0 -0
  60. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/resource/create_dto.py.j2 +0 -0
  61. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/resource/update_dto.py.j2 +0 -0
  62. {austial_cli-0.1.5 → austial_cli-0.2.1}/src/austial/cli/templates/service/service.py.j2 +0 -0
@@ -9,3 +9,7 @@ htmlcov/
9
9
  dist/
10
10
  build/
11
11
  *.egg-info/
12
+ .DS_Store
13
+
14
+ .claude/settings.local.json
15
+ coverage.xml
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: austial-cli
3
- Version: 0.1.5
3
+ Version: 0.2.1
4
4
  Summary: The `austial` scaffolding CLI (new/generate) and its Jinja2 project templates -- mirrors the separate @nestjs/cli repo.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: jinja2>=3.1.0
@@ -1,10 +1,16 @@
1
+ from austial.cli.generators.component_gen import COMPONENT_KINDS, generate_component
1
2
  from austial.cli.generators.controller_gen import generate_controller
2
3
  from austial.cli.generators.module_gen import generate_module
3
4
  from austial.cli.generators.new_project import create_new_project
4
5
  from austial.cli.generators.resource_gen import generate_resource
5
6
  from austial.cli.generators.service_gen import generate_service
7
+ from austial.cli.generators.workspace_gen import generate_app, generate_library
6
8
 
7
9
  __all__ = [
10
+ "COMPONENT_KINDS",
11
+ "generate_app",
12
+ "generate_component",
13
+ "generate_library",
8
14
  "create_new_project",
9
15
  "generate_controller",
10
16
  "generate_module",
@@ -0,0 +1,45 @@
1
+ """AI-tool instruction files for a new project.
2
+
3
+ One canonical ``AGENTS.md`` is written, and every other tool's instructions
4
+ file is a symlink to it, so there is a single file to edit and no drift. Where
5
+ the filesystem can't create symlinks (Windows without Developer Mode), the
6
+ file is copied instead. See https://docs.austial.com/ai/setup.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ AGENTS_FILE = "AGENTS.md"
16
+
17
+ # link path (relative to the project root) -> symlink target (relative to the link's own directory)
18
+ AGENT_LINKS: dict[str, str] = {
19
+ "CLAUDE.md": "AGENTS.md",
20
+ "GEMINI.md": "AGENTS.md",
21
+ "CONVENTIONS.md": "AGENTS.md",
22
+ ".windsurfrules": "AGENTS.md",
23
+ ".clinerules": "AGENTS.md",
24
+ ".rules": "AGENTS.md",
25
+ ".github/copilot-instructions.md": "../AGENTS.md",
26
+ }
27
+
28
+
29
+ def link_agent_files(project_dir: Path) -> list[str]:
30
+ """Points every tool-specific instructions file at ``AGENTS.md``. Returns
31
+ the link paths that had to be copied because symlinks aren't available.
32
+ Existing files are left untouched."""
33
+ source = project_dir / AGENTS_FILE
34
+ copied: list[str] = []
35
+ for link, target in AGENT_LINKS.items():
36
+ path = project_dir / link
37
+ if path.exists() or path.is_symlink():
38
+ continue
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ try:
41
+ os.symlink(target, path)
42
+ except (OSError, NotImplementedError):
43
+ shutil.copyfile(source, path)
44
+ copied.append(link)
45
+ return copied
@@ -3,6 +3,9 @@ discovery, mirroring what the ``nest`` CLI's schematics engine does."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from contextvars import ContextVar
6
9
  from pathlib import Path
7
10
  from typing import Any
8
11
 
@@ -23,12 +26,36 @@ def render(template_relpath: str, **context: Any) -> str:
23
26
  return template.render(**context)
24
27
 
25
28
 
29
+ _dry_run: ContextVar[list[Path] | None] = ContextVar("austial_dry_run", default=None)
30
+
31
+
32
+ @contextmanager
33
+ def dry_run() -> Iterator[list[Path]]:
34
+ """Inside the block nothing is written; the paths that *would* be created are collected instead
35
+ (``austial generate ... --dry-run``)."""
36
+ created: list[Path] = []
37
+ token = _dry_run.set(created)
38
+ try:
39
+ yield created
40
+ finally:
41
+ _dry_run.reset(token)
42
+
43
+
44
+ def is_dry_run() -> bool:
45
+ return _dry_run.get() is not None
46
+
47
+
26
48
  def write_file(path: Path, content: str, *, force: bool = False) -> bool:
27
49
  """Writes `content` to `path`, creating parent directories as needed.
28
50
  Returns False (and leaves the file untouched) if it already exists and
29
51
  `force` wasn't given -- mirrors `nest g`'s "already exists" guard."""
30
52
  if path.exists() and not force:
31
53
  return False
54
+ collector = _dry_run.get()
55
+ if collector is not None:
56
+ if path.name != "__init__.py": # package markers are noise in the report
57
+ collector.append(path)
58
+ return True
32
59
  path.parent.mkdir(parents=True, exist_ok=True)
33
60
  path.write_text(content)
34
61
  return True
@@ -0,0 +1,55 @@
1
+ """``austial generate guard|pipe|interceptor|filter|middleware|decorator|class <name>`` -- mirrors the
2
+ same ``nest g`` schematics.
3
+
4
+ ``<name>`` may carry a path: ``auth/api-key`` creates ``src/modules/auth/api_key/api_key_guard.py``
5
+ (``--flat`` drops the innermost folder: ``src/modules/auth/api_key_guard.py``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from austial.cli.generators.base import find_project_root, render, write_file
13
+ from austial.cli.naming import to_kebab_case, to_pascal_case, to_snake_case
14
+
15
+ # kind -> (template, file suffix)
16
+ COMPONENT_KINDS: dict[str, tuple[str, str]] = {
17
+ "guard": ("guard/guard.py.j2", "guard"),
18
+ "pipe": ("pipe/pipe.py.j2", "pipe"),
19
+ "interceptor": ("interceptor/interceptor.py.j2", "interceptor"),
20
+ "filter": ("filter/filter.py.j2", "filter"),
21
+ "middleware": ("middleware/middleware.py.j2", "middleware"),
22
+ "decorator": ("decorator/decorator.py.j2", "decorator"),
23
+ "class": ("class/class.py.j2", ""),
24
+ }
25
+
26
+
27
+ def generate_component(kind: str, name: str, *, flat: bool = False, project_root: Path | None = None) -> Path:
28
+ template, suffix = COMPONENT_KINDS[kind]
29
+ root = project_root or find_project_root()
30
+
31
+ *folders, leaf = [part for part in name.replace("\\", "/").split("/") if part]
32
+ snake_name = to_snake_case(leaf)
33
+ class_name = to_pascal_case(leaf)
34
+ ctx = {
35
+ "snake_name": snake_name,
36
+ "class_name": class_name,
37
+ "kebab_name": to_kebab_case(leaf),
38
+ "constant_name": snake_name.upper(),
39
+ }
40
+
41
+ base = root / "src" / "modules"
42
+ directory = base.joinpath(*(to_snake_case(f) for f in folders))
43
+ if not flat:
44
+ directory = directory / snake_name
45
+
46
+ # Every folder between src/modules and the file is a package.
47
+ for folder in (directory, *directory.parents):
48
+ if folder == base.parent or folder == base.parent.parent:
49
+ break
50
+ write_file(folder / "__init__.py", "")
51
+
52
+ file_name = f"{snake_name}_{suffix}.py" if suffix else f"{snake_name}.py"
53
+ file_path = directory / file_name
54
+ write_file(file_path, render(template, **ctx))
55
+ return file_path
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from austial.cli.generators.app_module_patcher import register_module
8
- from austial.cli.generators.base import find_project_root, render, write_file
8
+ from austial.cli.generators.base import find_project_root, is_dry_run, render, write_file
9
9
  from austial.cli.naming import to_kebab_case, to_pascal_case, to_snake_case
10
10
 
11
11
 
@@ -21,7 +21,7 @@ def generate_module(name: str, *, project_root: Path | None = None) -> Path:
21
21
  write_file(file_path, render("module/module.py.j2", **ctx))
22
22
 
23
23
  app_module_path = root / "src" / "app_module.py"
24
- if app_module_path.exists():
24
+ if app_module_path.exists() and not is_dry_run():
25
25
  register_module(
26
26
  app_module_path,
27
27
  f"{class_name}Module",
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import subprocess
6
6
  from pathlib import Path
7
7
 
8
+ from austial.cli.generators.agent_files import link_agent_files
8
9
  from austial.cli.generators.base import render, write_file
9
10
  from austial.cli.naming import to_kebab_case
10
11
 
@@ -16,10 +17,16 @@ def create_new_project(
16
17
  austial_source_path: str | None = None,
17
18
  skip_install: bool = False,
18
19
  skip_git: bool = False,
20
+ skip_agents: bool = False,
19
21
  ) -> Path:
20
22
  project_dir = (directory or Path.cwd() / name).resolve()
21
23
  project_slug = to_kebab_case(name)
22
- ctx = {"project_name": name, "project_slug": project_slug, "austial_source_path": austial_source_path}
24
+ ctx = {
25
+ "project_name": name,
26
+ "project_slug": project_slug,
27
+ "austial_source_path": austial_source_path,
28
+ "agents": not skip_agents,
29
+ }
23
30
 
24
31
  write_file(project_dir / "pyproject.toml", render("project/pyproject.toml.j2", **ctx))
25
32
  write_file(project_dir / "README.md", render("project/readme.j2", **ctx))
@@ -27,6 +34,10 @@ def create_new_project(
27
34
  write_file(project_dir / ".gitignore", render("project/gitignore.j2", **ctx))
28
35
  write_file(project_dir / ".pre-commit-config.yaml", render("project/pre_commit_config.yaml.j2", **ctx))
29
36
 
37
+ if not skip_agents:
38
+ write_file(project_dir / "AGENTS.md", render("project/agents_md.j2", **ctx))
39
+ link_agent_files(project_dir)
40
+
30
41
  write_file(project_dir / "src" / "__init__.py", "")
31
42
  write_file(project_dir / "src" / "main.py", render("project/main.py.j2", **ctx))
32
43
  write_file(project_dir / "src" / "app_module.py", render("project/app_module.py.j2", **ctx))
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
  from pathlib import Path
8
8
 
9
9
  from austial.cli.generators.app_module_patcher import register_module
10
- from austial.cli.generators.base import find_project_root, render, write_file
10
+ from austial.cli.generators.base import find_project_root, is_dry_run, render, write_file
11
11
  from austial.cli.naming import to_kebab_case, to_pascal_case, to_snake_case
12
12
 
13
13
 
@@ -30,7 +30,7 @@ def generate_resource(name: str, *, project_root: Path | None = None) -> Path:
30
30
  write_file(module_dir / f"{snake_name}_module.py", render("resource/module.py.j2", **ctx))
31
31
 
32
32
  app_module_path = root / "src" / "app_module.py"
33
- if app_module_path.exists():
33
+ if app_module_path.exists() and not is_dry_run():
34
34
  register_module(
35
35
  app_module_path,
36
36
  f"{class_name}Module",
@@ -0,0 +1,42 @@
1
+ """``austial generate library|app <name>`` -- mirrors Nest's monorepo ``libraries`` and ``applications``.
2
+
3
+ * ``library`` -> ``libs/<name>/`` : a module + service other apps import (``from libs.<name>... import``).
4
+ * ``app`` -> ``apps/<name>/`` : an additional application with its own ``main.py``, next to ``src/``.
5
+
6
+ Both live at the project root, which ``austial serve`` and pytest already have on the import path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from austial.cli.generators.base import find_project_root, render, write_file
14
+ from austial.cli.naming import to_kebab_case, to_pascal_case, to_snake_case
15
+
16
+
17
+ def _context(name: str) -> dict[str, str]:
18
+ return {"snake_name": to_snake_case(name), "class_name": to_pascal_case(name), "kebab_name": to_kebab_case(name)}
19
+
20
+
21
+ def generate_library(name: str, *, project_root: Path | None = None) -> Path:
22
+ root = project_root or find_project_root()
23
+ ctx = _context(name)
24
+ folder = root / "libs" / ctx["snake_name"]
25
+ write_file(root / "libs" / "__init__.py", "")
26
+ write_file(folder / "__init__.py", "")
27
+ write_file(folder / f"{ctx['snake_name']}_service.py", render("library/service.py.j2", **ctx))
28
+ path = folder / f"{ctx['snake_name']}_module.py"
29
+ write_file(path, render("library/module.py.j2", **ctx))
30
+ return path
31
+
32
+
33
+ def generate_app(name: str, *, project_root: Path | None = None) -> Path:
34
+ root = project_root or find_project_root()
35
+ ctx = _context(name)
36
+ folder = root / "apps" / ctx["snake_name"]
37
+ write_file(root / "apps" / "__init__.py", "")
38
+ write_file(folder / "__init__.py", "")
39
+ write_file(folder / "app_module.py", render("app/app_module.py.j2", **ctx))
40
+ path = folder / "main.py"
41
+ write_file(path, render("app/main.py.j2", **ctx))
42
+ return path
@@ -0,0 +1,317 @@
1
+ """``austial`` -- the CLI, mirrors the ``nest`` command.
2
+
3
+ austial new my-app
4
+ austial generate module cats (alias: austial g module cats)
5
+ austial generate controller cats
6
+ austial generate service cats
7
+ austial generate resource cats
8
+ austial generate guard auth/api-key [--flat] [--dry-run]
9
+ (also: pipe, interceptor, filter, middleware, decorator, class)
10
+ austial info
11
+ austial serve
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import typer
19
+ from rich.console import Console
20
+
21
+ from austial.cli.generators import (
22
+ COMPONENT_KINDS,
23
+ create_new_project,
24
+ generate_app,
25
+ generate_component,
26
+ generate_controller,
27
+ generate_library,
28
+ generate_module,
29
+ generate_resource,
30
+ generate_service,
31
+ )
32
+ from austial.cli.generators.base import ProjectNotFoundError, dry_run, find_project_root
33
+
34
+ console = Console()
35
+ app = typer.Typer(
36
+ name="austial",
37
+ help="Austial CLI -- scaffold and run NestJS-style Python projects, built on FastAPI.",
38
+ add_completion=False,
39
+ no_args_is_help=True,
40
+ )
41
+
42
+ _SCHEMATICS = {
43
+ "module": generate_module,
44
+ "controller": generate_controller,
45
+ "service": generate_service,
46
+ "provider": generate_service, # Nest calls a service a "provider"; same schematic
47
+ "resource": generate_resource,
48
+ "library": generate_library,
49
+ "app": generate_app,
50
+ }
51
+
52
+ # Schematics that accept `--flat` (the ones that create a single file rather than a feature folder).
53
+ _FLAT_SCHEMATICS = set(COMPONENT_KINDS)
54
+
55
+ # `nest g` short aliases.
56
+ _ALIASES = {
57
+ "mo": "module",
58
+ "co": "controller",
59
+ "s": "service",
60
+ "pr": "provider",
61
+ "r": "resource",
62
+ "gu": "guard",
63
+ "pi": "pipe",
64
+ "in": "interceptor",
65
+ "f": "filter",
66
+ "mi": "middleware",
67
+ "d": "decorator",
68
+ "cl": "class",
69
+ "lib": "library",
70
+ }
71
+
72
+ _ALL_SCHEMATICS = sorted({*_SCHEMATICS, *COMPONENT_KINDS})
73
+ _SCHEMATIC_HELP = "One of: " + ", ".join(_ALL_SCHEMATICS) + " (or a short alias: " + ", ".join(sorted(_ALIASES)) + ")"
74
+
75
+
76
+ @app.command("new")
77
+ def new_project(
78
+ name: str = typer.Argument(..., help="Name of the new project/directory to create."),
79
+ directory: str | None = typer.Option(None, "--directory", "-d", help="Target directory (defaults to ./<name>)."),
80
+ skip_install: bool = typer.Option(False, "--skip-install", help="Don't run `uv sync` after scaffolding."),
81
+ skip_git: bool = typer.Option(False, "--skip-git", help="Don't run `git init` after scaffolding."),
82
+ skip_agents: bool = typer.Option(
83
+ False,
84
+ "--skip-agents",
85
+ help="Don't write AGENTS.md or the per-tool symlinks (CLAUDE.md, GEMINI.md, ...).",
86
+ ),
87
+ link: str | None = typer.Option(
88
+ None,
89
+ "--link",
90
+ help="Path to a local Austial framework checkout to depend on via `uv`'s editable path sources "
91
+ "(useful before the framework is published to PyPI).",
92
+ ),
93
+ ) -> None:
94
+ """Scaffold a brand-new Austial project -- mirrors `nest new <name>`."""
95
+ console.print(f"[bold green]CREATE[/bold green] {name}/ ...")
96
+ target = Path(directory).resolve() if directory else None
97
+ project_dir = create_new_project(
98
+ name,
99
+ directory=target,
100
+ austial_source_path=link,
101
+ skip_install=skip_install,
102
+ skip_git=skip_git,
103
+ skip_agents=skip_agents,
104
+ )
105
+ console.print(f"[bold green]\u2714[/bold green] Project created at [bold]{project_dir}[/bold]")
106
+ console.print("\nNext steps:")
107
+ console.print(f" cd {project_dir.name}")
108
+ if skip_install:
109
+ console.print(" uv sync")
110
+ console.print(" cp .env.example .env")
111
+ console.print(" uv run austial serve\n")
112
+
113
+
114
+ def _generate(schematic: str, name: str, *, flat: bool = False, dry: bool = False) -> None:
115
+ schematic = _ALIASES.get(schematic.lower(), schematic.lower())
116
+ if schematic not in _ALL_SCHEMATICS:
117
+ console.print(
118
+ f"[bold red]Unknown schematic[/bold red] '{schematic}'. Choose one of: {', '.join(_ALL_SCHEMATICS)}."
119
+ )
120
+ raise typer.Exit(code=1)
121
+ if flat and schematic not in _FLAT_SCHEMATICS:
122
+ console.print(f"[bold red]\u2716[/bold red] --flat applies to: {', '.join(sorted(_FLAT_SCHEMATICS))}.")
123
+ raise typer.Exit(code=1)
124
+
125
+ def run() -> Path:
126
+ if schematic in COMPONENT_KINDS:
127
+ return generate_component(schematic, name, flat=flat)
128
+ return _SCHEMATICS[schematic](name)
129
+
130
+ try:
131
+ if dry:
132
+ with dry_run() as created:
133
+ run()
134
+ for path in created:
135
+ console.print(f"[bold yellow]CREATE[/bold yellow] {path} [dim](dry run)[/dim]")
136
+ console.print("[dim]Dry run: nothing was written.[/dim]")
137
+ return
138
+ path = run()
139
+ except ProjectNotFoundError as exc:
140
+ console.print(f"[bold red]\u2716[/bold red] {exc}")
141
+ raise typer.Exit(code=1) from exc
142
+ console.print(f"[bold green]CREATE[/bold green] {path}")
143
+
144
+
145
+ _FLAT_HELP = "Don't create a folder for the file (guard, pipe, interceptor, filter, middleware, decorator, class)."
146
+ _DRY_HELP = "Show what would be created without writing anything."
147
+
148
+
149
+ @app.command("generate")
150
+ def generate(
151
+ schematic: str = typer.Argument(..., help=_SCHEMATIC_HELP),
152
+ name: str = typer.Argument(..., help="Name, optionally with a path: 'cats' or 'auth/api-key'."),
153
+ flat: bool = typer.Option(False, "--flat", help=_FLAT_HELP),
154
+ dry: bool = typer.Option(False, "--dry-run", "-d", help=_DRY_HELP),
155
+ ) -> None:
156
+ """Generate an artifact -- mirrors `nest generate`."""
157
+ _generate(schematic, name, flat=flat, dry=dry)
158
+
159
+
160
+ @app.command("g", hidden=True)
161
+ def generate_alias(
162
+ schematic: str = typer.Argument(..., help=_SCHEMATIC_HELP),
163
+ name: str = typer.Argument(..., help="Name, optionally with a path: 'cats' or 'auth/api-key'."),
164
+ flat: bool = typer.Option(False, "--flat", help=_FLAT_HELP),
165
+ dry: bool = typer.Option(False, "--dry-run", "-d", help=_DRY_HELP),
166
+ ) -> None:
167
+ """Alias for `generate` -- mirrors Nest CLI's `nest g`."""
168
+ _generate(schematic, name, flat=flat, dry=dry)
169
+
170
+
171
+ @app.command("info")
172
+ def info() -> None:
173
+ """Prints Python, platform and installed Austial package versions -- mirrors `nest info`."""
174
+ import importlib.metadata as metadata
175
+ import platform
176
+
177
+ console.print(f"[bold]Python[/bold] {platform.python_version()} ({platform.system()} {platform.machine()})")
178
+ packages = sorted(
179
+ (dist.metadata["Name"], dist.version)
180
+ for dist in metadata.distributions()
181
+ if (dist.metadata["Name"] or "").lower().startswith("austial")
182
+ )
183
+ console.print("[bold]Austial packages[/bold]")
184
+ for name, version in packages:
185
+ console.print(f" {name:<22} {version}")
186
+ try:
187
+ console.print(f"[bold]Project[/bold] {find_project_root()}")
188
+ except ProjectNotFoundError:
189
+ console.print("[bold]Project[/bold] [dim](not inside an Austial project)[/dim]")
190
+
191
+
192
+ def _project_root_or_exit() -> Path:
193
+ try:
194
+ return find_project_root()
195
+ except ProjectNotFoundError as exc:
196
+ console.print(f"[bold red]\u2716[/bold red] {exc}")
197
+ raise typer.Exit(code=1) from exc
198
+
199
+
200
+ @app.command("serve")
201
+ def serve(
202
+ host: str = typer.Option("0.0.0.0", help="Bind host."),
203
+ port: int = typer.Option(8000, help="Bind port."),
204
+ reload: bool = typer.Option(True, help="Auto-reload on file changes (dev mode)."),
205
+ ) -> None:
206
+ """Runs the current project with auto-reload (mirrors `nest start --watch`)."""
207
+ _serve(host, port, reload)
208
+
209
+
210
+ @app.command("start")
211
+ def start(
212
+ host: str = typer.Option("0.0.0.0", help="Bind host."),
213
+ port: int = typer.Option(8000, help="Bind port."),
214
+ watch: bool = typer.Option(False, "--watch", "-w", help="Auto-reload on file changes."),
215
+ target: str = typer.Option("src.main:app", "--app", help="ASGI app to run, e.g. apps.admin.main:app."),
216
+ ) -> None:
217
+ """Runs the project (mirrors `nest start`; add --watch for reload, --app for an app in apps/)."""
218
+ _serve(host, port, watch, target)
219
+
220
+
221
+ @app.command("build")
222
+ def build() -> None:
223
+ """Byte-compiles the project (src, libs, apps) and checks the module graph imports and scans cleanly."""
224
+ from austial.cli.tools import TargetError, build_project, load_target, module_graph_for
225
+
226
+ root = _project_root_or_exit()
227
+ failed = build_project(root)
228
+ if failed:
229
+ console.print(f"[bold red]\u2716[/bold red] Compilation failed in: {', '.join(failed)}")
230
+ raise typer.Exit(code=1)
231
+ try:
232
+ graph = module_graph_for(load_target("src.app_module:AppModule", root))
233
+ except TargetError as exc:
234
+ console.print(f"[bold red]\u2716[/bold red] {exc}")
235
+ raise typer.Exit(code=1) from exc
236
+ console.print(f"[bold green]\u2714[/bold green] Build OK: {len(graph.records)} modules scanned")
237
+
238
+
239
+ @app.command("add")
240
+ def add(
241
+ package: str = typer.Argument(..., help="Optional package, e.g. cache, events, websockets (see `austial add x`)."),
242
+ dry: bool = typer.Option(False, "--dry-run", "-d", help="Print the command without running it."),
243
+ ) -> None:
244
+ """Installs an optional Austial package into the project with uv (mirrors `nest add`)."""
245
+ import subprocess
246
+
247
+ from austial.cli.tools import TargetError, add_command
248
+
249
+ try:
250
+ command = add_command(package)
251
+ except TargetError as exc:
252
+ console.print(f"[bold red]\u2716[/bold red] {exc}")
253
+ raise typer.Exit(code=1) from exc
254
+ console.print(f"[bold]$ {' '.join(command)}[/bold]")
255
+ if dry:
256
+ return
257
+ raise typer.Exit(code=subprocess.run(command, cwd=_project_root_or_exit()).returncode)
258
+
259
+
260
+ @app.command("graph")
261
+ def graph(
262
+ target: str = typer.Argument("src.app_module:AppModule", help="module.path:RootModule"),
263
+ fmt: str = typer.Option("mermaid", "--format", "-f", help="mermaid or json."),
264
+ ) -> None:
265
+ """Prints the module graph (imports, providers, controllers, exports), like Nest's devtools graph."""
266
+ import json
267
+
268
+ from austial.cli.tools import TargetError, load_target, module_graph_for
269
+
270
+ try:
271
+ module_graph = module_graph_for(load_target(target, _project_root_or_exit()))
272
+ except TargetError as exc:
273
+ console.print(f"[bold red]\u2716[/bold red] {exc}")
274
+ raise typer.Exit(code=1) from exc
275
+ typer.echo(json.dumps(module_graph.to_dict(), indent=2) if fmt == "json" else module_graph.to_mermaid())
276
+
277
+
278
+ @app.command("repl")
279
+ def repl(target: str = typer.Argument("src.app_module:AppModule", help="module.path:RootModule")) -> None:
280
+ """Starts a Python REPL with the app booted: get(Token), run(coroutine). Mirrors Nest's REPL."""
281
+ import code
282
+
283
+ from austial.cli.tools import ReplSession, TargetError, load_target
284
+
285
+ try:
286
+ session = ReplSession(load_target(target, _project_root_or_exit()))
287
+ except TargetError as exc:
288
+ console.print(f"[bold red]\u2716[/bold red] {exc}")
289
+ raise typer.Exit(code=1) from exc
290
+ try:
291
+ code.interact(banner=session.banner(), local=session.namespace)
292
+ finally:
293
+ session.close()
294
+
295
+
296
+ def _serve(host: str, port: int, reload: bool, target: str = "src.main:app") -> None:
297
+ import uvicorn
298
+
299
+ try:
300
+ project_root = find_project_root()
301
+ except ProjectNotFoundError as exc:
302
+ console.print(f"[bold red]\u2716[/bold red] {exc}")
303
+ raise typer.Exit(code=1) from exc
304
+
305
+ import sys
306
+
307
+ sys.path.insert(0, str(project_root))
308
+ console.print(f"[bold green]\u25b6[/bold green] Serving {project_root.name} on http://{host}:{port}")
309
+ uvicorn.run(target, host=host, port=port, reload=reload, app_dir=str(project_root))
310
+
311
+
312
+ def run() -> None:
313
+ app()
314
+
315
+
316
+ if __name__ == "__main__":
317
+ run()
@@ -0,0 +1,6 @@
1
+ from austial import Module
2
+
3
+
4
+ @Module(imports=[], controllers=[], providers=[])
5
+ class {{ class_name }}AppModule:
6
+ pass
@@ -0,0 +1,7 @@
1
+ """Entry point of the `{{ snake_name }}` app: `uvicorn apps.{{ snake_name }}.main:app` (from the repository root)."""
2
+
3
+ from austial import AustialFactory
4
+
5
+ from apps.{{ snake_name }}.app_module import {{ class_name }}AppModule
6
+
7
+ app = AustialFactory.create({{ class_name }}AppModule, title="{{ class_name }}")
@@ -0,0 +1,2 @@
1
+ class {{ class_name }}:
2
+ pass
@@ -0,0 +1,11 @@
1
+ from austial.core.metadata import set_metadata
2
+
3
+ {{ constant_name }}_KEY = "{{ snake_name }}"
4
+
5
+
6
+ def {{ class_name }}(*values: str):
7
+ """Marks a controller or handler: ``@{{ class_name }}("a", "b")``.
8
+
9
+ Read it back with ``Reflector.get({{ constant_name }}_KEY, context.get_handler())`` in a guard or interceptor.
10
+ """
11
+ return set_metadata({{ constant_name }}_KEY, list(values))
@@ -0,0 +1,11 @@
1
+ from austial import ArgumentsHost, Catch, ExceptionFilter, HttpException, Injectable
2
+ from starlette.responses import JSONResponse
3
+
4
+
5
+ @Injectable()
6
+ @Catch(HttpException)
7
+ class {{ class_name }}Filter(ExceptionFilter):
8
+ async def catch(self, exception: HttpException, host: ArgumentsHost) -> JSONResponse:
9
+ request = host.get_request()
10
+ body = {**exception.get_response(), "path": request.url.path}
11
+ return JSONResponse(status_code=exception.get_status(), content=body)
@@ -0,0 +1,8 @@
1
+ from austial import CanActivate, ExecutionContext, Injectable
2
+
3
+
4
+ @Injectable()
5
+ class {{ class_name }}Guard(CanActivate):
6
+ async def can_activate(self, context: ExecutionContext) -> bool:
7
+ request = context.get_request() # noqa: F841 - inspect headers, state, etc.
8
+ return True