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.
Files changed (41) hide show
  1. qx/cli/__init__.py +1 -0
  2. qx/cli/commands/__init__.py +0 -0
  3. qx/cli/commands/dev.py +86 -0
  4. qx/cli/commands/generate.py +182 -0
  5. qx/cli/commands/new.py +76 -0
  6. qx/cli/main.py +52 -0
  7. qx/cli/py.typed +0 -0
  8. qx/cli/scaffolds/__init__.py +0 -0
  9. qx/cli/scaffolds/aggregate/__init__.py +0 -0
  10. qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +60 -0
  11. qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/__init__.py.j2 +0 -0
  12. qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/mapping.py.j2 +27 -0
  13. qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/repository.py.j2 +21 -0
  14. qx/cli/scaffolds/command/__init__.py +0 -0
  15. qx/cli/scaffolds/command/src/__service_pkg__/application/commands/__name_snake__.py.j2 +32 -0
  16. qx/cli/scaffolds/endpoint/__init__.py +0 -0
  17. qx/cli/scaffolds/endpoint/src/__service_pkg__/presentation/routes/__name_snake__.py.j2 +24 -0
  18. qx/cli/scaffolds/event/__init__.py +0 -0
  19. qx/cli/scaffolds/event/src/__service_pkg__/domain/events/__name_snake__.py.j2 +18 -0
  20. qx/cli/scaffolds/query/__init__.py +0 -0
  21. qx/cli/scaffolds/query/src/__service_pkg__/application/queries/__name_snake__.py.j2 +21 -0
  22. qx/cli/scaffolds/service/.env.example.j2 +16 -0
  23. qx/cli/scaffolds/service/Dockerfile.j2 +19 -0
  24. qx/cli/scaffolds/service/README.md.j2 +59 -0
  25. qx/cli/scaffolds/service/__init__.py +0 -0
  26. qx/cli/scaffolds/service/alembic/env.py.j2 +13 -0
  27. qx/cli/scaffolds/service/alembic/script.py.mako +26 -0
  28. qx/cli/scaffolds/service/alembic.ini.j2 +38 -0
  29. qx/cli/scaffolds/service/pyproject.toml.j2 +47 -0
  30. qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +3 -0
  31. qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +22 -0
  32. qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +22 -0
  33. qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +22 -0
  34. qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +88 -0
  35. qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +21 -0
  36. qx/cli/scaffolds/service/tests/test_smoke.py.j2 +33 -0
  37. qx/cli/templates/__init__.py +162 -0
  38. qx_cli-0.1.0.dist-info/METADATA +79 -0
  39. qx_cli-0.1.0.dist-info/RECORD +41 -0
  40. qx_cli-0.1.0.dist-info/WHEEL +4 -0
  41. 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
@@ -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
@@ -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"]