qx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qx/cli/__init__.py +1 -0
- qx/cli/commands/__init__.py +0 -0
- qx/cli/commands/dev.py +86 -0
- qx/cli/commands/generate.py +182 -0
- qx/cli/commands/new.py +76 -0
- qx/cli/main.py +52 -0
- qx/cli/py.typed +0 -0
- qx/cli/scaffolds/__init__.py +0 -0
- qx/cli/scaffolds/aggregate/__init__.py +0 -0
- qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +60 -0
- qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/__init__.py.j2 +0 -0
- qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/mapping.py.j2 +27 -0
- qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/repository.py.j2 +21 -0
- qx/cli/scaffolds/command/__init__.py +0 -0
- qx/cli/scaffolds/command/src/__service_pkg__/application/commands/__name_snake__.py.j2 +32 -0
- qx/cli/scaffolds/endpoint/__init__.py +0 -0
- qx/cli/scaffolds/endpoint/src/__service_pkg__/presentation/routes/__name_snake__.py.j2 +24 -0
- qx/cli/scaffolds/event/__init__.py +0 -0
- qx/cli/scaffolds/event/src/__service_pkg__/domain/events/__name_snake__.py.j2 +18 -0
- qx/cli/scaffolds/query/__init__.py +0 -0
- qx/cli/scaffolds/query/src/__service_pkg__/application/queries/__name_snake__.py.j2 +21 -0
- qx/cli/scaffolds/service/.env.example.j2 +16 -0
- qx/cli/scaffolds/service/Dockerfile.j2 +19 -0
- qx/cli/scaffolds/service/README.md.j2 +59 -0
- qx/cli/scaffolds/service/__init__.py +0 -0
- qx/cli/scaffolds/service/alembic/env.py.j2 +13 -0
- qx/cli/scaffolds/service/alembic/script.py.mako +26 -0
- qx/cli/scaffolds/service/alembic.ini.j2 +38 -0
- qx/cli/scaffolds/service/pyproject.toml.j2 +47 -0
- qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +3 -0
- qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +22 -0
- qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +22 -0
- qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +22 -0
- qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +88 -0
- qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +21 -0
- qx/cli/scaffolds/service/tests/test_smoke.py.j2 +33 -0
- qx/cli/templates/__init__.py +162 -0
- qx_cli-0.1.0.dist-info/METADATA +79 -0
- qx_cli-0.1.0.dist-info/RECORD +41 -0
- qx_cli-0.1.0.dist-info/WHEEL +4 -0
- qx_cli-0.1.0.dist-info/entry_points.txt +2 -0
qx/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Qx cli package."""
|
|
File without changes
|
qx/cli/commands/dev.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""``qx dev`` — local development orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_compose_file() -> Path:
|
|
17
|
+
"""Locate the framework's docker-compose.yaml.
|
|
18
|
+
|
|
19
|
+
Searches: CWD, then walks up looking for ``deploy/docker-compose.yaml``.
|
|
20
|
+
"""
|
|
21
|
+
cwd = Path.cwd()
|
|
22
|
+
candidates = [
|
|
23
|
+
cwd / "deploy" / "docker-compose.yaml",
|
|
24
|
+
cwd / "docker-compose.yaml",
|
|
25
|
+
]
|
|
26
|
+
for c in candidates:
|
|
27
|
+
if c.exists():
|
|
28
|
+
return c
|
|
29
|
+
# Walk up
|
|
30
|
+
for parent in cwd.parents:
|
|
31
|
+
c = parent / "deploy" / "docker-compose.yaml"
|
|
32
|
+
if c.exists():
|
|
33
|
+
return c
|
|
34
|
+
raise typer.BadParameter("Could not find a docker-compose.yaml. Run from a qx project tree.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _docker_compose_cmd() -> list[str]:
|
|
38
|
+
"""Pick whichever compose flavor is available."""
|
|
39
|
+
if shutil.which("docker"):
|
|
40
|
+
return ["docker", "compose"]
|
|
41
|
+
if shutil.which("podman"):
|
|
42
|
+
return ["podman-compose"]
|
|
43
|
+
raise typer.BadParameter("Neither docker nor podman found on PATH.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command()
|
|
47
|
+
def up(
|
|
48
|
+
detach: bool = typer.Option(True, "--detach/--no-detach", "-d/-n"),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Start the local infrastructure stack."""
|
|
51
|
+
compose = _find_compose_file()
|
|
52
|
+
cmd = [*_docker_compose_cmd(), "-f", str(compose), "up"]
|
|
53
|
+
if detach:
|
|
54
|
+
cmd.append("-d")
|
|
55
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
56
|
+
subprocess.run(cmd, check=False)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def down() -> None:
|
|
61
|
+
"""Stop the local infrastructure stack."""
|
|
62
|
+
compose = _find_compose_file()
|
|
63
|
+
cmd = [*_docker_compose_cmd(), "-f", str(compose), "down"]
|
|
64
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
65
|
+
subprocess.run(cmd, check=False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def logs(
|
|
70
|
+
service: str = typer.Argument("", help="Specific service to tail (omit for all)."),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Tail logs from the local stack."""
|
|
73
|
+
compose = _find_compose_file()
|
|
74
|
+
cmd = [*_docker_compose_cmd(), "-f", str(compose), "logs", "-f"]
|
|
75
|
+
if service:
|
|
76
|
+
cmd.append(service)
|
|
77
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
78
|
+
subprocess.run(cmd, check=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command()
|
|
82
|
+
def status() -> None:
|
|
83
|
+
"""Show the running containers."""
|
|
84
|
+
compose = _find_compose_file()
|
|
85
|
+
cmd = [*_docker_compose_cmd(), "-f", str(compose), "ps"]
|
|
86
|
+
subprocess.run(cmd, check=False)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""``qx generate`` — code generation into an existing service.
|
|
2
|
+
|
|
3
|
+
Each subcommand expects the cwd to be a qx service directory
|
|
4
|
+
(containing a ``pyproject.toml`` and a Python package matching the service
|
|
5
|
+
name). The CLI infers the service package from ``pyproject.toml``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from qx.cli.templates import preview_tree, render_tree
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(no_args_is_help=True)
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _service_package(start: Path | None = None) -> tuple[Path, str]:
|
|
23
|
+
"""Walk up to find ``pyproject.toml`` and infer the service package."""
|
|
24
|
+
p = (start or Path.cwd()).resolve()
|
|
25
|
+
for candidate in (p, *p.parents):
|
|
26
|
+
pp = candidate / "pyproject.toml"
|
|
27
|
+
if pp.exists():
|
|
28
|
+
try:
|
|
29
|
+
data = tomllib.loads(pp.read_text())
|
|
30
|
+
except Exception:
|
|
31
|
+
continue
|
|
32
|
+
name = data.get("project", {}).get("name", "")
|
|
33
|
+
if not name:
|
|
34
|
+
continue
|
|
35
|
+
pkg = name.replace("-", "_")
|
|
36
|
+
# Validate the package directory actually exists
|
|
37
|
+
if (candidate / "src" / pkg).exists():
|
|
38
|
+
return candidate, pkg
|
|
39
|
+
if (candidate / pkg).exists():
|
|
40
|
+
return candidate, pkg
|
|
41
|
+
raise typer.BadParameter("Could not locate a qx service from current directory.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def aggregate(
|
|
46
|
+
name: str = typer.Argument(..., help="Aggregate name (PascalCase, e.g. 'Invoice')."),
|
|
47
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Generate a new aggregate (domain entity + table mapping + repository)."""
|
|
50
|
+
root, pkg = _service_package()
|
|
51
|
+
context = _names(name, pkg)
|
|
52
|
+
files = render_tree(
|
|
53
|
+
"qx.cli.scaffolds",
|
|
54
|
+
"aggregate",
|
|
55
|
+
root,
|
|
56
|
+
context,
|
|
57
|
+
overwrite=force,
|
|
58
|
+
)
|
|
59
|
+
console.rule(f"[bold green]aggregate {context['name_pascal']} generated[/bold green]")
|
|
60
|
+
preview_tree(files, root)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def command(
|
|
65
|
+
name: str = typer.Argument(..., help="Command name (e.g. 'CreateUser')."),
|
|
66
|
+
aggregate_for: str = typer.Option(
|
|
67
|
+
"",
|
|
68
|
+
"--aggregate",
|
|
69
|
+
"-a",
|
|
70
|
+
help="Target aggregate (for layout hints).",
|
|
71
|
+
),
|
|
72
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Generate a Command class and its handler."""
|
|
75
|
+
root, pkg = _service_package()
|
|
76
|
+
context = _names(name, pkg)
|
|
77
|
+
context["aggregate"] = _names(aggregate_for, pkg) if aggregate_for else None
|
|
78
|
+
files = render_tree(
|
|
79
|
+
"qx.cli.scaffolds",
|
|
80
|
+
"command",
|
|
81
|
+
root,
|
|
82
|
+
context,
|
|
83
|
+
overwrite=force,
|
|
84
|
+
)
|
|
85
|
+
console.rule(f"[bold green]command {context['name_pascal']} generated[/bold green]")
|
|
86
|
+
preview_tree(files, root)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def query(
|
|
91
|
+
name: str = typer.Argument(..., help="Query name (e.g. 'GetUser')."),
|
|
92
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Generate a Query class and its handler."""
|
|
95
|
+
root, pkg = _service_package()
|
|
96
|
+
context = _names(name, pkg)
|
|
97
|
+
files = render_tree(
|
|
98
|
+
"qx.cli.scaffolds",
|
|
99
|
+
"query",
|
|
100
|
+
root,
|
|
101
|
+
context,
|
|
102
|
+
overwrite=force,
|
|
103
|
+
)
|
|
104
|
+
console.rule(f"[bold green]query {context['name_pascal']} generated[/bold green]")
|
|
105
|
+
preview_tree(files, root)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def event(
|
|
110
|
+
name: str = typer.Argument(..., help="Event name (e.g. 'UserRegistered')."),
|
|
111
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Generate an IntegrationEvent class."""
|
|
114
|
+
root, pkg = _service_package()
|
|
115
|
+
context = _names(name, pkg)
|
|
116
|
+
files = render_tree(
|
|
117
|
+
"qx.cli.scaffolds",
|
|
118
|
+
"event",
|
|
119
|
+
root,
|
|
120
|
+
context,
|
|
121
|
+
overwrite=force,
|
|
122
|
+
)
|
|
123
|
+
console.rule(f"[bold green]event {context['name_pascal']} generated[/bold green]")
|
|
124
|
+
preview_tree(files, root)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command()
|
|
128
|
+
def endpoint(
|
|
129
|
+
path: str = typer.Argument(..., help="Route path (e.g. '/users')."),
|
|
130
|
+
method: str = typer.Option("POST", "--method", "-m"),
|
|
131
|
+
handler: str = typer.Option(..., "--handler", "-h", help="Command or Query name to dispatch."),
|
|
132
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Generate an HTTP endpoint that dispatches a command/query."""
|
|
135
|
+
root, pkg = _service_package()
|
|
136
|
+
context = _names(handler, pkg)
|
|
137
|
+
context.update(
|
|
138
|
+
{
|
|
139
|
+
"endpoint_path": path,
|
|
140
|
+
"endpoint_method": method.upper(),
|
|
141
|
+
"endpoint_method_lower": method.lower(),
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
files = render_tree(
|
|
145
|
+
"qx.cli.scaffolds",
|
|
146
|
+
"endpoint",
|
|
147
|
+
root,
|
|
148
|
+
context,
|
|
149
|
+
overwrite=force,
|
|
150
|
+
)
|
|
151
|
+
console.rule(f"[bold green]endpoint {method.upper()} {path} generated[/bold green]")
|
|
152
|
+
preview_tree(files, root)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---- helpers ----
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _names(name: str, pkg: str) -> dict[str, Any]:
|
|
159
|
+
"""Build the standard naming variants for a generated artifact."""
|
|
160
|
+
return {
|
|
161
|
+
"name_pascal": _pascal(name),
|
|
162
|
+
"name_snake": _snake(name),
|
|
163
|
+
"name_kebab": _kebab(name),
|
|
164
|
+
"service_pkg": pkg,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _snake(s: str) -> str:
|
|
169
|
+
import re # noqa: PLC0415
|
|
170
|
+
|
|
171
|
+
s = re.sub(r"[\s\-]+", "_", s.strip())
|
|
172
|
+
s = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", s)
|
|
173
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
|
174
|
+
return s.lower()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _pascal(s: str) -> str:
|
|
178
|
+
return "".join(p.capitalize() for p in _snake(s).split("_"))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _kebab(s: str) -> str:
|
|
182
|
+
return _snake(s).replace("_", "-")
|
qx/cli/commands/new.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""``qx new`` — scaffold new projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from qx.cli.templates import preview_tree, render_tree
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True)
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def service(
|
|
17
|
+
name: str = typer.Argument(..., help="Service name (kebab-case)."),
|
|
18
|
+
target: Path = typer.Option( # noqa: B008
|
|
19
|
+
Path.cwd(), # noqa: B008
|
|
20
|
+
"--target",
|
|
21
|
+
"-t",
|
|
22
|
+
help="Directory to create the project in.",
|
|
23
|
+
),
|
|
24
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files."),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Scaffold a new qx service.
|
|
27
|
+
|
|
28
|
+
Generates a fully wired service with application/domain/infrastructure/
|
|
29
|
+
presentation layers, a Dockerfile, alembic config, and a passing test.
|
|
30
|
+
"""
|
|
31
|
+
pkg_name = _snake(name)
|
|
32
|
+
context = {
|
|
33
|
+
"service_name": name,
|
|
34
|
+
"service_pkg": pkg_name,
|
|
35
|
+
"service_pascal": _pascal(name),
|
|
36
|
+
"service_kebab": _kebab(name),
|
|
37
|
+
"service_pkg_path": pkg_name.replace("_", "/"),
|
|
38
|
+
}
|
|
39
|
+
dest = target / context["service_kebab"]
|
|
40
|
+
if dest.exists() and not force and any(dest.iterdir()):
|
|
41
|
+
console.print(f"[red]error[/red] {dest} exists and is not empty (use --force to overwrite)")
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
files = render_tree(
|
|
46
|
+
"qx.cli.scaffolds",
|
|
47
|
+
"service",
|
|
48
|
+
dest,
|
|
49
|
+
context,
|
|
50
|
+
overwrite=force,
|
|
51
|
+
)
|
|
52
|
+
console.rule("[bold green]Service scaffolded[/bold green]")
|
|
53
|
+
preview_tree(files, dest)
|
|
54
|
+
console.print(
|
|
55
|
+
"\n[bold]Next steps:[/bold]\n"
|
|
56
|
+
f" cd {context['service_kebab']}\n"
|
|
57
|
+
" uv sync\n"
|
|
58
|
+
" docker compose -f ../deploy/docker-compose.yaml up -d # local Postgres/Redis/NATS\n"
|
|
59
|
+
" uv run alembic upgrade head\n"
|
|
60
|
+
" uv run uvicorn " + f"{pkg_name}.main:app --reload\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _snake(s: str) -> str:
|
|
65
|
+
import re # noqa: PLC0415
|
|
66
|
+
|
|
67
|
+
s = re.sub(r"[\s\-]+", "_", s.strip())
|
|
68
|
+
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s).lower()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pascal(s: str) -> str:
|
|
72
|
+
return "".join(p.capitalize() for p in _snake(s).split("_"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _kebab(s: str) -> str:
|
|
76
|
+
return _snake(s).replace("_", "-")
|
qx/cli/main.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Qx CLI — scaffolding and developer tooling.
|
|
2
|
+
|
|
3
|
+
Subcommands::
|
|
4
|
+
|
|
5
|
+
qx new service NAME # scaffold a new service
|
|
6
|
+
qx generate aggregate NAME # add an aggregate to current service
|
|
7
|
+
qx generate command NAME # add a command + handler
|
|
8
|
+
qx generate query NAME # add a query + handler
|
|
9
|
+
qx generate endpoint # add an HTTP endpoint
|
|
10
|
+
qx generate event NAME # add an integration event
|
|
11
|
+
qx dev up # start docker-compose stack
|
|
12
|
+
qx dev down # stop the stack
|
|
13
|
+
qx version # print framework version
|
|
14
|
+
|
|
15
|
+
Invoke as ``qx`` once the package is installed; ``uv run qx ...``
|
|
16
|
+
during development.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from qx.cli.commands import dev, generate, new
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="qx",
|
|
29
|
+
help="Qx framework CLI — scaffolding, code generation, and dev orchestration.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
add_completion=False,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
app.add_typer(new.app, name="new", help="Scaffold a new project from a template.")
|
|
35
|
+
app.add_typer(
|
|
36
|
+
generate.app,
|
|
37
|
+
name="generate",
|
|
38
|
+
help="Generate code into an existing service (aggregate, command, query, ...).",
|
|
39
|
+
)
|
|
40
|
+
app.add_typer(dev.app, name="dev", help="Local development orchestration.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def version() -> None:
|
|
45
|
+
"""Print the framework version."""
|
|
46
|
+
from qx.core import __version__ as core_version # noqa: PLC0415
|
|
47
|
+
|
|
48
|
+
console.print(f"[bold]qx-python[/bold] [cyan]{core_version}[/cyan]")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
app()
|
qx/cli/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""{{ name_pascal }} aggregate.
|
|
2
|
+
|
|
3
|
+
Domain object. Encapsulates the invariants and emits domain events when
|
|
4
|
+
state changes. Knows nothing about HOW it's stored.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import field
|
|
10
|
+
from typing import ClassVar
|
|
11
|
+
from uuid import UUID, uuid4
|
|
12
|
+
|
|
13
|
+
from qx.core import (
|
|
14
|
+
AggregateRoot,
|
|
15
|
+
DomainEvent,
|
|
16
|
+
Identifier,
|
|
17
|
+
IntegrationEvent,
|
|
18
|
+
aggregate,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---- Domain events ----
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class {{ name_pascal }}Created(DomainEvent):
|
|
26
|
+
"""In-process event recorded when a {{ name_pascal }} is created."""
|
|
27
|
+
|
|
28
|
+
event_name: ClassVar[str] = "{{ service_pkg }}.{{ name_snake }}.created"
|
|
29
|
+
|
|
30
|
+
{{ name_snake }}_id: UUID
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class {{ name_pascal }}CreatedIntegrationEvent(IntegrationEvent):
|
|
34
|
+
"""Cross-process event published via the outbox."""
|
|
35
|
+
|
|
36
|
+
event_name: ClassVar[str] = "{{ service_pkg }}.{{ name_snake }}.created"
|
|
37
|
+
event_version: ClassVar[int] = 1
|
|
38
|
+
|
|
39
|
+
{{ name_snake }}_id: UUID
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---- Aggregate ----
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@aggregate
|
|
46
|
+
class {{ name_pascal }}(AggregateRoot[Identifier]):
|
|
47
|
+
"""The {{ name_pascal }} aggregate root."""
|
|
48
|
+
|
|
49
|
+
# Add your fields here. Example:
|
|
50
|
+
# name: str = ""
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def create(cls) -> "{{ name_pascal }}":
|
|
54
|
+
"""Factory: construct a new {{ name_pascal }} and record creation events."""
|
|
55
|
+
new = cls(id=Identifier(value=uuid4()))
|
|
56
|
+
new.record_event({{ name_pascal }}Created({{ name_snake }}_id=new.id.value))
|
|
57
|
+
new.record_event(
|
|
58
|
+
{{ name_pascal }}CreatedIntegrationEvent({{ name_snake }}_id=new.id.value)
|
|
59
|
+
)
|
|
60
|
+
return new
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Persistence mapping for {{ name_pascal }}.
|
|
2
|
+
|
|
3
|
+
Imperative-mapped via the framework's registry. Keep the table definition
|
|
4
|
+
*here* (in infrastructure) and the domain object pure of any ORM dependency.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import Column, String, Table
|
|
10
|
+
|
|
11
|
+
from qx.db import make_registry, standard_audit_columns, uuid_column
|
|
12
|
+
|
|
13
|
+
from {{ service_pkg }}.domain.aggregates.{{ name_snake }} import {{ name_pascal }}
|
|
14
|
+
from {{ service_pkg }}.infrastructure import metadata
|
|
15
|
+
|
|
16
|
+
registry = make_registry(metadata=metadata)
|
|
17
|
+
|
|
18
|
+
{{ name_snake }}_table = Table(
|
|
19
|
+
"{{ name_snake }}",
|
|
20
|
+
metadata,
|
|
21
|
+
uuid_column("id", primary_key=True),
|
|
22
|
+
# Add columns matching the aggregate's fields here.
|
|
23
|
+
*standard_audit_columns(),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
registry.map_imperatively({{ name_pascal }}, {{ name_snake }}_table)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Repository for {{ name_pascal }}.
|
|
2
|
+
|
|
3
|
+
Subclass of the framework's generic Repository. Add domain-flavored finders
|
|
4
|
+
here (``find_by_email`` etc.) rather than letting controllers compose
|
|
5
|
+
ad-hoc queries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from qx.db import Repository
|
|
11
|
+
|
|
12
|
+
from {{ service_pkg }}.domain.aggregates.{{ name_snake }} import {{ name_pascal }}
|
|
13
|
+
from {{ service_pkg }}.infrastructure.persistence.{{ name_snake }}.mapping import {{ name_snake }}_table
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class {{ name_pascal }}Repository(Repository[{{ name_pascal }}]):
|
|
17
|
+
entity_cls = {{ name_pascal }}
|
|
18
|
+
table = {{ name_snake }}_table
|
|
19
|
+
# Permit filtering only on these fields; everything else is rejected at the boundary.
|
|
20
|
+
filterable_fields: set[str] = set()
|
|
21
|
+
sortable_fields: set[str] = {"created_at"}
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""{{ name_pascal }} command + handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from qx.core import Result
|
|
6
|
+
from qx.cqrs import Command, command_handler
|
|
7
|
+
from qx.db import UnitOfWork
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class {{ name_pascal }}Command(Command[None]):
|
|
11
|
+
"""TODO: describe what {{ name_pascal }} does."""
|
|
12
|
+
|
|
13
|
+
# Fill in command fields here. Example:
|
|
14
|
+
# name: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@command_handler({{ name_pascal }}Command)
|
|
18
|
+
class {{ name_pascal }}Handler:
|
|
19
|
+
"""Handler for {{ name_pascal }}Command.
|
|
20
|
+
|
|
21
|
+
Constructor-injected dependencies. The DI container resolves these per
|
|
22
|
+
dispatch — repos, unit-of-work, domain services.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, uow: UnitOfWork) -> None:
|
|
26
|
+
self._uow = uow
|
|
27
|
+
|
|
28
|
+
async def handle(self, command: {{ name_pascal }}Command) -> Result[None]:
|
|
29
|
+
async with self._uow:
|
|
30
|
+
# TODO: implement the use case
|
|
31
|
+
await self._uow.commit()
|
|
32
|
+
return Result.success(None)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""HTTP endpoint dispatching {{ name_pascal }}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
from qx.cqrs import Mediator
|
|
8
|
+
from qx.http import Inject, envelope_success, unwrap
|
|
9
|
+
|
|
10
|
+
# Adjust the import path to match where the command/query lives in your project.
|
|
11
|
+
# from {{ service_pkg }}.application.commands.{{ name_snake }} import {{ name_pascal }}Command
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.{{ endpoint_method_lower }}("{{ endpoint_path }}")
|
|
17
|
+
async def {{ name_snake }}_endpoint(
|
|
18
|
+
# cmd: {{ name_pascal }}Command,
|
|
19
|
+
mediator: Mediator = Inject(Mediator),
|
|
20
|
+
):
|
|
21
|
+
"""{{ endpoint_method }} {{ endpoint_path }} → dispatches {{ name_pascal }}."""
|
|
22
|
+
# result = await mediator.send(cmd)
|
|
23
|
+
# return envelope_success(unwrap(result))
|
|
24
|
+
return envelope_success({"todo": "wire {{ name_pascal }}Command"})
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""{{ name_pascal }} integration event."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from qx.core import IntegrationEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class {{ name_pascal }}(IntegrationEvent):
|
|
11
|
+
"""TODO: document the meaning + downstream contract for this event."""
|
|
12
|
+
|
|
13
|
+
event_name: ClassVar[str] = "{{ service_pkg }}.{{ name_snake }}"
|
|
14
|
+
event_version: ClassVar[int] = 1
|
|
15
|
+
|
|
16
|
+
# Payload fields. Keep this minimal — events are a contract, breaking
|
|
17
|
+
# consumers is painful. Add a new event version rather than rename fields.
|
|
18
|
+
# example_id: UUID
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""{{ name_pascal }} query + handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from qx.core import Result
|
|
6
|
+
from qx.cqrs import Query, query_handler
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class {{ name_pascal }}Query(Query[None]):
|
|
10
|
+
"""TODO: describe what {{ name_pascal }} returns."""
|
|
11
|
+
|
|
12
|
+
# id: UUID
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@query_handler({{ name_pascal }}Query)
|
|
16
|
+
class {{ name_pascal }}Handler:
|
|
17
|
+
"""Handler for {{ name_pascal }}Query."""
|
|
18
|
+
|
|
19
|
+
async def handle(self, query: {{ name_pascal }}Query) -> Result[None]:
|
|
20
|
+
# TODO: read state, return DTO
|
|
21
|
+
return Result.success(None)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
QX_APP__NAME={{ service_kebab }}
|
|
2
|
+
QX_ENVIRONMENT=development
|
|
3
|
+
|
|
4
|
+
# Database
|
|
5
|
+
QX_DB__URL=postgresql+asyncpg://postgres:postgres@localhost:5432/{{ service_pkg }}
|
|
6
|
+
QX_DB__ECHO=false
|
|
7
|
+
|
|
8
|
+
# Cache
|
|
9
|
+
QX_CACHE__URL=redis://localhost:6379/0
|
|
10
|
+
|
|
11
|
+
# NATS
|
|
12
|
+
QX_NATS__SERVERS=["nats://localhost:4222"]
|
|
13
|
+
|
|
14
|
+
# Logging
|
|
15
|
+
QX_LOGGING__JSON_OUTPUT=false
|
|
16
|
+
QX_LOGGING__LEVEL=INFO
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1.7
|
|
2
|
+
FROM python:3.12-slim AS build
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
RUN pip install --no-cache-dir uv
|
|
5
|
+
COPY pyproject.toml uv.lock* ./
|
|
6
|
+
RUN uv venv .venv
|
|
7
|
+
COPY src ./src
|
|
8
|
+
RUN uv pip install --python .venv -e .
|
|
9
|
+
|
|
10
|
+
FROM python:3.12-slim
|
|
11
|
+
WORKDIR /app
|
|
12
|
+
RUN groupadd -r app && useradd -r -g app app
|
|
13
|
+
COPY --from=build --chown=app:app /app /app
|
|
14
|
+
ENV PATH="/app/.venv/bin:$PATH"
|
|
15
|
+
USER app
|
|
16
|
+
EXPOSE 8000
|
|
17
|
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \
|
|
18
|
+
CMD curl -fsS http://localhost:8000/healthz || exit 1
|
|
19
|
+
CMD ["uvicorn", "{{ service_pkg }}.main:app", "--host", "0.0.0.0", "--port", "8000"]
|