austial-cli 0.1.5__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 (39) hide show
  1. austial_cli-0.1.5/.gitignore +11 -0
  2. austial_cli-0.1.5/PKG-INFO +8 -0
  3. austial_cli-0.1.5/pyproject.toml +23 -0
  4. austial_cli-0.1.5/src/austial/cli/__init__.py +0 -0
  5. austial_cli-0.1.5/src/austial/cli/generators/__init__.py +13 -0
  6. austial_cli-0.1.5/src/austial/cli/generators/app_module_patcher.py +61 -0
  7. austial_cli-0.1.5/src/austial/cli/generators/base.py +61 -0
  8. austial_cli-0.1.5/src/austial/cli/generators/controller_gen.py +21 -0
  9. austial_cli-0.1.5/src/austial/cli/generators/module_gen.py +30 -0
  10. austial_cli-0.1.5/src/austial/cli/generators/new_project.py +67 -0
  11. austial_cli-0.1.5/src/austial/cli/generators/resource_gen.py +39 -0
  12. austial_cli-0.1.5/src/austial/cli/generators/service_gen.py +21 -0
  13. austial_cli-0.1.5/src/austial/cli/main.py +133 -0
  14. austial_cli-0.1.5/src/austial/cli/naming.py +33 -0
  15. austial_cli-0.1.5/src/austial/cli/templates/controller/controller.py.j2 +8 -0
  16. austial_cli-0.1.5/src/austial/cli/templates/module/module.py.j2 +6 -0
  17. austial_cli-0.1.5/src/austial/cli/templates/project/app_controller.py.j2 +13 -0
  18. austial_cli-0.1.5/src/austial/cli/templates/project/app_module.py.j2 +19 -0
  19. austial_cli-0.1.5/src/austial/cli/templates/project/app_service.py.j2 +7 -0
  20. austial_cli-0.1.5/src/austial/cli/templates/project/env_example.j2 +2 -0
  21. austial_cli-0.1.5/src/austial/cli/templates/project/gitignore.j2 +11 -0
  22. austial_cli-0.1.5/src/austial/cli/templates/project/main.py.j2 +37 -0
  23. austial_cli-0.1.5/src/austial/cli/templates/project/modules/health/guards/api_key_guard.py.j2 +13 -0
  24. austial_cli-0.1.5/src/austial/cli/templates/project/modules/health/health_controller.py.j2 +20 -0
  25. austial_cli-0.1.5/src/austial/cli/templates/project/modules/health/health_dto.py.j2 +10 -0
  26. austial_cli-0.1.5/src/austial/cli/templates/project/modules/health/health_module.py.j2 +10 -0
  27. austial_cli-0.1.5/src/austial/cli/templates/project/modules/health/health_service.py.j2 +19 -0
  28. austial_cli-0.1.5/src/austial/cli/templates/project/pre_commit_config.yaml.j2 +36 -0
  29. austial_cli-0.1.5/src/austial/cli/templates/project/pyproject.toml.j2 +46 -0
  30. austial_cli-0.1.5/src/austial/cli/templates/project/readme.j2 +68 -0
  31. austial_cli-0.1.5/src/austial/cli/templates/project/tests/e2e/app_e2e_spec.py.j2 +38 -0
  32. austial_cli-0.1.5/src/austial/cli/templates/project/tests/unit/health_service_spec.py.j2 +35 -0
  33. austial_cli-0.1.5/src/austial/cli/templates/resource/controller.py.j2 +33 -0
  34. austial_cli-0.1.5/src/austial/cli/templates/resource/create_dto.py.j2 +5 -0
  35. austial_cli-0.1.5/src/austial/cli/templates/resource/entity.py.j2 +6 -0
  36. austial_cli-0.1.5/src/austial/cli/templates/resource/module.py.j2 +9 -0
  37. austial_cli-0.1.5/src/austial/cli/templates/resource/service.py.j2 +39 -0
  38. austial_cli-0.1.5/src/austial/cli/templates/resource/update_dto.py.j2 +5 -0
  39. austial_cli-0.1.5/src/austial/cli/templates/service/service.py.j2 +6 -0
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.db
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: austial-cli
3
+ Version: 0.1.5
4
+ Summary: The `austial` scaffolding CLI (new/generate) and its Jinja2 project templates -- mirrors the separate @nestjs/cli repo.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: jinja2>=3.1.0
7
+ Requires-Dist: rich>=13.7.0
8
+ Requires-Dist: typer>=0.12.0
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "austial-cli"
3
+ dynamic = ["version"]
4
+ description = "The `austial` scaffolding CLI (new/generate) and its Jinja2 project templates -- mirrors the separate @nestjs/cli repo."
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "jinja2>=3.1.0",
8
+ "rich>=13.7.0",
9
+ "typer>=0.12.0",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["hatchling", "hatch-vcs"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/austial"]
18
+
19
+ [tool.hatch.version]
20
+ source = "vcs"
21
+
22
+ [tool.hatch.version.raw-options]
23
+ root = "../.."
File without changes
@@ -0,0 +1,13 @@
1
+ from austial.cli.generators.controller_gen import generate_controller
2
+ from austial.cli.generators.module_gen import generate_module
3
+ from austial.cli.generators.new_project import create_new_project
4
+ from austial.cli.generators.resource_gen import generate_resource
5
+ from austial.cli.generators.service_gen import generate_service
6
+
7
+ __all__ = [
8
+ "create_new_project",
9
+ "generate_controller",
10
+ "generate_module",
11
+ "generate_resource",
12
+ "generate_service",
13
+ ]
@@ -0,0 +1,61 @@
1
+ """Best-effort patcher that registers a newly generated module into the
2
+ nearest ``app_module.py``, mirroring how the real ``nest`` CLI updates
3
+ ``app.module.ts`` after ``nest g module``/``nest g resource``.
4
+
5
+ Text-based and intentionally conservative: if the file doesn't look like what
6
+ `austial new`/`austial generate` produce, it leaves it alone and tells the
7
+ caller so a manual edit instruction can be printed instead.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+
15
+ _IMPORTS_ARRAY_RE = re.compile(r"imports\s*=\s*\[(.*?)\]", re.DOTALL)
16
+
17
+
18
+ def register_module(app_module_path: Path, class_name: str, import_line: str) -> bool:
19
+ """Returns True if the patch was applied, False if it was already present
20
+ or the file couldn't be safely patched."""
21
+ text = app_module_path.read_text()
22
+
23
+ if import_line in text:
24
+ return False # already registered, nothing to do
25
+
26
+ lines = text.splitlines()
27
+
28
+ # Group the new import with its isort "block" (contiguous run of
29
+ # from/import lines sharing the same top-level package as `import_line`,
30
+ # e.g. all `from src...` lines) and re-sort that block alphabetically, so
31
+ # the patched file stays isort-clean instead of just tacking the new line
32
+ # onto the end of the whole import section.
33
+ package = import_line.split()[1].split(".")[0]
34
+ prefixes = (f"from {package}.", f"import {package}.")
35
+ block_indices = [i for i, line in enumerate(lines) if line.startswith(prefixes)]
36
+
37
+ if block_indices:
38
+ first, last = block_indices[0], block_indices[-1]
39
+ block = [lines[i] for i in block_indices] + [import_line]
40
+ block = sorted(set(block))
41
+ lines[first : last + 1] = block
42
+ else:
43
+ last_import_idx = 0
44
+ for i, line in enumerate(lines):
45
+ if line.startswith("from ") or line.startswith("import "):
46
+ last_import_idx = i
47
+ lines.insert(last_import_idx + 1, import_line)
48
+
49
+ text = "\n".join(lines) + "\n"
50
+
51
+ match = _IMPORTS_ARRAY_RE.search(text)
52
+ if not match:
53
+ app_module_path.write_text(text)
54
+ return False
55
+
56
+ inner = match.group(1)
57
+ new_inner = f"{inner.rstrip()}, {class_name}" if inner.strip() else class_name
58
+ text = text[: match.start(1)] + new_inner + text[match.end(1) :]
59
+
60
+ app_module_path.write_text(text)
61
+ return True
@@ -0,0 +1,61 @@
1
+ """Shared helpers for every generator: template rendering + project-root
2
+ discovery, mirroring what the ``nest`` CLI's schematics engine does."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from jinja2 import Environment, FileSystemLoader
10
+
11
+ TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
12
+
13
+ _env = Environment(
14
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
15
+ keep_trailing_newline=True,
16
+ trim_blocks=True,
17
+ lstrip_blocks=True,
18
+ )
19
+
20
+
21
+ def render(template_relpath: str, **context: Any) -> str:
22
+ template = _env.get_template(template_relpath)
23
+ return template.render(**context)
24
+
25
+
26
+ def write_file(path: Path, content: str, *, force: bool = False) -> bool:
27
+ """Writes `content` to `path`, creating parent directories as needed.
28
+ Returns False (and leaves the file untouched) if it already exists and
29
+ `force` wasn't given -- mirrors `nest g`'s "already exists" guard."""
30
+ if path.exists() and not force:
31
+ return False
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ path.write_text(content)
34
+ return True
35
+
36
+
37
+ def ensure_package_init(directory: Path) -> None:
38
+ """Creates an empty ``__init__.py`` in `directory` (and does *not* recurse
39
+ -- callers create the full chain explicitly) if one doesn't exist yet."""
40
+ init_file = directory / "__init__.py"
41
+ if not init_file.exists():
42
+ directory.mkdir(parents=True, exist_ok=True)
43
+ init_file.write_text("")
44
+
45
+
46
+ class ProjectNotFoundError(Exception):
47
+ pass
48
+
49
+
50
+ def find_project_root(start: Path | None = None) -> Path:
51
+ """Walks upward from `start` (default: cwd) looking for `src/app_module.py`,
52
+ the marker of an Austial project root."""
53
+ current = (start or Path.cwd()).resolve()
54
+ for candidate in (current, *current.parents):
55
+ if (candidate / "src" / "app_module.py").exists():
56
+ return candidate
57
+ raise ProjectNotFoundError(
58
+ "Couldn't find an Austial project in this or any parent directory "
59
+ "(looked for `src/app_module.py`). Run this inside a project created "
60
+ "with `austial new`, or create `src/app_module.py` by hand first."
61
+ )
@@ -0,0 +1,21 @@
1
+ """``austial generate controller <name>`` -- mirrors ``nest g controller``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from austial.cli.generators.base import find_project_root, render, write_file
8
+ from austial.cli.naming import to_kebab_case, to_pascal_case, to_snake_case
9
+
10
+
11
+ def generate_controller(name: str, *, project_root: Path | None = None) -> Path:
12
+ root = project_root or find_project_root()
13
+ snake_name = to_snake_case(name)
14
+ class_name = to_pascal_case(name)
15
+ ctx = {"snake_name": snake_name, "class_name": class_name, "kebab_name": to_kebab_case(name)}
16
+
17
+ module_dir = root / "src" / "modules" / snake_name
18
+ write_file(module_dir / "__init__.py", "")
19
+ file_path = module_dir / f"{snake_name}_controller.py"
20
+ write_file(file_path, render("controller/controller.py.j2", **ctx))
21
+ return file_path
@@ -0,0 +1,30 @@
1
+ """``austial generate module <name>`` -- mirrors ``nest g module``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from austial.cli.generators.app_module_patcher import register_module
8
+ from austial.cli.generators.base import find_project_root, render, write_file
9
+ from austial.cli.naming import to_kebab_case, to_pascal_case, to_snake_case
10
+
11
+
12
+ def generate_module(name: str, *, project_root: Path | None = None) -> Path:
13
+ root = project_root or find_project_root()
14
+ snake_name = to_snake_case(name)
15
+ class_name = to_pascal_case(name)
16
+ ctx = {"snake_name": snake_name, "class_name": class_name, "kebab_name": to_kebab_case(name)}
17
+
18
+ module_dir = root / "src" / "modules" / snake_name
19
+ write_file(module_dir / "__init__.py", "")
20
+ file_path = module_dir / f"{snake_name}_module.py"
21
+ write_file(file_path, render("module/module.py.j2", **ctx))
22
+
23
+ app_module_path = root / "src" / "app_module.py"
24
+ if app_module_path.exists():
25
+ register_module(
26
+ app_module_path,
27
+ f"{class_name}Module",
28
+ f"from src.modules.{snake_name}.{snake_name}_module import {class_name}Module",
29
+ )
30
+ return file_path
@@ -0,0 +1,67 @@
1
+ """``austial new <name>`` -- mirrors ``nest new <name>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from austial.cli.generators.base import render, write_file
9
+ from austial.cli.naming import to_kebab_case
10
+
11
+
12
+ def create_new_project(
13
+ name: str,
14
+ *,
15
+ directory: Path | None = None,
16
+ austial_source_path: str | None = None,
17
+ skip_install: bool = False,
18
+ skip_git: bool = False,
19
+ ) -> Path:
20
+ project_dir = (directory or Path.cwd() / name).resolve()
21
+ project_slug = to_kebab_case(name)
22
+ ctx = {"project_name": name, "project_slug": project_slug, "austial_source_path": austial_source_path}
23
+
24
+ write_file(project_dir / "pyproject.toml", render("project/pyproject.toml.j2", **ctx))
25
+ write_file(project_dir / "README.md", render("project/readme.j2", **ctx))
26
+ write_file(project_dir / ".env.example", render("project/env_example.j2", **ctx))
27
+ write_file(project_dir / ".gitignore", render("project/gitignore.j2", **ctx))
28
+ write_file(project_dir / ".pre-commit-config.yaml", render("project/pre_commit_config.yaml.j2", **ctx))
29
+
30
+ write_file(project_dir / "src" / "__init__.py", "")
31
+ write_file(project_dir / "src" / "main.py", render("project/main.py.j2", **ctx))
32
+ write_file(project_dir / "src" / "app_module.py", render("project/app_module.py.j2", **ctx))
33
+ write_file(project_dir / "src" / "app_controller.py", render("project/app_controller.py.j2", **ctx))
34
+ write_file(project_dir / "src" / "app_service.py", render("project/app_service.py.j2", **ctx))
35
+
36
+ write_file(project_dir / "src" / "modules" / "__init__.py", "")
37
+ health_dir = project_dir / "src" / "modules" / "health"
38
+ write_file(health_dir / "__init__.py", "")
39
+ write_file(health_dir / "health_module.py", render("project/modules/health/health_module.py.j2", **ctx))
40
+ write_file(health_dir / "health_controller.py", render("project/modules/health/health_controller.py.j2", **ctx))
41
+ write_file(health_dir / "health_service.py", render("project/modules/health/health_service.py.j2", **ctx))
42
+ write_file(health_dir / "health_dto.py", render("project/modules/health/health_dto.py.j2", **ctx))
43
+ write_file(health_dir / "guards" / "__init__.py", "")
44
+ write_file(
45
+ health_dir / "guards" / "api_key_guard.py",
46
+ render("project/modules/health/guards/api_key_guard.py.j2", **ctx),
47
+ )
48
+
49
+ write_file(project_dir / "tests" / "__init__.py", "")
50
+ write_file(project_dir / "tests" / "unit" / "__init__.py", "")
51
+ write_file(project_dir / "tests" / "e2e" / "__init__.py", "")
52
+ write_file(
53
+ project_dir / "tests" / "unit" / "health_service_spec.py",
54
+ render("project/tests/unit/health_service_spec.py.j2", **ctx),
55
+ )
56
+ write_file(
57
+ project_dir / "tests" / "e2e" / "app_e2e_spec.py",
58
+ render("project/tests/e2e/app_e2e_spec.py.j2", **ctx),
59
+ )
60
+
61
+ if not skip_git:
62
+ subprocess.run(["git", "init", "-q"], cwd=project_dir, check=False)
63
+
64
+ if not skip_install:
65
+ subprocess.run(["uv", "sync"], cwd=project_dir, check=False)
66
+
67
+ return project_dir
@@ -0,0 +1,39 @@
1
+ """``austial generate resource <name>`` -- mirrors ``nest g resource``:
2
+ scaffolds a full CRUD module (module + controller + service + dto + entity)
3
+ in one shot and registers it into the nearest ``app_module.py``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from austial.cli.generators.app_module_patcher import register_module
10
+ from austial.cli.generators.base import find_project_root, render, write_file
11
+ from austial.cli.naming import to_kebab_case, to_pascal_case, to_snake_case
12
+
13
+
14
+ def generate_resource(name: str, *, project_root: Path | None = None) -> Path:
15
+ root = project_root or find_project_root()
16
+ snake_name = to_snake_case(name)
17
+ class_name = to_pascal_case(name)
18
+ ctx = {"snake_name": snake_name, "class_name": class_name, "kebab_name": to_kebab_case(name)}
19
+
20
+ module_dir = root / "src" / "modules" / snake_name
21
+ write_file(module_dir / "__init__.py", "")
22
+ write_file(module_dir / "dto" / "__init__.py", "")
23
+ write_file(module_dir / "entities" / "__init__.py", "")
24
+
25
+ write_file(module_dir / "entities" / f"{snake_name}_entity.py", render("resource/entity.py.j2", **ctx))
26
+ write_file(module_dir / "dto" / f"create_{snake_name}_dto.py", render("resource/create_dto.py.j2", **ctx))
27
+ write_file(module_dir / "dto" / f"update_{snake_name}_dto.py", render("resource/update_dto.py.j2", **ctx))
28
+ write_file(module_dir / f"{snake_name}_service.py", render("resource/service.py.j2", **ctx))
29
+ write_file(module_dir / f"{snake_name}_controller.py", render("resource/controller.py.j2", **ctx))
30
+ write_file(module_dir / f"{snake_name}_module.py", render("resource/module.py.j2", **ctx))
31
+
32
+ app_module_path = root / "src" / "app_module.py"
33
+ if app_module_path.exists():
34
+ register_module(
35
+ app_module_path,
36
+ f"{class_name}Module",
37
+ f"from src.modules.{snake_name}.{snake_name}_module import {class_name}Module",
38
+ )
39
+ return module_dir / f"{snake_name}_module.py"
@@ -0,0 +1,21 @@
1
+ """``austial generate service <name>`` -- mirrors ``nest g service``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from austial.cli.generators.base import find_project_root, render, write_file
8
+ from austial.cli.naming import to_kebab_case, to_pascal_case, to_snake_case
9
+
10
+
11
+ def generate_service(name: str, *, project_root: Path | None = None) -> Path:
12
+ root = project_root or find_project_root()
13
+ snake_name = to_snake_case(name)
14
+ class_name = to_pascal_case(name)
15
+ ctx = {"snake_name": snake_name, "class_name": class_name, "kebab_name": to_kebab_case(name)}
16
+
17
+ module_dir = root / "src" / "modules" / snake_name
18
+ write_file(module_dir / "__init__.py", "")
19
+ file_path = module_dir / f"{snake_name}_service.py"
20
+ write_file(file_path, render("service/service.py.j2", **ctx))
21
+ return file_path
@@ -0,0 +1,133 @@
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 serve
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import typer
16
+ from rich.console import Console
17
+
18
+ from austial.cli.generators import (
19
+ create_new_project,
20
+ generate_controller,
21
+ generate_module,
22
+ generate_resource,
23
+ generate_service,
24
+ )
25
+ from austial.cli.generators.base import ProjectNotFoundError, find_project_root
26
+
27
+ console = Console()
28
+ app = typer.Typer(
29
+ name="austial",
30
+ help="Austial CLI -- scaffold and run NestJS-style Python projects, built on FastAPI.",
31
+ add_completion=False,
32
+ no_args_is_help=True,
33
+ )
34
+
35
+ _SCHEMATICS = {
36
+ "module": generate_module,
37
+ "controller": generate_controller,
38
+ "service": generate_service,
39
+ "resource": generate_resource,
40
+ }
41
+
42
+
43
+ @app.command("new")
44
+ def new_project(
45
+ name: str = typer.Argument(..., help="Name of the new project/directory to create."),
46
+ directory: str | None = typer.Option(None, "--directory", "-d", help="Target directory (defaults to ./<name>)."),
47
+ skip_install: bool = typer.Option(False, "--skip-install", help="Don't run `uv sync` after scaffolding."),
48
+ skip_git: bool = typer.Option(False, "--skip-git", help="Don't run `git init` after scaffolding."),
49
+ link: str | None = typer.Option(
50
+ None,
51
+ "--link",
52
+ help="Path to a local Austial framework checkout to depend on via `uv`'s editable path sources "
53
+ "(useful before the framework is published to PyPI).",
54
+ ),
55
+ ) -> None:
56
+ """Scaffold a brand-new Austial project -- mirrors `nest new <name>`."""
57
+ console.print(f"[bold green]CREATE[/bold green] {name}/ ...")
58
+ target = Path(directory).resolve() if directory else None
59
+ project_dir = create_new_project(
60
+ name,
61
+ directory=target,
62
+ austial_source_path=link,
63
+ skip_install=skip_install,
64
+ skip_git=skip_git,
65
+ )
66
+ console.print(f"[bold green]\u2714[/bold green] Project created at [bold]{project_dir}[/bold]")
67
+ console.print("\nNext steps:")
68
+ console.print(f" cd {project_dir.name}")
69
+ if skip_install:
70
+ console.print(" uv sync")
71
+ console.print(" cp .env.example .env")
72
+ console.print(" uv run austial serve\n")
73
+
74
+
75
+ def _generate(schematic: str, name: str) -> None:
76
+ schematic = schematic.lower()
77
+ if schematic not in _SCHEMATICS:
78
+ console.print(f"[bold red]Unknown schematic[/bold red] '{schematic}'. Choose one of: {', '.join(_SCHEMATICS)}.")
79
+ raise typer.Exit(code=1)
80
+ try:
81
+ path = _SCHEMATICS[schematic](name)
82
+ except ProjectNotFoundError as exc:
83
+ console.print(f"[bold red]\u2716[/bold red] {exc}")
84
+ raise typer.Exit(code=1) from exc
85
+ console.print(f"[bold green]CREATE[/bold green] {path}")
86
+
87
+
88
+ @app.command("generate")
89
+ def generate(
90
+ schematic: str = typer.Argument(..., help="One of: module, controller, service, resource"),
91
+ name: str = typer.Argument(..., help="Name for the generated artifact, e.g. 'cats'."),
92
+ ) -> None:
93
+ """Generate a module/controller/service/resource -- mirrors `nest generate`."""
94
+ _generate(schematic, name)
95
+
96
+
97
+ @app.command("g", hidden=True)
98
+ def generate_alias(
99
+ schematic: str = typer.Argument(..., help="One of: module, controller, service, resource"),
100
+ name: str = typer.Argument(..., help="Name for the generated artifact, e.g. 'cats'."),
101
+ ) -> None:
102
+ """Alias for `generate` -- mirrors Nest CLI's `nest g`."""
103
+ _generate(schematic, name)
104
+
105
+
106
+ @app.command("serve")
107
+ def serve(
108
+ host: str = typer.Option("0.0.0.0", help="Bind host."),
109
+ port: int = typer.Option(8000, help="Bind port."),
110
+ reload: bool = typer.Option(True, help="Auto-reload on file changes (dev mode)."),
111
+ ) -> None:
112
+ """Runs the current project (mirrors `nest start --watch`)."""
113
+ import uvicorn
114
+
115
+ try:
116
+ project_root = find_project_root()
117
+ except ProjectNotFoundError as exc:
118
+ console.print(f"[bold red]\u2716[/bold red] {exc}")
119
+ raise typer.Exit(code=1) from exc
120
+
121
+ import sys
122
+
123
+ sys.path.insert(0, str(project_root))
124
+ console.print(f"[bold green]\u25b6[/bold green] Serving {project_root.name} on http://{host}:{port}")
125
+ uvicorn.run("src.main:app", host=host, port=port, reload=reload, app_dir=str(project_root))
126
+
127
+
128
+ def run() -> None:
129
+ app()
130
+
131
+
132
+ if __name__ == "__main__":
133
+ run()
@@ -0,0 +1,33 @@
1
+ """Naming helpers shared by every generator -- turns whatever the user typed
2
+ (``cats``, ``cat-breed``, ``CatBreed``, ``cat_breed``) into the forms needed
3
+ for file names, class names and route prefixes, mirroring how ``nest g``
4
+ normalizes its ``<name>`` argument."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ _BOUNDARY_RE = re.compile(r"[-_\s]+|(?<=[a-z0-9])(?=[A-Z])")
11
+
12
+
13
+ def _words(name: str) -> list[str]:
14
+ return [w for w in _BOUNDARY_RE.split(name.strip()) if w]
15
+
16
+
17
+ def to_snake_case(name: str) -> str:
18
+ return "_".join(w.lower() for w in _words(name))
19
+
20
+
21
+ def to_kebab_case(name: str) -> str:
22
+ return "-".join(w.lower() for w in _words(name))
23
+
24
+
25
+ def to_pascal_case(name: str) -> str:
26
+ return "".join(w.capitalize() for w in _words(name))
27
+
28
+
29
+ def to_camel_case(name: str) -> str:
30
+ words = _words(name)
31
+ if not words:
32
+ return name
33
+ return words[0].lower() + "".join(w.capitalize() for w in words[1:])
@@ -0,0 +1,8 @@
1
+ from austial import Controller, Get
2
+
3
+
4
+ @Controller("{{ kebab_name }}")
5
+ class {{ class_name }}Controller:
6
+ @Get()
7
+ async def find_all(self):
8
+ return []
@@ -0,0 +1,6 @@
1
+ from austial import Module
2
+
3
+
4
+ @Module()
5
+ class {{ class_name }}Module:
6
+ pass
@@ -0,0 +1,13 @@
1
+ from austial import Controller, Get
2
+
3
+ from src.app_service import AppService
4
+
5
+
6
+ @Controller()
7
+ class AppController:
8
+ def __init__(self, app_service: AppService):
9
+ self.app_service = app_service
10
+
11
+ @Get()
12
+ async def get_hello(self):
13
+ return self.app_service.get_hello()
@@ -0,0 +1,19 @@
1
+ """Root application module -- mirrors Nest's ``src/app.module.ts``."""
2
+
3
+ from austial import Module
4
+ from austial.common.middleware import LoggingMiddleware, MiddlewareConsumer
5
+ from austial.config import ConfigModule
6
+
7
+ from src.app_controller import AppController
8
+ from src.app_service import AppService
9
+ from src.modules.health.health_module import HealthModule
10
+
11
+
12
+ @Module(
13
+ imports=[ConfigModule.for_root(), HealthModule],
14
+ controllers=[AppController],
15
+ providers=[AppService],
16
+ )
17
+ class AppModule:
18
+ def configure(self, consumer: MiddlewareConsumer) -> None:
19
+ consumer.apply(LoggingMiddleware).for_routes("*")
@@ -0,0 +1,7 @@
1
+ from austial import Injectable
2
+
3
+
4
+ @Injectable()
5
+ class AppService:
6
+ def get_hello(self) -> dict:
7
+ return {"message": "Hello from {{ project_name }}, built with Austial!"}
@@ -0,0 +1,2 @@
1
+ PORT=8000
2
+ API_KEY=changeme
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.db
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
@@ -0,0 +1,37 @@
1
+ """Application entry point -- mirrors Nest's ``src/main.ts``.
2
+
3
+ Works two ways, just like a FastAPI app:
4
+
5
+ * ``python src/main.py`` -> runs ``bootstrap()``, Nest-style ``app.listen()``
6
+ * ``uvicorn src.main:app --reload`` -> `app` is already a real ASGI callable
7
+
8
+ Note: building the app graph (module scanning + DI wiring) is synchronous --
9
+ only ``app.listen()`` (actually starting the ASGI server) is async, mirroring
10
+ where real async work happens in Nest too.
11
+ """
12
+
13
+ import asyncio
14
+
15
+ from austial import AustialApplication, AustialFactory, ValidationPipe
16
+ from austial.common.filters import AllExceptionsFilter
17
+
18
+ from src.app_module import AppModule
19
+
20
+
21
+ def _build() -> AustialApplication:
22
+ app = AustialFactory.create(AppModule, title="{{ project_name }}")
23
+ app.use_global_pipes(ValidationPipe())
24
+ app.use_global_filters(AllExceptionsFilter())
25
+ app.enable_cors()
26
+ return app
27
+
28
+
29
+ app = _build()
30
+
31
+
32
+ async def bootstrap() -> None:
33
+ await app.listen(8000)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ asyncio.run(bootstrap())
@@ -0,0 +1,13 @@
1
+ import os
2
+
3
+ from austial import CanActivate, ExecutionContext
4
+
5
+
6
+ class ApiKeyGuard(CanActivate):
7
+ """Demo guard -- protects a route with a static ``x-api-key`` header,
8
+ the value coming from the ``API_KEY`` environment variable."""
9
+
10
+ async def can_activate(self, context: ExecutionContext) -> bool:
11
+ request = context.get_request()
12
+ expected = os.getenv("API_KEY", "changeme")
13
+ return request.headers.get("x-api-key") == expected
@@ -0,0 +1,20 @@
1
+ from austial import Controller, Get, UseGuards
2
+
3
+ from src.modules.health.guards.api_key_guard import ApiKeyGuard
4
+ from src.modules.health.health_dto import HealthResponseDto
5
+ from src.modules.health.health_service import HealthService
6
+
7
+
8
+ @Controller("health")
9
+ class HealthController:
10
+ def __init__(self, health_service: HealthService):
11
+ self.health_service = health_service
12
+
13
+ @Get()
14
+ async def check(self) -> HealthResponseDto:
15
+ return await self.health_service.check()
16
+
17
+ @Get("protected")
18
+ @UseGuards(ApiKeyGuard)
19
+ async def protected(self):
20
+ return {"message": "You presented a valid x-api-key header."}
@@ -0,0 +1,10 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class HealthResponseDto(BaseModel):
7
+ status: str
8
+ info: dict[str, Any] = {}
9
+ error: dict[str, Any] = {}
10
+ details: dict[str, Any] = {}
@@ -0,0 +1,10 @@
1
+ from austial import Module
2
+
3
+ from src.modules.health.guards.api_key_guard import ApiKeyGuard
4
+ from src.modules.health.health_controller import HealthController
5
+ from src.modules.health.health_service import HealthService
6
+
7
+
8
+ @Module(controllers=[HealthController], providers=[HealthService, ApiKeyGuard])
9
+ class HealthModule:
10
+ pass
@@ -0,0 +1,19 @@
1
+ from austial import Injectable
2
+ from austial.terminus import HealthCheckService, MemoryHealthIndicator
3
+
4
+ from src.modules.health.health_dto import HealthResponseDto
5
+
6
+
7
+ @Injectable()
8
+ class HealthService:
9
+ def __init__(self, health_check_service: HealthCheckService, memory: MemoryHealthIndicator):
10
+ self.health_check_service = health_check_service
11
+ self.memory = memory
12
+
13
+ async def check(self) -> HealthResponseDto:
14
+ result = await self.health_check_service.check(
15
+ [
16
+ lambda: self.memory.check_heap("memory_heap", 300 * 1024 * 1024),
17
+ ]
18
+ )
19
+ return HealthResponseDto(**result)
@@ -0,0 +1,36 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-merge-conflict
10
+ - id: check-added-large-files
11
+ - id: mixed-line-ending
12
+ - id: debug-statements
13
+
14
+ - repo: https://github.com/astral-sh/ruff-pre-commit
15
+ rev: v0.15.21
16
+ hooks:
17
+ - id: ruff
18
+ args: [--fix]
19
+ - id: ruff-format
20
+
21
+ - repo: local
22
+ hooks:
23
+ - id: mypy
24
+ name: mypy
25
+ entry: uv run mypy src tests
26
+ language: system
27
+ pass_filenames: false
28
+ types: [python]
29
+
30
+ - id: pytest
31
+ name: pytest
32
+ entry: uv run pytest -q
33
+ language: system
34
+ pass_filenames: false
35
+ types: [python]
36
+ stages: [pre-push]
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "{{ project_slug }}"
3
+ version = "0.1.0"
4
+ description = "Generated with the Austial CLI (`austial new`)."
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "austial>=0.1.0",
8
+ ]
9
+
10
+ [project.optional-dependencies]
11
+ dev = [
12
+ "pytest>=8.2.0",
13
+ "pytest-asyncio>=0.23.0",
14
+ "httpx>=0.27.0",
15
+ "ruff>=0.6.0",
16
+ "mypy>=1.11.0",
17
+ "pre-commit>=3.7.0",
18
+ ]
19
+ {% if austial_source_path %}
20
+
21
+ [tool.uv.sources]
22
+ austial = { path = "{{ austial_source_path }}", editable = true }
23
+ {% endif %}
24
+
25
+ [tool.pytest.ini_options]
26
+ asyncio_mode = "auto"
27
+ testpaths = ["tests"]
28
+ pythonpath = ["."]
29
+ python_files = ["*_spec.py", "test_*.py", "*_test.py"]
30
+
31
+ [tool.ruff]
32
+ line-length = 120
33
+ target-version = "py311"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "UP", "B", "SIM"]
37
+ ignore = ["B008"] # function calls in default args (`Body()`, `Query()`, ... -- FastAPI's own idiom)
38
+
39
+ [tool.ruff.lint.isort]
40
+ known-first-party = ["src"]
41
+
42
+ [tool.mypy]
43
+ python_version = "3.11"
44
+ ignore_missing_imports = true
45
+ check_untyped_defs = true
46
+ warn_unused_ignores = true
@@ -0,0 +1,68 @@
1
+ # {{ project_name }}
2
+
3
+ Generated with the [Austial](https://github.com/) CLI -- a NestJS-style, batteries-included
4
+ web framework for Python, built on FastAPI.
5
+
6
+ ## Project structure
7
+
8
+ ```
9
+ src/
10
+ ├── main.py # entry point (mirrors Nest's main.ts)
11
+ ├── app_module.py # root module (mirrors app.module.ts)
12
+ ├── app_controller.py
13
+ ├── app_service.py
14
+ └── modules/
15
+ └── health/ # GET /health, GET /health/protected
16
+ tests/
17
+ ├── unit/
18
+ └── e2e/
19
+ ```
20
+
21
+ > Note: Python can't import filenames containing dots the way Node resolves
22
+ > `health.controller.ts`, so Austial uses `health_controller.py` /
23
+ > `health_service.py` / `health_module.py` instead -- same one-folder-per-feature
24
+ > layout as Nest, just underscore-suffixed file names.
25
+
26
+ ## Running it
27
+
28
+ ```bash
29
+ uv sync
30
+ cp .env.example .env
31
+ uv run austial serve # http://localhost:8000, auto-reload
32
+ # or
33
+ uv run python -m src.main
34
+ ```
35
+
36
+ - `GET /health` -> terminus-style health check
37
+ - `GET /health/protected` -> requires `x-api-key: <API_KEY from .env>`
38
+ - `GET /docs` -> Swagger UI (free from FastAPI)
39
+
40
+ ## Generating more artifacts
41
+
42
+ ```bash
43
+ uv run austial generate module cats
44
+ uv run austial generate controller cats
45
+ uv run austial generate service cats
46
+ uv run austial generate resource cats # module + CRUD controller + service + dto
47
+ ```
48
+
49
+ ## Tests
50
+
51
+ ```bash
52
+ uv run pytest
53
+ ```
54
+
55
+ ## Linting, type-checking & pre-commit hooks
56
+
57
+ ```bash
58
+ uv run ruff check . # lint
59
+ uv run ruff format . # format
60
+ uv run mypy src tests # type-check
61
+
62
+ uv run pre-commit install --hook-type pre-commit --hook-type pre-push
63
+ uv run pre-commit run --all-files # run every hook once, on demand
64
+ ```
65
+
66
+ Once installed, `git commit` runs formatting/linting/type-checks automatically,
67
+ and `git push` also runs the test suite -- mirroring a typical Nest project's
68
+ husky + lint-staged setup.
@@ -0,0 +1,38 @@
1
+ """End-to-end test -- mirrors a Nest ``*.e2e-spec.ts`` test: spin up a real
2
+ (in-process) app via ``austial.testing`` + ``httpx``'s ASGI transport, and hit
3
+ routes exactly like a client would, no mocking of the HTTP layer at all.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import pytest
9
+ from austial.testing import Test
10
+ from httpx import ASGITransport, AsyncClient
11
+
12
+ from src.app_controller import AppController
13
+ from src.app_service import AppService
14
+ from src.modules.health.health_module import HealthModule
15
+
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_root_and_health_endpoints():
19
+ module = await Test.create_testing_module(
20
+ imports=[HealthModule],
21
+ controllers=[AppController],
22
+ providers=[AppService],
23
+ ).compile()
24
+ app = module.create_austial_application()
25
+ await app.init()
26
+
27
+ transport = ASGITransport(app=app)
28
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
29
+ root_response = await client.get("/")
30
+ assert root_response.status_code == 200
31
+ assert "message" in root_response.json()
32
+
33
+ health_response = await client.get("/health")
34
+ assert health_response.status_code == 200
35
+ assert health_response.json()["status"] == "ok"
36
+
37
+ protected_denied = await client.get("/health/protected")
38
+ assert protected_denied.status_code == 403
@@ -0,0 +1,35 @@
1
+ """Unit test for ``HealthService`` -- mirrors a typical Nest ``*.spec.ts``
2
+ unit test: build the provider straight from the DI container (via
3
+ ``austial.testing``) instead of hand-wiring mocks, then assert on its output.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import pytest
9
+ from austial.testing import Test
10
+
11
+ from src.modules.health.health_module import HealthModule
12
+ from src.modules.health.health_service import HealthService
13
+
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_health_service_reports_ok_status():
17
+ module = await Test.create_testing_module(imports=[HealthModule]).compile()
18
+ service = module.get(HealthService)
19
+
20
+ result = await service.check()
21
+
22
+ assert result.status == "ok"
23
+ assert result.error == {}
24
+ assert "memory_heap" in result.info
25
+ assert result.info["memory_heap"]["status"] == "up"
26
+ assert result.details == result.info
27
+
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_health_service_is_resolved_as_singleton():
31
+ module = await Test.create_testing_module(imports=[HealthModule]).compile()
32
+ first = module.get(HealthService)
33
+ second = module.get(HealthService)
34
+
35
+ assert first is second
@@ -0,0 +1,33 @@
1
+ from austial import Body, Controller, Delete, Get, Patch, Post
2
+
3
+ from src.modules.{{ snake_name }}.{{ snake_name }}_service import {{ class_name }}Service
4
+ from src.modules.{{ snake_name }}.dto.create_{{ snake_name }}_dto import Create{{ class_name }}Dto
5
+ from src.modules.{{ snake_name }}.dto.update_{{ snake_name }}_dto import Update{{ class_name }}Dto
6
+ from src.modules.{{ snake_name }}.entities.{{ snake_name }}_entity import {{ class_name }}
7
+
8
+
9
+ @Controller("{{ kebab_name }}")
10
+ class {{ class_name }}Controller:
11
+ def __init__(self, service: {{ class_name }}Service):
12
+ self.service = service
13
+
14
+ @Post()
15
+ async def create(self, dto: Create{{ class_name }}Dto = Body()) -> {{ class_name }}:
16
+ return self.service.create(dto)
17
+
18
+ @Get()
19
+ async def find_all(self) -> list[{{ class_name }}]:
20
+ return self.service.find_all()
21
+
22
+ @Get(":id")
23
+ async def find_one(self, id: int) -> {{ class_name }}:
24
+ return self.service.find_one(id)
25
+
26
+ @Patch(":id")
27
+ async def update(self, id: int, dto: Update{{ class_name }}Dto = Body()) -> {{ class_name }}:
28
+ return self.service.update(id, dto)
29
+
30
+ @Delete(":id")
31
+ async def remove(self, id: int) -> dict:
32
+ self.service.remove(id)
33
+ return {"deleted": True}
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Create{{ class_name }}Dto(BaseModel):
5
+ name: str
@@ -0,0 +1,6 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class {{ class_name }}(BaseModel):
5
+ id: int
6
+ name: str
@@ -0,0 +1,9 @@
1
+ from austial import Module
2
+
3
+ from src.modules.{{ snake_name }}.{{ snake_name }}_controller import {{ class_name }}Controller
4
+ from src.modules.{{ snake_name }}.{{ snake_name }}_service import {{ class_name }}Service
5
+
6
+
7
+ @Module(controllers=[{{ class_name }}Controller], providers=[{{ class_name }}Service])
8
+ class {{ class_name }}Module:
9
+ pass
@@ -0,0 +1,39 @@
1
+ from austial import Injectable, NotFoundException
2
+
3
+ from src.modules.{{ snake_name }}.dto.create_{{ snake_name }}_dto import Create{{ class_name }}Dto
4
+ from src.modules.{{ snake_name }}.dto.update_{{ snake_name }}_dto import Update{{ class_name }}Dto
5
+ from src.modules.{{ snake_name }}.entities.{{ snake_name }}_entity import {{ class_name }}
6
+
7
+
8
+ @Injectable()
9
+ class {{ class_name }}Service:
10
+ """In-memory CRUD store -- swap this out for a real repository backed by
11
+ ``austial.orm`` (SQLAlchemy) whenever you're ready."""
12
+
13
+ def __init__(self):
14
+ self._items: list[{{ class_name }}] = []
15
+ self._next_id = 1
16
+
17
+ def create(self, dto: Create{{ class_name }}Dto) -> {{ class_name }}:
18
+ item = {{ class_name }}(id=self._next_id, **dto.model_dump())
19
+ self._next_id += 1
20
+ self._items.append(item)
21
+ return item
22
+
23
+ def find_all(self) -> list[{{ class_name }}]:
24
+ return self._items
25
+
26
+ def find_one(self, id: int) -> {{ class_name }}:
27
+ for item in self._items:
28
+ if item.id == id:
29
+ return item
30
+ raise NotFoundException(f"{{ class_name }} #{id} not found")
31
+
32
+ def update(self, id: int, dto: Update{{ class_name }}Dto) -> {{ class_name }}:
33
+ item = self.find_one(id)
34
+ updated = item.model_copy(update=dto.model_dump(exclude_unset=True))
35
+ self._items[self._items.index(item)] = updated
36
+ return updated
37
+
38
+ def remove(self, id: int) -> None:
39
+ self._items.remove(self.find_one(id))
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Update{{ class_name }}Dto(BaseModel):
5
+ name: str | None = None
@@ -0,0 +1,6 @@
1
+ from austial import Injectable
2
+
3
+
4
+ @Injectable()
5
+ class {{ class_name }}Service:
6
+ pass