qx-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. qx_cli-0.1.0/.gitignore +51 -0
  2. qx_cli-0.1.0/PKG-INFO +79 -0
  3. qx_cli-0.1.0/README.md +65 -0
  4. qx_cli-0.1.0/pyproject.toml +25 -0
  5. qx_cli-0.1.0/src/qx/cli/__init__.py +1 -0
  6. qx_cli-0.1.0/src/qx/cli/commands/__init__.py +0 -0
  7. qx_cli-0.1.0/src/qx/cli/commands/dev.py +86 -0
  8. qx_cli-0.1.0/src/qx/cli/commands/generate.py +182 -0
  9. qx_cli-0.1.0/src/qx/cli/commands/new.py +76 -0
  10. qx_cli-0.1.0/src/qx/cli/main.py +52 -0
  11. qx_cli-0.1.0/src/qx/cli/py.typed +0 -0
  12. qx_cli-0.1.0/src/qx/cli/scaffolds/__init__.py +0 -0
  13. qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/__init__.py +0 -0
  14. qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +60 -0
  15. qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/__init__.py.j2 +0 -0
  16. qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/mapping.py.j2 +27 -0
  17. qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/repository.py.j2 +21 -0
  18. qx_cli-0.1.0/src/qx/cli/scaffolds/command/__init__.py +0 -0
  19. qx_cli-0.1.0/src/qx/cli/scaffolds/command/src/__service_pkg__/application/commands/__name_snake__.py.j2 +32 -0
  20. qx_cli-0.1.0/src/qx/cli/scaffolds/endpoint/__init__.py +0 -0
  21. qx_cli-0.1.0/src/qx/cli/scaffolds/endpoint/src/__service_pkg__/presentation/routes/__name_snake__.py.j2 +24 -0
  22. qx_cli-0.1.0/src/qx/cli/scaffolds/event/__init__.py +0 -0
  23. qx_cli-0.1.0/src/qx/cli/scaffolds/event/src/__service_pkg__/domain/events/__name_snake__.py.j2 +18 -0
  24. qx_cli-0.1.0/src/qx/cli/scaffolds/query/__init__.py +0 -0
  25. qx_cli-0.1.0/src/qx/cli/scaffolds/query/src/__service_pkg__/application/queries/__name_snake__.py.j2 +21 -0
  26. qx_cli-0.1.0/src/qx/cli/scaffolds/service/.env.example.j2 +16 -0
  27. qx_cli-0.1.0/src/qx/cli/scaffolds/service/Dockerfile.j2 +19 -0
  28. qx_cli-0.1.0/src/qx/cli/scaffolds/service/README.md.j2 +59 -0
  29. qx_cli-0.1.0/src/qx/cli/scaffolds/service/__init__.py +0 -0
  30. qx_cli-0.1.0/src/qx/cli/scaffolds/service/alembic/env.py.j2 +13 -0
  31. qx_cli-0.1.0/src/qx/cli/scaffolds/service/alembic/script.py.mako +26 -0
  32. qx_cli-0.1.0/src/qx/cli/scaffolds/service/alembic.ini.j2 +38 -0
  33. qx_cli-0.1.0/src/qx/cli/scaffolds/service/pyproject.toml.j2 +47 -0
  34. qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +3 -0
  35. qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +22 -0
  36. qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +22 -0
  37. qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +22 -0
  38. qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +88 -0
  39. qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +21 -0
  40. qx_cli-0.1.0/src/qx/cli/scaffolds/service/tests/test_smoke.py.j2 +33 -0
  41. qx_cli-0.1.0/src/qx/cli/templates/__init__.py +162 -0
  42. qx_cli-0.1.0/tests/test_cli.py +157 -0
