qx-cli 0.1.0__tar.gz → 0.2.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 → qx_cli-0.2.0}/.gitignore +5 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/PKG-INFO +1 -1
- {qx_cli-0.1.0 → qx_cli-0.2.0}/pyproject.toml +1 -1
- qx_cli-0.2.0/src/qx/cli/commands/dev.py +134 -0
- qx_cli-0.2.0/src/qx/cli/commands/doctor.py +216 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/commands/generate.py +7 -1
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/commands/new.py +1 -1
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/main.py +3 -1
- qx_cli-0.2.0/src/qx/cli/scaffolds/aggregate/alembic/versions/__migration_name__.py.j2 +49 -0
- qx_cli-0.2.0/src/qx/cli/scaffolds/query/__init__.py +0 -0
- qx_cli-0.2.0/src/qx/cli/scaffolds/service/__init__.py +0 -0
- qx_cli-0.2.0/src/qx/cli/scaffolds/service/docker-compose.override.yaml.j2 +17 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/pyproject.toml.j2 +3 -3
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +10 -11
- {qx_cli-0.1.0 → qx_cli-0.2.0}/tests/test_cli.py +40 -22
- qx_cli-0.1.0/src/qx/cli/commands/dev.py +0 -86
- {qx_cli-0.1.0 → qx_cli-0.2.0}/README.md +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/__init__.py +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/commands/__init__.py +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/py.typed +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/__init__.py +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/aggregate/__init__.py +0 -0
- {qx_cli-0.1.0/src/qx/cli/scaffolds/command → qx_cli-0.2.0/src/qx/cli/scaffolds/aggregate/alembic}/__init__.py +0 -0
- {qx_cli-0.1.0/src/qx/cli/scaffolds/endpoint → qx_cli-0.2.0/src/qx/cli/scaffolds/aggregate/alembic/versions}/__init__.py +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/__init__.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/mapping.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/repository.py.j2 +0 -0
- {qx_cli-0.1.0/src/qx/cli/scaffolds/event → qx_cli-0.2.0/src/qx/cli/scaffolds/command}/__init__.py +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/command/src/__service_pkg__/application/commands/__name_snake__.py.j2 +0 -0
- {qx_cli-0.1.0/src/qx/cli/scaffolds/query → qx_cli-0.2.0/src/qx/cli/scaffolds/endpoint}/__init__.py +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/endpoint/src/__service_pkg__/presentation/routes/__name_snake__.py.j2 +0 -0
- {qx_cli-0.1.0/src/qx/cli/scaffolds/service → qx_cli-0.2.0/src/qx/cli/scaffolds/event}/__init__.py +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/event/src/__service_pkg__/domain/events/__name_snake__.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/query/src/__service_pkg__/application/queries/__name_snake__.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/.env.example.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/Dockerfile.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/README.md.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/alembic/env.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/alembic/script.py.mako +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/alembic.ini.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/tests/test_smoke.py.j2 +0 -0
- {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/templates/__init__.py +0 -0
|
@@ -0,0 +1,134 @@
|
|
|
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_files() -> list[Path]:
|
|
17
|
+
"""Locate the base compose file and any service-level override.
|
|
18
|
+
|
|
19
|
+
Returns a list of ``-f <path>`` values — base first, override second
|
|
20
|
+
(if present). Docker Compose merges them in order.
|
|
21
|
+
|
|
22
|
+
Search order for the base file:
|
|
23
|
+
1. ``./deploy/docker-compose.yaml``
|
|
24
|
+
2. ``./docker-compose.yaml``
|
|
25
|
+
3. Walk up looking for ``deploy/docker-compose.yaml``
|
|
26
|
+
|
|
27
|
+
A ``docker-compose.override.yaml`` in CWD is automatically appended when
|
|
28
|
+
found, allowing per-service extensions of the shared stack.
|
|
29
|
+
"""
|
|
30
|
+
cwd = Path.cwd()
|
|
31
|
+
base: Path | None = None
|
|
32
|
+
|
|
33
|
+
candidates = [
|
|
34
|
+
cwd / "deploy" / "docker-compose.yaml",
|
|
35
|
+
cwd / "docker-compose.yaml",
|
|
36
|
+
]
|
|
37
|
+
for c in candidates:
|
|
38
|
+
if c.exists():
|
|
39
|
+
base = c
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
if base is None:
|
|
43
|
+
for parent in cwd.parents:
|
|
44
|
+
c = parent / "deploy" / "docker-compose.yaml"
|
|
45
|
+
if c.exists():
|
|
46
|
+
base = c
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if base is None:
|
|
50
|
+
raise typer.BadParameter(
|
|
51
|
+
"Could not find a docker-compose.yaml. Run from a qx project tree."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
files = [base]
|
|
55
|
+
override = cwd / "docker-compose.override.yaml"
|
|
56
|
+
if override.exists():
|
|
57
|
+
files.append(override)
|
|
58
|
+
console.print(f"[dim]using override: {override}[/dim]")
|
|
59
|
+
|
|
60
|
+
return files
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _docker_compose_cmd() -> list[str]:
|
|
64
|
+
"""Pick whichever compose flavor is available."""
|
|
65
|
+
if shutil.which("docker"):
|
|
66
|
+
return ["docker", "compose"]
|
|
67
|
+
if shutil.which("podman"):
|
|
68
|
+
return ["podman-compose"]
|
|
69
|
+
raise typer.BadParameter("Neither docker nor podman found on PATH.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _compose_file_flags() -> list[str]:
|
|
73
|
+
"""Build the ``-f file [-f override]`` flags for all compose subcommands."""
|
|
74
|
+
flags: list[str] = []
|
|
75
|
+
for f in _find_compose_files():
|
|
76
|
+
flags += ["-f", str(f)]
|
|
77
|
+
return flags
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def up(
|
|
82
|
+
detach: bool = typer.Option(True, "--detach/--no-detach", "-d/-n"),
|
|
83
|
+
service: str = typer.Argument("", help="Bring up a specific service only (omit for all)."),
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Start the local infrastructure stack (+ service override if present)."""
|
|
86
|
+
cmd = [*_docker_compose_cmd(), *_compose_file_flags(), "up"]
|
|
87
|
+
if detach:
|
|
88
|
+
cmd.append("-d")
|
|
89
|
+
if service:
|
|
90
|
+
cmd.append(service)
|
|
91
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
92
|
+
subprocess.run(cmd, check=False)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def down(
|
|
97
|
+
volumes: bool = typer.Option(False, "--volumes", "-v", help="Also remove volumes."),
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Stop the local infrastructure stack."""
|
|
100
|
+
cmd = [*_docker_compose_cmd(), *_compose_file_flags(), "down"]
|
|
101
|
+
if volumes:
|
|
102
|
+
cmd.append("-v")
|
|
103
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
104
|
+
subprocess.run(cmd, check=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def logs(
|
|
109
|
+
service: str = typer.Argument("", help="Specific service to tail (omit for all)."),
|
|
110
|
+
tail: int = typer.Option(100, "--tail", "-n", help="Number of lines to show from the end."),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Tail logs from the local stack."""
|
|
113
|
+
cmd = [*_docker_compose_cmd(), *_compose_file_flags(), "logs", "-f", "--tail", str(tail)]
|
|
114
|
+
if service:
|
|
115
|
+
cmd.append(service)
|
|
116
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
117
|
+
subprocess.run(cmd, check=False)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def status() -> None:
|
|
122
|
+
"""Show the running containers."""
|
|
123
|
+
cmd = [*_docker_compose_cmd(), *_compose_file_flags(), "ps"]
|
|
124
|
+
subprocess.run(cmd, check=False)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command()
|
|
128
|
+
def restart(
|
|
129
|
+
service: str = typer.Argument(..., help="Service name to restart."),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Restart a specific container in the stack."""
|
|
132
|
+
cmd = [*_docker_compose_cmd(), *_compose_file_flags(), "restart", service]
|
|
133
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
134
|
+
subprocess.run(cmd, check=False)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""``qx doctor`` — environment health check.
|
|
2
|
+
|
|
3
|
+
Validates that all tools and services required to develop with qx are
|
|
4
|
+
reachable. Exits non-zero if any required component is missing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.metadata
|
|
10
|
+
import shutil
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
app = typer.Typer()
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
_QX_PACKAGES = [
|
|
24
|
+
"qx-core",
|
|
25
|
+
"qx-di",
|
|
26
|
+
"qx-cqrs",
|
|
27
|
+
"qx-db",
|
|
28
|
+
"qx-http",
|
|
29
|
+
"qx-observability",
|
|
30
|
+
"qx-events",
|
|
31
|
+
"qx-worker",
|
|
32
|
+
"qx-cache",
|
|
33
|
+
"qx-auth",
|
|
34
|
+
"qx-grpc",
|
|
35
|
+
"qx-search",
|
|
36
|
+
"qx-testing",
|
|
37
|
+
"qx-cli",
|
|
38
|
+
"qx-devtools",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_TOOL_FIXES: dict[str, str] = {
|
|
42
|
+
"uv": "curl -LsSf https://astral.sh/uv/install.sh | sh",
|
|
43
|
+
"git": "brew install git # macOS\n# or: apt install git",
|
|
44
|
+
"docker": "# Install Docker Desktop:\nhttps://www.docker.com/products/docker-desktop/",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ok(msg: str) -> str:
|
|
49
|
+
return f"[green]✓[/green] {msg}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fail(msg: str) -> str:
|
|
53
|
+
return f"[red]✗[/red] {msg}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _warn(msg: str) -> str:
|
|
57
|
+
return f"[yellow]![/yellow] {msg}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _tcp_reachable(host: str, port: int, timeout: float = 2.0) -> bool:
|
|
61
|
+
try:
|
|
62
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
63
|
+
return True
|
|
64
|
+
except OSError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_python(failures: list[str], fix_commands: list[str]) -> None:
|
|
69
|
+
major, minor, micro = sys.version_info[:3]
|
|
70
|
+
ver_str = f"{major}.{minor}.{micro}"
|
|
71
|
+
console.print("\n[bold]Python[/bold]")
|
|
72
|
+
if (major, minor) >= (3, 14):
|
|
73
|
+
console.print(_ok(f"Python {ver_str}"))
|
|
74
|
+
else:
|
|
75
|
+
msg = f"Python {ver_str} — need ≥ 3.14"
|
|
76
|
+
console.print(_fail(msg))
|
|
77
|
+
failures.append(msg)
|
|
78
|
+
fix_commands.append("# Install Python 3.14 via uv:\nuv python install 3.14")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_tools(failures: list[str], fix_commands: list[str]) -> None:
|
|
82
|
+
console.print("\n[bold]Tools[/bold]")
|
|
83
|
+
for tool in ("uv", "git", "docker", "ruff", "mypy"):
|
|
84
|
+
path = shutil.which(tool)
|
|
85
|
+
if path:
|
|
86
|
+
try:
|
|
87
|
+
out = subprocess.run(
|
|
88
|
+
[tool, "--version"], capture_output=True, text=True, timeout=5, check=False
|
|
89
|
+
)
|
|
90
|
+
ver = (out.stdout or out.stderr).strip().splitlines()[0]
|
|
91
|
+
console.print(_ok(f"{tool} [dim]{ver}[/dim]"))
|
|
92
|
+
except Exception:
|
|
93
|
+
console.print(_ok(f"{tool} [dim]{path}[/dim]"))
|
|
94
|
+
elif tool in _TOOL_FIXES:
|
|
95
|
+
msg = f"{tool} not found on PATH"
|
|
96
|
+
console.print(_fail(msg))
|
|
97
|
+
failures.append(msg)
|
|
98
|
+
fix_commands.append(f"# Install {tool}:\n{_TOOL_FIXES[tool]}")
|
|
99
|
+
else:
|
|
100
|
+
console.print(_warn(f"{tool} not found (optional for dev)"))
|
|
101
|
+
|
|
102
|
+
if shutil.which("docker"):
|
|
103
|
+
try:
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
["docker", "info"], capture_output=True, text=True, timeout=5, check=False
|
|
106
|
+
)
|
|
107
|
+
if result.returncode == 0:
|
|
108
|
+
console.print(_ok("Docker daemon running"))
|
|
109
|
+
else:
|
|
110
|
+
console.print(_warn("Docker daemon not running (run: docker desktop start)"))
|
|
111
|
+
except Exception:
|
|
112
|
+
console.print(_warn("Docker daemon check timed out"))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_packages() -> None:
|
|
116
|
+
console.print("\n[bold]qx packages[/bold]")
|
|
117
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
118
|
+
table.add_column("Package")
|
|
119
|
+
table.add_column("Version")
|
|
120
|
+
table.add_column("Status")
|
|
121
|
+
for pkg in _QX_PACKAGES:
|
|
122
|
+
try:
|
|
123
|
+
ver = importlib.metadata.version(pkg)
|
|
124
|
+
table.add_row(pkg, ver, "[green]installed[/green]")
|
|
125
|
+
except importlib.metadata.PackageNotFoundError:
|
|
126
|
+
table.add_row(pkg, "—", "[yellow]not installed[/yellow]")
|
|
127
|
+
console.print(table)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_env() -> None:
|
|
131
|
+
import os # noqa: PLC0415
|
|
132
|
+
|
|
133
|
+
console.print("\n[bold]Environment variables[/bold]")
|
|
134
|
+
env_vars = {
|
|
135
|
+
"DATABASE_URL": True,
|
|
136
|
+
"REDIS_URL": False,
|
|
137
|
+
"NATS_URL": False,
|
|
138
|
+
"SECRET_KEY": False,
|
|
139
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT": False,
|
|
140
|
+
}
|
|
141
|
+
for var, required in env_vars.items():
|
|
142
|
+
val = os.getenv(var)
|
|
143
|
+
if val:
|
|
144
|
+
display = "***" if "SECRET" in var else val
|
|
145
|
+
console.print(_ok(f"{var} [dim]{display}[/dim]"))
|
|
146
|
+
elif required:
|
|
147
|
+
console.print(_warn(f"{var} not set"))
|
|
148
|
+
else:
|
|
149
|
+
console.print(f" [dim]{var} not set (optional)[/dim]")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_connectivity() -> None:
|
|
153
|
+
import os # noqa: PLC0415
|
|
154
|
+
|
|
155
|
+
console.print("\n[bold]Connectivity[/bold]")
|
|
156
|
+
defaults = {
|
|
157
|
+
"DATABASE_URL": "postgresql://localhost:5432",
|
|
158
|
+
"REDIS_URL": "redis://localhost:6379",
|
|
159
|
+
"NATS_URL": "nats://localhost:4222",
|
|
160
|
+
}
|
|
161
|
+
default_ports = {"postgres": 5432, "redis": 6379, "nats": 4222}
|
|
162
|
+
labels = {"DATABASE_URL": "Postgres", "REDIS_URL": "Redis", "NATS_URL": "NATS"}
|
|
163
|
+
|
|
164
|
+
for env_var, label in labels.items():
|
|
165
|
+
raw = os.getenv(env_var, defaults[env_var])
|
|
166
|
+
try:
|
|
167
|
+
parsed = urlparse(raw)
|
|
168
|
+
host = parsed.hostname or "localhost"
|
|
169
|
+
key = label.lower()
|
|
170
|
+
port = parsed.port or next(v for k, v in default_ports.items() if k in key)
|
|
171
|
+
except Exception:
|
|
172
|
+
continue
|
|
173
|
+
if _tcp_reachable(host, port):
|
|
174
|
+
console.print(_ok(f"{label} {host}:{port}"))
|
|
175
|
+
else:
|
|
176
|
+
console.print(
|
|
177
|
+
_warn(f"{label} {host}:{port} unreachable (is the stack running? qx dev up)")
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.callback(invoke_without_command=True)
|
|
182
|
+
def doctor(
|
|
183
|
+
connectivity: bool = typer.Option(
|
|
184
|
+
False, "--connectivity", "-c", help="Also probe Postgres / Redis / NATS TCP ports."
|
|
185
|
+
),
|
|
186
|
+
fix: bool = typer.Option(
|
|
187
|
+
False, "--fix", help="Print shell commands to resolve detected issues."
|
|
188
|
+
),
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Check that your development environment is correctly set up."""
|
|
191
|
+
failures: list[str] = []
|
|
192
|
+
fix_commands: list[str] = []
|
|
193
|
+
|
|
194
|
+
console.rule("[bold]qx doctor[/bold]")
|
|
195
|
+
_check_python(failures, fix_commands)
|
|
196
|
+
_check_tools(failures, fix_commands)
|
|
197
|
+
_check_packages()
|
|
198
|
+
_check_env()
|
|
199
|
+
|
|
200
|
+
if connectivity:
|
|
201
|
+
_check_connectivity()
|
|
202
|
+
|
|
203
|
+
console.print()
|
|
204
|
+
if failures:
|
|
205
|
+
console.rule(f"[red]{len(failures)} issue(s) found[/red]")
|
|
206
|
+
if fix and fix_commands:
|
|
207
|
+
console.print("\n[bold]Suggested fixes:[/bold]\n")
|
|
208
|
+
for cmd in fix_commands:
|
|
209
|
+
console.print(f"[dim]{cmd}[/dim]\n")
|
|
210
|
+
else:
|
|
211
|
+
console.print(
|
|
212
|
+
"[dim]Run [bold]qx doctor --fix[/bold] for suggested remediation commands.[/dim]"
|
|
213
|
+
)
|
|
214
|
+
raise typer.Exit(code=1)
|
|
215
|
+
else:
|
|
216
|
+
console.rule("[green]All checks passed[/green]")
|
|
@@ -46,9 +46,15 @@ def aggregate(
|
|
|
46
46
|
name: str = typer.Argument(..., help="Aggregate name (PascalCase, e.g. 'Invoice')."),
|
|
47
47
|
force: bool = typer.Option(False, "--force", "-f"),
|
|
48
48
|
) -> None:
|
|
49
|
-
"""Generate a new aggregate (domain entity + table mapping + repository)."""
|
|
49
|
+
"""Generate a new aggregate (domain entity + table mapping + repository + migration stub)."""
|
|
50
|
+
import secrets # noqa: PLC0415
|
|
51
|
+
from datetime import UTC, datetime # noqa: PLC0415
|
|
52
|
+
|
|
50
53
|
root, pkg = _service_package()
|
|
51
54
|
context = _names(name, pkg)
|
|
55
|
+
context["revision_id"] = secrets.token_hex(6)
|
|
56
|
+
context["create_date"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
57
|
+
context["migration_name"] = f"create_{context['name_snake']}"
|
|
52
58
|
files = render_tree(
|
|
53
59
|
"qx.cli.scaffolds",
|
|
54
60
|
"aggregate",
|
|
@@ -55,7 +55,7 @@ def service(
|
|
|
55
55
|
"\n[bold]Next steps:[/bold]\n"
|
|
56
56
|
f" cd {context['service_kebab']}\n"
|
|
57
57
|
" uv sync\n"
|
|
58
|
-
"
|
|
58
|
+
" qx dev up # start Postgres · Redis · NATS · Grafana\n"
|
|
59
59
|
" uv run alembic upgrade head\n"
|
|
60
60
|
" uv run uvicorn " + f"{pkg_name}.main:app --reload\n"
|
|
61
61
|
)
|
|
@@ -10,6 +10,7 @@ Subcommands::
|
|
|
10
10
|
qx generate event NAME # add an integration event
|
|
11
11
|
qx dev up # start docker-compose stack
|
|
12
12
|
qx dev down # stop the stack
|
|
13
|
+
qx doctor # check dev environment health
|
|
13
14
|
qx version # print framework version
|
|
14
15
|
|
|
15
16
|
Invoke as ``qx`` once the package is installed; ``uv run qx ...``
|
|
@@ -19,7 +20,7 @@ during development.
|
|
|
19
20
|
from __future__ import annotations
|
|
20
21
|
|
|
21
22
|
import typer
|
|
22
|
-
from qx.cli.commands import dev, generate, new
|
|
23
|
+
from qx.cli.commands import dev, doctor, generate, new
|
|
23
24
|
from rich.console import Console
|
|
24
25
|
|
|
25
26
|
console = Console()
|
|
@@ -38,6 +39,7 @@ app.add_typer(
|
|
|
38
39
|
help="Generate code into an existing service (aggregate, command, query, ...).",
|
|
39
40
|
)
|
|
40
41
|
app.add_typer(dev.app, name="dev", help="Local development orchestration.")
|
|
42
|
+
app.add_typer(doctor.app, name="doctor", help="Check development environment health.")
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
@app.command()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""create {{ name_snake }} table
|
|
2
|
+
|
|
3
|
+
Revision ID: {{ revision_id }}
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: {{ create_date }}
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
from alembic import op
|
|
12
|
+
|
|
13
|
+
revision = "{{ revision_id }}"
|
|
14
|
+
down_revision = None # set to previous revision ID when chaining
|
|
15
|
+
branch_labels = None
|
|
16
|
+
depends_on = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade() -> None:
|
|
20
|
+
op.create_table(
|
|
21
|
+
"{{ name_snake }}",
|
|
22
|
+
sa.Column("id", sa.Uuid(), nullable=False),
|
|
23
|
+
# Add columns matching the aggregate's fields here:
|
|
24
|
+
# sa.Column("name", sa.String(255), nullable=False),
|
|
25
|
+
sa.Column(
|
|
26
|
+
"created_at",
|
|
27
|
+
sa.DateTime(timezone=True),
|
|
28
|
+
nullable=False,
|
|
29
|
+
server_default=sa.text("now()"),
|
|
30
|
+
),
|
|
31
|
+
sa.Column(
|
|
32
|
+
"updated_at",
|
|
33
|
+
sa.DateTime(timezone=True),
|
|
34
|
+
nullable=False,
|
|
35
|
+
server_default=sa.text("now()"),
|
|
36
|
+
),
|
|
37
|
+
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
|
|
38
|
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_{{ name_snake }}")),
|
|
39
|
+
)
|
|
40
|
+
op.create_index(
|
|
41
|
+
op.f("ix_{{ name_snake }}_created_at"),
|
|
42
|
+
"{{ name_snake }}",
|
|
43
|
+
["created_at"],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def downgrade() -> None:
|
|
48
|
+
op.drop_index(op.f("ix_{{ name_snake }}_created_at"), table_name="{{ name_snake }}")
|
|
49
|
+
op.drop_table("{{ name_snake }}")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Per-service Docker Compose override.
|
|
2
|
+
#
|
|
3
|
+
# Merged on top of the shared stack (deploy/docker-compose.yaml) when you run
|
|
4
|
+
# ``qx dev up`` from this directory. Use it to add service-specific containers
|
|
5
|
+
# (e.g., seed jobs, mocks, secondary databases) without modifying the shared file.
|
|
6
|
+
#
|
|
7
|
+
# Example — add a local HTTP mock:
|
|
8
|
+
#
|
|
9
|
+
# services:
|
|
10
|
+
# payment-mock:
|
|
11
|
+
# image: mockserver/mockserver:latest
|
|
12
|
+
# ports:
|
|
13
|
+
# - "1080:1080"
|
|
14
|
+
|
|
15
|
+
name: {{ service_kebab }}-dev
|
|
16
|
+
|
|
17
|
+
services: {}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name = "{{ service_kebab }}"
|
|
3
3
|
version = "0.1.0"
|
|
4
4
|
description = "{{ service_name }} — a Qx service."
|
|
5
|
-
requires-python = ">=3.
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"qx-core",
|
|
8
8
|
"qx-di",
|
|
@@ -39,9 +39,9 @@ testpaths = ["tests"]
|
|
|
39
39
|
|
|
40
40
|
[tool.ruff]
|
|
41
41
|
line-length = 100
|
|
42
|
-
target-version = "
|
|
42
|
+
target-version = "py314"
|
|
43
43
|
|
|
44
44
|
[tool.mypy]
|
|
45
|
-
python_version = "3.
|
|
45
|
+
python_version = "3.14"
|
|
46
46
|
strict = true
|
|
47
47
|
plugins = ["pydantic.mypy"]
|
|
@@ -10,7 +10,6 @@ from collections.abc import AsyncIterator
|
|
|
10
10
|
from contextlib import asynccontextmanager
|
|
11
11
|
|
|
12
12
|
from fastapi import FastAPI
|
|
13
|
-
|
|
14
13
|
from qx.core import QxSettings
|
|
15
14
|
from qx.cqrs import ExceptionTranslationBehavior, LoggingBehavior, Mediator
|
|
16
15
|
from qx.db import (
|
|
@@ -20,6 +19,7 @@ from qx.db import (
|
|
|
20
19
|
create_engine,
|
|
21
20
|
make_session_factory,
|
|
22
21
|
)
|
|
22
|
+
from qx.db.outbox import DefaultOutboxRecorder
|
|
23
23
|
from qx.di import Container
|
|
24
24
|
from qx.events import EventRegistry, MediatorEventDispatcher
|
|
25
25
|
from qx.http import setup_qx_app
|
|
@@ -35,7 +35,7 @@ def build_app() -> FastAPI:
|
|
|
35
35
|
metrics, health = setup_observability(settings)
|
|
36
36
|
container = Container()
|
|
37
37
|
|
|
38
|
-
# ----
|
|
38
|
+
# ---- Infrastructure singletons ----
|
|
39
39
|
db_settings = DatabaseSettings()
|
|
40
40
|
engine = create_engine(db_settings)
|
|
41
41
|
session_factory = make_session_factory(engine)
|
|
@@ -49,19 +49,18 @@ def build_app() -> FastAPI:
|
|
|
49
49
|
)
|
|
50
50
|
container.register_instance(Mediator, mediator)
|
|
51
51
|
|
|
52
|
-
# ---- Event registry
|
|
52
|
+
# ---- Event registry ----
|
|
53
53
|
event_registry = EventRegistry()
|
|
54
54
|
register_events(event_registry)
|
|
55
55
|
container.register_instance(EventRegistry, event_registry)
|
|
56
56
|
|
|
57
|
-
# ---- UnitOfWork
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)
|
|
57
|
+
# ---- UnitOfWork (scoped per request) ----
|
|
58
|
+
dispatcher = MediatorEventDispatcher(mediator)
|
|
59
|
+
outbox = DefaultOutboxRecorder()
|
|
60
|
+
|
|
61
|
+
def _uow_factory(sf: SessionFactory) -> UnitOfWork:
|
|
62
|
+
return UnitOfWork(session_factory=sf, dispatcher=dispatcher, outbox=outbox)
|
|
63
|
+
|
|
65
64
|
container.register_scoped(UnitOfWork, _uow_factory)
|
|
66
65
|
|
|
67
66
|
# ---- Handlers (commands, queries, integration handlers) ----
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import ast
|
|
6
|
+
import os
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
8
9
|
from typing import TYPE_CHECKING
|
|
@@ -37,7 +38,6 @@ def test_new_service_renders_valid_python(tmp_path: Path) -> None:
|
|
|
37
38
|
assert (project / "Dockerfile").exists()
|
|
38
39
|
assert (project / "tests" / "test_smoke.py").exists()
|
|
39
40
|
|
|
40
|
-
# Every generated .py must parse cleanly.
|
|
41
41
|
for py_file in project.rglob("*.py"):
|
|
42
42
|
text = py_file.read_text()
|
|
43
43
|
try:
|
|
@@ -66,7 +66,7 @@ def test_new_service_generates_lint_clean_python(tmp_path: Path) -> None:
|
|
|
66
66
|
# ---- qx generate ----
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def _scaffold_service(tmp_path: Path, name: str = "my-service") ->
|
|
69
|
+
def _scaffold_service(tmp_path: Path, name: str = "my-service") -> Path:
|
|
70
70
|
result = runner.invoke(app, ["new", "service", name, "--target", str(tmp_path)])
|
|
71
71
|
assert result.exit_code == 0, result.stdout
|
|
72
72
|
return tmp_path / name
|
|
@@ -74,14 +74,6 @@ def _scaffold_service(tmp_path: Path, name: str = "my-service") -> "Path":
|
|
|
74
74
|
|
|
75
75
|
def test_generate_command_renders_valid_python(tmp_path: Path) -> None:
|
|
76
76
|
project = _scaffold_service(tmp_path)
|
|
77
|
-
result = runner.invoke(
|
|
78
|
-
app,
|
|
79
|
-
["generate", "command", "CreateOrder", "--aggregate", "Order"],
|
|
80
|
-
catch_exceptions=False,
|
|
81
|
-
)
|
|
82
|
-
# generate resolves from cwd; we need to cd into the service dir
|
|
83
|
-
import os
|
|
84
|
-
|
|
85
77
|
old = os.getcwd()
|
|
86
78
|
try:
|
|
87
79
|
os.chdir(project)
|
|
@@ -96,8 +88,6 @@ def test_generate_command_renders_valid_python(tmp_path: Path) -> None:
|
|
|
96
88
|
|
|
97
89
|
def test_generate_query_renders_valid_python(tmp_path: Path) -> None:
|
|
98
90
|
project = _scaffold_service(tmp_path)
|
|
99
|
-
import os
|
|
100
|
-
|
|
101
91
|
old = os.getcwd()
|
|
102
92
|
try:
|
|
103
93
|
os.chdir(project)
|
|
@@ -112,8 +102,6 @@ def test_generate_query_renders_valid_python(tmp_path: Path) -> None:
|
|
|
112
102
|
|
|
113
103
|
def test_generate_event_renders_valid_python(tmp_path: Path) -> None:
|
|
114
104
|
project = _scaffold_service(tmp_path)
|
|
115
|
-
import os
|
|
116
|
-
|
|
117
105
|
old = os.getcwd()
|
|
118
106
|
try:
|
|
119
107
|
os.chdir(project)
|
|
@@ -128,18 +116,12 @@ def test_generate_event_renders_valid_python(tmp_path: Path) -> None:
|
|
|
128
116
|
|
|
129
117
|
def test_generate_endpoint_renders_valid_python(tmp_path: Path) -> None:
|
|
130
118
|
project = _scaffold_service(tmp_path)
|
|
131
|
-
import os
|
|
132
|
-
|
|
133
119
|
old = os.getcwd()
|
|
134
120
|
try:
|
|
135
121
|
os.chdir(project)
|
|
136
|
-
result = runner.invoke(
|
|
137
|
-
app, ["generate", "endpoint", "/orders", "--handler", "CreateOrder"]
|
|
138
|
-
)
|
|
122
|
+
result = runner.invoke(app, ["generate", "endpoint", "/orders", "--handler", "CreateOrder"])
|
|
139
123
|
assert result.exit_code == 0, result.stdout
|
|
140
|
-
generated =
|
|
141
|
-
project / "src" / "my_service" / "presentation" / "routes" / "create_order.py"
|
|
142
|
-
)
|
|
124
|
+
generated = project / "src" / "my_service" / "presentation" / "routes" / "create_order.py"
|
|
143
125
|
assert generated.exists()
|
|
144
126
|
ast.parse(generated.read_text())
|
|
145
127
|
finally:
|
|
@@ -155,3 +137,39 @@ def test_new_service_refuses_nonempty_dir_without_force(tmp_path: Path) -> None:
|
|
|
155
137
|
["new", "service", "demo", "--target", str(tmp_path)],
|
|
156
138
|
)
|
|
157
139
|
assert result.exit_code != 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_doctor_runs_without_error(tmp_path: Path) -> None:
|
|
143
|
+
result = runner.invoke(app, ["doctor"])
|
|
144
|
+
assert result.exception is None or isinstance(result.exception, SystemExit)
|
|
145
|
+
assert "qx doctor" in result.stdout
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_generate_aggregate_renders_valid_python(tmp_path: Path) -> None:
|
|
149
|
+
project = _scaffold_service(tmp_path)
|
|
150
|
+
old = os.getcwd()
|
|
151
|
+
try:
|
|
152
|
+
os.chdir(project)
|
|
153
|
+
result = runner.invoke(app, ["generate", "aggregate", "Invoice"])
|
|
154
|
+
assert result.exit_code == 0, result.stdout
|
|
155
|
+
|
|
156
|
+
agg = project / "src" / "my_service" / "domain" / "aggregates" / "invoice" / "__init__.py"
|
|
157
|
+
repo = (
|
|
158
|
+
project
|
|
159
|
+
/ "src"
|
|
160
|
+
/ "my_service"
|
|
161
|
+
/ "infrastructure"
|
|
162
|
+
/ "persistence"
|
|
163
|
+
/ "invoice"
|
|
164
|
+
/ "repository.py"
|
|
165
|
+
)
|
|
166
|
+
migration = project / "alembic" / "versions" / "create_invoice.py"
|
|
167
|
+
|
|
168
|
+
assert agg.exists(), "aggregate __init__.py missing"
|
|
169
|
+
assert repo.exists(), "repository.py missing"
|
|
170
|
+
assert migration.exists(), "migration stub missing"
|
|
171
|
+
|
|
172
|
+
for f in (agg, repo, migration):
|
|
173
|
+
ast.parse(f.read_text())
|
|
174
|
+
finally:
|
|
175
|
+
os.chdir(old)
|
|
@@ -1,86 +0,0 @@
|
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qx_cli-0.1.0/src/qx/cli/scaffolds/event → qx_cli-0.2.0/src/qx/cli/scaffolds/command}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{qx_cli-0.1.0/src/qx/cli/scaffolds/query → qx_cli-0.2.0/src/qx/cli/scaffolds/endpoint}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{qx_cli-0.1.0/src/qx/cli/scaffolds/service → qx_cli-0.2.0/src/qx/cli/scaffolds/event}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2
RENAMED
|
File without changes
|
|
File without changes
|
{qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|