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.
Files changed (48) hide show
  1. {qx_cli-0.1.0 → qx_cli-0.2.0}/.gitignore +5 -0
  2. {qx_cli-0.1.0 → qx_cli-0.2.0}/PKG-INFO +1 -1
  3. {qx_cli-0.1.0 → qx_cli-0.2.0}/pyproject.toml +1 -1
  4. qx_cli-0.2.0/src/qx/cli/commands/dev.py +134 -0
  5. qx_cli-0.2.0/src/qx/cli/commands/doctor.py +216 -0
  6. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/commands/generate.py +7 -1
  7. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/commands/new.py +1 -1
  8. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/main.py +3 -1
  9. qx_cli-0.2.0/src/qx/cli/scaffolds/aggregate/alembic/versions/__migration_name__.py.j2 +49 -0
  10. qx_cli-0.2.0/src/qx/cli/scaffolds/query/__init__.py +0 -0
  11. qx_cli-0.2.0/src/qx/cli/scaffolds/service/__init__.py +0 -0
  12. qx_cli-0.2.0/src/qx/cli/scaffolds/service/docker-compose.override.yaml.j2 +17 -0
  13. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/pyproject.toml.j2 +3 -3
  14. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +10 -11
  15. {qx_cli-0.1.0 → qx_cli-0.2.0}/tests/test_cli.py +40 -22
  16. qx_cli-0.1.0/src/qx/cli/commands/dev.py +0 -86
  17. {qx_cli-0.1.0 → qx_cli-0.2.0}/README.md +0 -0
  18. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/__init__.py +0 -0
  19. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/commands/__init__.py +0 -0
  20. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/py.typed +0 -0
  21. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/__init__.py +0 -0
  22. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/aggregate/__init__.py +0 -0
  23. {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
  24. {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
  25. {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
  26. {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
  27. {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
  28. {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
  29. {qx_cli-0.1.0/src/qx/cli/scaffolds/event → qx_cli-0.2.0/src/qx/cli/scaffolds/command}/__init__.py +0 -0
  30. {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
  31. {qx_cli-0.1.0/src/qx/cli/scaffolds/query → qx_cli-0.2.0/src/qx/cli/scaffolds/endpoint}/__init__.py +0 -0
  32. {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
  33. {qx_cli-0.1.0/src/qx/cli/scaffolds/service → qx_cli-0.2.0/src/qx/cli/scaffolds/event}/__init__.py +0 -0
  34. {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
  35. {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
  36. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/.env.example.j2 +0 -0
  37. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/Dockerfile.j2 +0 -0
  38. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/README.md.j2 +0 -0
  39. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/alembic/env.py.j2 +0 -0
  40. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/alembic/script.py.mako +0 -0
  41. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/alembic.ini.j2 +0 -0
  42. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +0 -0
  43. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +0 -0
  44. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +0 -0
  45. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +0 -0
  46. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +0 -0
  47. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/scaffolds/service/tests/test_smoke.py.j2 +0 -0
  48. {qx_cli-0.1.0 → qx_cli-0.2.0}/src/qx/cli/templates/__init__.py +0 -0
@@ -49,3 +49,8 @@ Thumbs.db
49
49
 
50
50
  # Dist artifacts
51
51
  dist/
52
+
53
+ # VS Code extension build artifacts
54
+ extensions/vscode/node_modules/
55
+ extensions/vscode/dist/
56
+ extensions/vscode/*.vsix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qx-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Qx CLI: scaffolding, code generation, local dev orchestration
5
5
  Author: Qx Engineering
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qx-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Qx CLI: scaffolding, code generation, local dev orchestration"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -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
- " docker compose -f ../deploy/docker-compose.yaml up -d # local Postgres/Redis/NATS\n"
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.12"
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 = "py312"
42
+ target-version = "py314"
43
43
 
44
44
  [tool.mypy]
45
- python_version = "3.12"
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
- # ---- Settings + infrastructure singletons ----
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 (integration event types this service knows about) ----
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 scoped (per-request) ----
58
- def _uow_factory() -> UnitOfWork:
59
- from qx.db.outbox import DefaultOutboxRecorder
60
- return UnitOfWork(
61
- session_factory=session_factory,
62
- dispatcher=MediatorEventDispatcher(mediator),
63
- outbox=DefaultOutboxRecorder(),
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") -> "Path":
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