austial-cli 0.1.5__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.
- austial/cli/__init__.py +0 -0
- austial/cli/generators/__init__.py +13 -0
- austial/cli/generators/app_module_patcher.py +61 -0
- austial/cli/generators/base.py +61 -0
- austial/cli/generators/controller_gen.py +21 -0
- austial/cli/generators/module_gen.py +30 -0
- austial/cli/generators/new_project.py +67 -0
- austial/cli/generators/resource_gen.py +39 -0
- austial/cli/generators/service_gen.py +21 -0
- austial/cli/main.py +133 -0
- austial/cli/naming.py +33 -0
- austial/cli/templates/controller/controller.py.j2 +8 -0
- austial/cli/templates/module/module.py.j2 +6 -0
- austial/cli/templates/project/app_controller.py.j2 +13 -0
- austial/cli/templates/project/app_module.py.j2 +19 -0
- austial/cli/templates/project/app_service.py.j2 +7 -0
- austial/cli/templates/project/env_example.j2 +2 -0
- austial/cli/templates/project/gitignore.j2 +11 -0
- austial/cli/templates/project/main.py.j2 +37 -0
- austial/cli/templates/project/modules/health/guards/api_key_guard.py.j2 +13 -0
- austial/cli/templates/project/modules/health/health_controller.py.j2 +20 -0
- austial/cli/templates/project/modules/health/health_dto.py.j2 +10 -0
- austial/cli/templates/project/modules/health/health_module.py.j2 +10 -0
- austial/cli/templates/project/modules/health/health_service.py.j2 +19 -0
- austial/cli/templates/project/pre_commit_config.yaml.j2 +36 -0
- austial/cli/templates/project/pyproject.toml.j2 +46 -0
- austial/cli/templates/project/readme.j2 +68 -0
- austial/cli/templates/project/tests/e2e/app_e2e_spec.py.j2 +38 -0
- austial/cli/templates/project/tests/unit/health_service_spec.py.j2 +35 -0
- austial/cli/templates/resource/controller.py.j2 +33 -0
- austial/cli/templates/resource/create_dto.py.j2 +5 -0
- austial/cli/templates/resource/entity.py.j2 +6 -0
- austial/cli/templates/resource/module.py.j2 +9 -0
- austial/cli/templates/resource/service.py.j2 +39 -0
- austial/cli/templates/resource/update_dto.py.j2 +5 -0
- austial/cli/templates/service/service.py.j2 +6 -0
- austial_cli-0.1.5.dist-info/METADATA +8 -0
- austial_cli-0.1.5.dist-info/RECORD +39 -0
- austial_cli-0.1.5.dist-info/WHEEL +4 -0
austial/cli/__init__.py
ADDED
|
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
|
austial/cli/main.py
ADDED
|
@@ -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()
|
austial/cli/naming.py
ADDED
|
@@ -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,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,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 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,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,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,39 @@
|
|
|
1
|
+
austial/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
austial/cli/main.py,sha256=tJJwLzYsEKxpmOfCKOmrEFugiXo9cYlUNMr1xnDw310,4482
|
|
3
|
+
austial/cli/naming.py,sha256=YoRPUVepC7vkkWIh5fNemQnCLo_ZlxWGLdtVd1hU4Xc,931
|
|
4
|
+
austial/cli/generators/__init__.py,sha256=oGrEGo9Ea-EfchWQRkS1EKhaQS30pmbcE3HxP_7SRpA,468
|
|
5
|
+
austial/cli/generators/app_module_patcher.py,sha256=Ye_g-NXGfoBBZMZfE3G5Y0S-o5ix9WQk1E3XREdx6Og,2340
|
|
6
|
+
austial/cli/generators/base.py,sha256=2q_7i8cw4AabKGGbfsIaJT4OzXKvWBnV37FWCxXl5z4,2158
|
|
7
|
+
austial/cli/generators/controller_gen.py,sha256=OnJxa2iVTTsEmRNjzcOpn5CNNreoSivHpl71nohg0RQ,854
|
|
8
|
+
austial/cli/generators/module_gen.py,sha256=51VA_TnUtMNeUl2qW5pex6LYMmZYjhFBuaNXx5dmSCQ,1178
|
|
9
|
+
austial/cli/generators/new_project.py,sha256=uwY3To8OAwh0HVO_NReSq8XMjnUwYc4sCF3g4CxRAK0,3077
|
|
10
|
+
austial/cli/generators/resource_gen.py,sha256=ieirmz9BpyocmwENCOO7zhqyHlfBay0MeYTmR0zHWJ8,1976
|
|
11
|
+
austial/cli/generators/service_gen.py,sha256=wiJrMPQwlODBCO583d1WqfueckaCHnleztT636iuEAs,836
|
|
12
|
+
austial/cli/templates/controller/controller.py.j2,sha256=gtpDJbhcnFcAKgdzHiQ_bDY4aXL4WO-1a-BcA4zN--Q,163
|
|
13
|
+
austial/cli/templates/module/module.py.j2,sha256=zVXUT7K2ZPI3MwCI7D0We8QlamBBydoAjKX3mk5c8qg,78
|
|
14
|
+
austial/cli/templates/project/app_controller.py.j2,sha256=K74n7LaAkchOlCNsLkeW5-Y7L7HgYSs5VH21KCeDm5o,288
|
|
15
|
+
austial/cli/templates/project/app_module.py.j2,sha256=2ihi2Py-UZyKKp4lrx62CJgvQt2KPfZV7jsXDsEf7N8,623
|
|
16
|
+
austial/cli/templates/project/app_service.py.j2,sha256=mjmpGFx0qHYPyI-Rp-yqDPtgALvtUe5aJljQVuT4IeM,179
|
|
17
|
+
austial/cli/templates/project/env_example.j2,sha256=K8oEhPY1jDh01wRAnsYG35f-x07I1QepMBOv_mgEao0,27
|
|
18
|
+
austial/cli/templates/project/gitignore.j2,sha256=ijvFZR8ycFaQMN_WooPsFuptCcYJjHTmva6fT9og7FU,99
|
|
19
|
+
austial/cli/templates/project/main.py.j2,sha256=Hx3fRzHE6olZpQ5J5BEso8l0IhzWruy385rZzBkwNgU,1030
|
|
20
|
+
austial/cli/templates/project/pre_commit_config.yaml.j2,sha256=YKY3UqGTaR1elShJ-n1aYhcW3FVsrwNk01K3jVxwtGA,834
|
|
21
|
+
austial/cli/templates/project/pyproject.toml.j2,sha256=S82kU9DCb9-k2GhB5UJY8OWvOkJua768DDZ7HHILi_0,1029
|
|
22
|
+
austial/cli/templates/project/readme.j2,sha256=1Sxa1jLpnMwYrJ6LrkeNFtsBAzhnFlIbw0z6YuXTSCU,1891
|
|
23
|
+
austial/cli/templates/project/modules/health/health_controller.py.j2,sha256=rn_0fTTR4EMCQswCHmrrGQaGydYH2JvYy6kFvpgqeqA,641
|
|
24
|
+
austial/cli/templates/project/modules/health/health_dto.py.j2,sha256=vgzXP-JQdtjs-MPnBDfaLEaZdu3vKrmb3hibe0t82aM,203
|
|
25
|
+
austial/cli/templates/project/modules/health/health_module.py.j2,sha256=Cruo9mjpEnhesdX1aGGNoTuvGqc6Og7sAWgzQseo-c0,329
|
|
26
|
+
austial/cli/templates/project/modules/health/health_service.py.j2,sha256=ahd0qgNCfTDei19RW0mq1xOmKDg4Hn9mDwc0TlMfLa8,651
|
|
27
|
+
austial/cli/templates/project/modules/health/guards/api_key_guard.py.j2,sha256=prKEsSRyLWIqnklDxYvpU1MvI-uVK46wTlujGC5AfZQ,458
|
|
28
|
+
austial/cli/templates/project/tests/e2e/app_e2e_spec.py.j2,sha256=gzluCGMZfKaXvJFGCs1PiZ03ZAcOxO2UwTyzExCcluI,1352
|
|
29
|
+
austial/cli/templates/project/tests/unit/health_service_spec.py.j2,sha256=35fliuw9aZ7taY8NFSXU0LOC5OIOUSP8sjduzzyVXjg,1123
|
|
30
|
+
austial/cli/templates/resource/controller.py.j2,sha256=6Kw1qBvR8EbqzGmoWhYiryVBAnmM2Kyg4_MNeu-hTh4,1257
|
|
31
|
+
austial/cli/templates/resource/create_dto.py.j2,sha256=Di0sMu7L62UKlLRijCjpMuMqlO77LgiW_X5qw-c6MoM,91
|
|
32
|
+
austial/cli/templates/resource/entity.py.j2,sha256=AKIewi5gdVmLFYJii4oqSgDNCs2yCIsWNB_hyBprinM,94
|
|
33
|
+
austial/cli/templates/resource/module.py.j2,sha256=kt6cdCMsQHevwdRjrvxNEf36FM727zieE2y0lFf4ouc,342
|
|
34
|
+
austial/cli/templates/resource/service.py.j2,sha256=8YGe2DXAtz-LpFU0cVLWqEccJx8M4ExcJz2jdXEXMzs,1498
|
|
35
|
+
austial/cli/templates/resource/update_dto.py.j2,sha256=Sp0Qq2eiK6m4-o6Z6RblVwKploS8rp0V-o9bCJVRA2k,105
|
|
36
|
+
austial/cli/templates/service/service.py.j2,sha256=SJH7NUPtSkB23SDo1ILjkdjLqcSdkfIdK5EIhWErJ1w,87
|
|
37
|
+
austial_cli-0.1.5.dist-info/METADATA,sha256=GrZBEHCfjBtKjEcr6fnp1A8fR7Sh27OH5xWLmLW40uk,294
|
|
38
|
+
austial_cli-0.1.5.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
39
|
+
austial_cli-0.1.5.dist-info/RECORD,,
|