@@ -0,0 +1,51 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.so
8
+ *.egg
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+ eggs/
13
+ .eggs/
14
+ sdist/
15
+ wheels/
16
+ *.egg-link
17
+
18
+ # Virtual environments
19
+ .venv/
20
+ venv/
21
+ env/
22
+ ENV/
23
+
24
+ # uv
25
+ .uv/
26
+
27
+ # Testing
28
+ .pytest_cache/
29
+ .coverage
30
+ htmlcov/
31
+ .tox/
32
+
33
+ # Type checking
34
+ .mypy_cache/
35
+ .ruff_cache/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.swp
41
+ *.swo
42
+
43
+ # OS
44
+ .DS_Store
45
+ Thumbs.db
46
+
47
+ # Docker
48
+ *.env.local
49
+
50
+ # Dist artifacts
51
+ dist/
qx_cli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: qx-cli
3
+ Version: 0.1.0
4
+ Summary: Qx CLI: scaffolding, code generation, local dev orchestration
5
+ Author: Qx Engineering
6
+ License: MIT
7
+ Requires-Python: >=3.14
8
+ Requires-Dist: jinja2>=3.1.0
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: qx-core
11
+ Requires-Dist: rich>=13.0.0
12
+ Requires-Dist: typer>=0.12.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # qx-cli
16
+
17
+ Scaffolding, code generation, and local-dev orchestration for the Qx framework. Invoked as `qx` once installed, or `uv run qx` during development.
18
+
19
+ ## What lives here
20
+
21
+ - **`qx new service NAME`** — scaffold a complete new Qx service: directory structure, pyproject.toml, DI bootstrap, FastAPI app factory, Alembic config, Docker Compose overrides, and a `README.md`.
22
+ - **`qx generate aggregate NAME`** — add a domain aggregate with `Entity`, events, and a stub repository to an existing service.
23
+ - **`qx generate command NAME`** — add a `Command` and handler class with the standard boilerplate.
24
+ - **`qx generate query NAME`** — add a `Query` and handler class.
25
+ - **`qx generate endpoint`** — add a FastAPI route file wired to the Mediator.
26
+ - **`qx generate event NAME`** — add an `IntegrationEvent` and its handler skeleton.
27
+ - **`qx dev up`** — start the local Docker Compose stack (Postgres, Redis, NATS, Prometheus, Tempo, Grafana, MailHog, MinIO).
28
+ - **`qx dev down`** — stop and remove the local stack.
29
+ - **`qx version`** — print the framework version.
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Install (or use via uv in the workspace)
35
+ pip install qx-cli
36
+
37
+ # Scaffold a new service
38
+ qx new service payments-service
39
+ cd payments-service
40
+
41
+ # Add domain objects
42
+ qx generate aggregate Payment
43
+ qx generate command ProcessPayment
44
+ qx generate query GetPayment
45
+ qx generate event PaymentProcessed
46
+
47
+ # Start local infra
48
+ qx dev up
49
+
50
+ # Run
51
+ uv run uvicorn payments_service.main:app --reload
52
+ ```
53
+
54
+ ## Generated structure
55
+
56
+ `qx new service` produces:
57
+
58
+ ```
59
+ my-service/
60
+ ├── src/my_service/
61
+ │ ├── domain/aggregates/ # domain model
62
+ │ ├── application/
63
+ │ │ ├── commands/ # command handlers
64
+ │ │ └── queries/ # query handlers
65
+ │ ├── infrastructure/
66
+ │ │ └── persistence/ # repositories, SA mapping
67
+ │ └── presentation/routes/ # FastAPI routes
68
+ ├── tests/
69
+ │ ├── unit/
70
+ │ └── integration/
71
+ ├── alembic/
72
+ ├── pyproject.toml
73
+ └── README.md
74
+ ```
75
+
76
+ ## Design rules
77
+
78
+ - Generated code follows the same conventions as `examples/identity-service` — it is the canonical reference for what the generator should produce.
79
+ - `qx dev up/down` is a thin wrapper around `docker compose` pointing at `deploy/docker-compose.yaml` in the workspace root. It does not manage application containers, only infrastructure.
qx_cli-0.1.0/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # qx-cli
2
+
3
+ Scaffolding, code generation, and local-dev orchestration for the Qx framework. Invoked as `qx` once installed, or `uv run qx` during development.
4
+
5
+ ## What lives here
6
+
7
+ - **`qx new service NAME`** — scaffold a complete new Qx service: directory structure, pyproject.toml, DI bootstrap, FastAPI app factory, Alembic config, Docker Compose overrides, and a `README.md`.
8
+ - **`qx generate aggregate NAME`** — add a domain aggregate with `Entity`, events, and a stub repository to an existing service.
9
+ - **`qx generate command NAME`** — add a `Command` and handler class with the standard boilerplate.
10
+ - **`qx generate query NAME`** — add a `Query` and handler class.
11
+ - **`qx generate endpoint`** — add a FastAPI route file wired to the Mediator.
12
+ - **`qx generate event NAME`** — add an `IntegrationEvent` and its handler skeleton.
13
+ - **`qx dev up`** — start the local Docker Compose stack (Postgres, Redis, NATS, Prometheus, Tempo, Grafana, MailHog, MinIO).
14
+ - **`qx dev down`** — stop and remove the local stack.
15
+ - **`qx version`** — print the framework version.
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ # Install (or use via uv in the workspace)
21
+ pip install qx-cli
22
+
23
+ # Scaffold a new service
24
+ qx new service payments-service
25
+ cd payments-service
26
+
27
+ # Add domain objects
28
+ qx generate aggregate Payment
29
+ qx generate command ProcessPayment
30
+ qx generate query GetPayment
31
+ qx generate event PaymentProcessed
32
+
33
+ # Start local infra
34
+ qx dev up
35
+
36
+ # Run
37
+ uv run uvicorn payments_service.main:app --reload
38
+ ```
39
+
40
+ ## Generated structure
41
+
42
+ `qx new service` produces:
43
+
44
+ ```
45
+ my-service/
46
+ ├── src/my_service/
47
+ │ ├── domain/aggregates/ # domain model
48
+ │ ├── application/
49
+ │ │ ├── commands/ # command handlers
50
+ │ │ └── queries/ # query handlers
51
+ │ ├── infrastructure/
52
+ │ │ └── persistence/ # repositories, SA mapping
53
+ │ └── presentation/routes/ # FastAPI routes
54
+ ├── tests/
55
+ │ ├── unit/
56
+ │ └── integration/
57
+ ├── alembic/
58
+ ├── pyproject.toml
59
+ └── README.md
60
+ ```
61
+
62
+ ## Design rules
63
+
64
+ - Generated code follows the same conventions as `examples/identity-service` — it is the canonical reference for what the generator should produce.
65
+ - `qx dev up/down` is a thin wrapper around `docker compose` pointing at `deploy/docker-compose.yaml` in the workspace root. It does not manage application containers, only infrastructure.
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "qx-cli"
3
+ version = "0.1.0"
4
+ description = "Qx CLI: scaffolding, code generation, local dev orchestration"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Qx Engineering" }]
9
+ dependencies = [
10
+ "qx-core",
11
+ "typer>=0.12.0",
12
+ "rich>=13.0.0",
13
+ "jinja2>=3.1.0",
14
+ "pyyaml>=6.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ qx = "qx.cli.main:app"
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/qx"]
@@ -0,0 +1 @@
1
+ """Qx cli package."""
File without changes
@@ -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("_", "-")
@@ -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("_", "-")
@@ -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()
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