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.
- qx_cli-0.1.0/.gitignore +51 -0
- qx_cli-0.1.0/PKG-INFO +79 -0
- qx_cli-0.1.0/README.md +65 -0
- qx_cli-0.1.0/pyproject.toml +25 -0
- qx_cli-0.1.0/src/qx/cli/__init__.py +1 -0
- qx_cli-0.1.0/src/qx/cli/commands/__init__.py +0 -0
- qx_cli-0.1.0/src/qx/cli/commands/dev.py +86 -0
- qx_cli-0.1.0/src/qx/cli/commands/generate.py +182 -0
- qx_cli-0.1.0/src/qx/cli/commands/new.py +76 -0
- qx_cli-0.1.0/src/qx/cli/main.py +52 -0
- qx_cli-0.1.0/src/qx/cli/py.typed +0 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/__init__.py +0 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/__init__.py +0 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +60 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/__init__.py.j2 +0 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/mapping.py.j2 +27 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/repository.py.j2 +21 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/command/__init__.py +0 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/command/src/__service_pkg__/application/commands/__name_snake__.py.j2 +32 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/endpoint/__init__.py +0 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/endpoint/src/__service_pkg__/presentation/routes/__name_snake__.py.j2 +24 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/event/__init__.py +0 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/event/src/__service_pkg__/domain/events/__name_snake__.py.j2 +18 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/query/__init__.py +0 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/query/src/__service_pkg__/application/queries/__name_snake__.py.j2 +21 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/.env.example.j2 +16 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/Dockerfile.j2 +19 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/README.md.j2 +59 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/__init__.py +0 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/alembic/env.py.j2 +13 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/alembic/script.py.mako +26 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/alembic.ini.j2 +38 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/pyproject.toml.j2 +47 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +3 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +22 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +22 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +22 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +88 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +21 -0
- qx_cli-0.1.0/src/qx/cli/scaffolds/service/tests/test_smoke.py.j2 +33 -0
- qx_cli-0.1.0/src/qx/cli/templates/__init__.py +162 -0
- qx_cli-0.1.0/tests/test_cli.py +157 -0
qx_cli-0.1.0/.gitignore
ADDED
|
@@ -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
|
|
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
|