qx-cli 0.2.0__tar.gz → 1.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.2.0 → qx_cli-1.1.0}/PKG-INFO +2 -1
- {qx_cli-0.2.0 → qx_cli-1.1.0}/pyproject.toml +2 -1
- qx_cli-1.1.0/src/qx/cli/commands/dlq.py +231 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/doctor.py +39 -3
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/generate.py +104 -18
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/new.py +36 -5
- qx_cli-1.1.0/src/qx/cli/commands/projections.py +231 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/main.py +15 -1
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/repository.py.j2 +4 -3
- qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__/application/__slice_name_snake__/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__/application/__slice_name_snake__/commands/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__/application/__slice_name_snake__/commands/__name_snake__.py.j2 +28 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/endpoint/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/esaggregate/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/esaggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +85 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/event/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/query/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/application/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/application/__slice_name_snake__/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/application/__slice_name_snake__/queries/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/query_vs/src/__service_pkg__/application/__slice_name_snake__/queries/__name_snake__.py.j2 +22 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/pyproject.toml.j2 +1 -1
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +2 -1
- qx_cli-1.1.0/src/qx/cli/scaffolds/service/tests/test_smoke.py.j2 +46 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/.env.example.j2 +16 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/Dockerfile.j2 +19 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/README.md.j2 +63 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/alembic/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/alembic/env.py.j2 +13 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/alembic/script.py.mako +26 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/alembic.ini.j2 +38 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/docker-compose.override.yaml.j2 +17 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/pyproject.toml.j2 +47 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/__init__.py.j2 +3 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/commands/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/commands/__init__.py.j2 +1 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/queries/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__domain_snake__/queries/__init__.py.j2 +1 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/application/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/domain/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/domain/__init__.py.j2 +4 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/infrastructure/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/infrastructure/__init__.py.j2 +5 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/main.py.j2 +92 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/presentation/__domain_snake__.py.j2 +20 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/presentation/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/presentation/__init__.py.j2 +6 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/shared/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/src/__service_pkg__/shared/__init__.py.j2 +17 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/tests/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/service_vs/tests/test_smoke.py.j2 +46 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/__init__.py.j2 +1 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/commands/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/commands/__init__.py.j2 +1 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/queries/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/application/__slice_name_snake__/queries/__init__.py.j2 +1 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/presentation/__init__.py +0 -0
- qx_cli-1.1.0/src/qx/cli/scaffolds/slice/src/__service_pkg__/presentation/__slice_name_snake__.py.j2 +20 -0
- qx_cli-0.2.0/src/qx/cli/scaffolds/service/tests/test_smoke.py.j2 +0 -33
- {qx_cli-0.2.0 → qx_cli-1.1.0}/.gitignore +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/README.md +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/commands/dev.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/py.typed +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/alembic/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/alembic/versions/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/alembic/versions/__migration_name__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/__init__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/mapping.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/command/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/command/src/__service_pkg__/application/commands/__name_snake__.py.j2 +0 -0
- {qx_cli-0.2.0/src/qx/cli/scaffolds/endpoint → qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs}/__init__.py +0 -0
- {qx_cli-0.2.0/src/qx/cli/scaffolds/event → qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src}/__init__.py +0 -0
- {qx_cli-0.2.0/src/qx/cli/scaffolds/query → qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__}/__init__.py +0 -0
- {qx_cli-0.2.0/src/qx/cli/scaffolds/service → qx_cli-1.1.0/src/qx/cli/scaffolds/command_vs/src/__service_pkg__/application}/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/endpoint/src/__service_pkg__/presentation/routes/__name_snake__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/event/src/__service_pkg__/domain/events/__name_snake__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/query/src/__service_pkg__/application/queries/__name_snake__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/.env.example.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/Dockerfile.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/README.md.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/alembic/env.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/alembic/script.py.mako +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/alembic.ini.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/docker-compose.override.yaml.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/src/qx/cli/templates/__init__.py +0 -0
- {qx_cli-0.2.0 → qx_cli-1.1.0}/tests/test_cli.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qx-cli
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Qx CLI: scaffolding, code generation, local dev orchestration
|
|
5
5
|
Author: Qx Engineering
|
|
6
6
|
License: MIT
|
|
7
7
|
Requires-Python: >=3.14
|
|
8
8
|
Requires-Dist: jinja2>=3.1.0
|
|
9
|
+
Requires-Dist: nats-py>=2.9.0
|
|
9
10
|
Requires-Dist: pyyaml>=6.0
|
|
10
11
|
Requires-Dist: qx-core
|
|
11
12
|
Requires-Dist: rich>=13.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "qx-cli"
|
|
3
|
-
version = "
|
|
3
|
+
version = "1.1.0"
|
|
4
4
|
description = "Qx CLI: scaffolding, code generation, local dev orchestration"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -11,6 +11,7 @@ dependencies = [
|
|
|
11
11
|
"typer>=0.12.0",
|
|
12
12
|
"rich>=13.0.0",
|
|
13
13
|
"jinja2>=3.1.0",
|
|
14
|
+
"nats-py>=2.9.0",
|
|
14
15
|
"pyyaml>=6.0",
|
|
15
16
|
]
|
|
16
17
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""``qx dlq`` — inspect and replay dead-letter queue messages.
|
|
2
|
+
|
|
3
|
+
Commands::
|
|
4
|
+
|
|
5
|
+
qx dlq list # show recent dead letters
|
|
6
|
+
qx dlq replay <id> # re-publish a dead letter to its original NATS subject
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(no_args_is_help=True)
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_db_url() -> str:
|
|
23
|
+
url = os.getenv("DATABASE_URL")
|
|
24
|
+
if not url:
|
|
25
|
+
console.print("[red]DATABASE_URL is not set.[/red]")
|
|
26
|
+
raise typer.Exit(code=1)
|
|
27
|
+
return url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_nats_url() -> str:
|
|
31
|
+
return os.getenv("NATS_URL", "nats://localhost:4222")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("list")
|
|
35
|
+
def list_dead_letters(
|
|
36
|
+
db_url: str = typer.Option(
|
|
37
|
+
None,
|
|
38
|
+
"--db-url",
|
|
39
|
+
envvar="DATABASE_URL",
|
|
40
|
+
help="Postgres connection URL.",
|
|
41
|
+
),
|
|
42
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Maximum rows to display."),
|
|
43
|
+
event_name: str = typer.Option(
|
|
44
|
+
None,
|
|
45
|
+
"--event-name",
|
|
46
|
+
"-e",
|
|
47
|
+
help="Filter by event name (substring match).",
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""List recent dead-letter messages."""
|
|
51
|
+
|
|
52
|
+
async def _run() -> None:
|
|
53
|
+
from sqlalchemy import text # noqa: PLC0415
|
|
54
|
+
from sqlalchemy.ext.asyncio import create_async_engine # noqa: PLC0415
|
|
55
|
+
|
|
56
|
+
url = (db_url or _get_db_url()).replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
57
|
+
engine = create_async_engine(url, echo=False)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
async with engine.connect() as conn:
|
|
61
|
+
exists = (
|
|
62
|
+
await conn.execute(
|
|
63
|
+
text(
|
|
64
|
+
"SELECT 1 FROM information_schema.tables"
|
|
65
|
+
" WHERE table_name = 'qx_dead_letters'"
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
).first()
|
|
69
|
+
if not exists:
|
|
70
|
+
console.print(
|
|
71
|
+
"[yellow]qx_dead_letters table not found."
|
|
72
|
+
" Run migrations or configure DeadLetterStore first.[/yellow]"
|
|
73
|
+
)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
filters = ""
|
|
77
|
+
params: dict[str, object] = {"limit": limit}
|
|
78
|
+
if event_name:
|
|
79
|
+
filters = "WHERE event_name ILIKE :event_name"
|
|
80
|
+
params["event_name"] = f"%{event_name}%"
|
|
81
|
+
|
|
82
|
+
rows = (
|
|
83
|
+
(
|
|
84
|
+
await conn.execute(
|
|
85
|
+
text(
|
|
86
|
+
f"""
|
|
87
|
+
SELECT id, event_name, delivered_count,
|
|
88
|
+
last_error, failed_at, subject
|
|
89
|
+
FROM qx_dead_letters
|
|
90
|
+
{filters}
|
|
91
|
+
ORDER BY failed_at DESC
|
|
92
|
+
LIMIT :limit
|
|
93
|
+
"""
|
|
94
|
+
),
|
|
95
|
+
params,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
.mappings()
|
|
99
|
+
.all()
|
|
100
|
+
)
|
|
101
|
+
finally:
|
|
102
|
+
await engine.dispose()
|
|
103
|
+
|
|
104
|
+
if not rows:
|
|
105
|
+
console.print("[dim]No dead letters found.[/dim]")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
109
|
+
table.add_column("ID", style="dim")
|
|
110
|
+
table.add_column("Event Name")
|
|
111
|
+
table.add_column("Deliveries", justify="right")
|
|
112
|
+
table.add_column("Failed At")
|
|
113
|
+
table.add_column("Last Error")
|
|
114
|
+
|
|
115
|
+
for r in rows:
|
|
116
|
+
failed_str = r["failed_at"].strftime("%Y-%m-%d %H:%M:%S") if r["failed_at"] else "—"
|
|
117
|
+
error = (r["last_error"] or "")[:60]
|
|
118
|
+
if len(r["last_error"] or "") > 60:
|
|
119
|
+
error += "…"
|
|
120
|
+
table.add_row(
|
|
121
|
+
str(r["id"])[:8] + "…",
|
|
122
|
+
r["event_name"],
|
|
123
|
+
str(r["delivered_count"]),
|
|
124
|
+
failed_str,
|
|
125
|
+
error,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
console.print(table)
|
|
129
|
+
console.print(f"\n[dim]{len(rows)} row(s) shown (limit {limit})[/dim]")
|
|
130
|
+
|
|
131
|
+
asyncio.run(_run())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def replay(
|
|
136
|
+
id: str = typer.Argument(..., help="UUID of the dead letter row to replay."),
|
|
137
|
+
db_url: str = typer.Option(
|
|
138
|
+
None,
|
|
139
|
+
"--db-url",
|
|
140
|
+
envvar="DATABASE_URL",
|
|
141
|
+
help="Postgres connection URL.",
|
|
142
|
+
),
|
|
143
|
+
nats_url: str = typer.Option(
|
|
144
|
+
None,
|
|
145
|
+
"--nats-url",
|
|
146
|
+
envvar="NATS_URL",
|
|
147
|
+
help="NATS server URL (falls back to NATS_URL or nats://localhost:4222).",
|
|
148
|
+
),
|
|
149
|
+
subject_override: str = typer.Option(
|
|
150
|
+
None,
|
|
151
|
+
"--subject",
|
|
152
|
+
help="Override the NATS subject (default: use the original subject from the DB row).",
|
|
153
|
+
),
|
|
154
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Re-publish a dead letter to its original NATS subject.
|
|
157
|
+
|
|
158
|
+
Fetches the stored payload from ``qx_dead_letters`` by ID and
|
|
159
|
+
republishes it to the original NATS subject so the worker can
|
|
160
|
+
attempt processing again.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
async def _run() -> None:
|
|
164
|
+
import json # noqa: PLC0415
|
|
165
|
+
|
|
166
|
+
import nats # noqa: PLC0415
|
|
167
|
+
from sqlalchemy import text # noqa: PLC0415
|
|
168
|
+
from sqlalchemy.ext.asyncio import create_async_engine # noqa: PLC0415
|
|
169
|
+
|
|
170
|
+
url = (db_url or _get_db_url()).replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
171
|
+
engine = create_async_engine(url, echo=False)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
async with engine.connect() as conn:
|
|
175
|
+
row = (
|
|
176
|
+
(
|
|
177
|
+
await conn.execute(
|
|
178
|
+
text(
|
|
179
|
+
"SELECT id, event_name, payload, headers, subject"
|
|
180
|
+
" FROM qx_dead_letters WHERE id = :id"
|
|
181
|
+
),
|
|
182
|
+
{"id": id},
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
.mappings()
|
|
186
|
+
.first()
|
|
187
|
+
)
|
|
188
|
+
finally:
|
|
189
|
+
await engine.dispose()
|
|
190
|
+
|
|
191
|
+
if row is None:
|
|
192
|
+
console.print(f"[red]Dead letter [bold]{id}[/bold] not found.[/red]")
|
|
193
|
+
raise typer.Exit(code=1)
|
|
194
|
+
|
|
195
|
+
target_subject = subject_override or row["subject"]
|
|
196
|
+
if not target_subject:
|
|
197
|
+
console.print("[red]Row has no stored subject. Use --subject to specify one.[/red]")
|
|
198
|
+
raise typer.Exit(code=1)
|
|
199
|
+
|
|
200
|
+
if not yes:
|
|
201
|
+
confirmed = typer.confirm(
|
|
202
|
+
f"Replay [bold]{row['event_name']}[/bold] → [bold]{target_subject}[/bold]?"
|
|
203
|
+
)
|
|
204
|
+
if not confirmed:
|
|
205
|
+
raise typer.Abort()
|
|
206
|
+
|
|
207
|
+
payload_bytes = (
|
|
208
|
+
row["payload"].encode()
|
|
209
|
+
if isinstance(row["payload"], str)
|
|
210
|
+
else json.dumps(row["payload"]).encode()
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Reconstruct headers from stored headers dict, dropping None values.
|
|
214
|
+
stored_headers: dict[str, str] = {}
|
|
215
|
+
if row["headers"]:
|
|
216
|
+
raw = row["headers"] if isinstance(row["headers"], dict) else json.loads(row["headers"])
|
|
217
|
+
stored_headers = {k: v for k, v in raw.items() if v is not None}
|
|
218
|
+
|
|
219
|
+
nc = await nats.connect(nats_url or _get_nats_url())
|
|
220
|
+
try:
|
|
221
|
+
js = nc.jetstream()
|
|
222
|
+
await js.publish(target_subject, payload_bytes, headers=stored_headers or None)
|
|
223
|
+
finally:
|
|
224
|
+
await nc.drain()
|
|
225
|
+
|
|
226
|
+
console.print(
|
|
227
|
+
f"[green]✓[/green] Replayed [bold]{row['event_name']}[/bold]"
|
|
228
|
+
f" → [bold]{target_subject}[/bold]"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
asyncio.run(_run())
|
|
@@ -178,10 +178,45 @@ def _check_connectivity() -> None:
|
|
|
178
178
|
)
|
|
179
179
|
|
|
180
180
|
|
|
181
|
+
_MIN_VERSIONS: dict[str, str] = {
|
|
182
|
+
"qx-core": "0.2.0",
|
|
183
|
+
"qx-cqrs": "0.2.0",
|
|
184
|
+
"qx-db": "0.2.0",
|
|
185
|
+
"qx-http": "0.2.0",
|
|
186
|
+
"qx-events": "0.2.0",
|
|
187
|
+
"qx-worker": "0.2.0",
|
|
188
|
+
"qx-cli": "0.2.0",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _check_version_mismatches() -> None:
|
|
193
|
+
from importlib.metadata import PackageNotFoundError, version # noqa: PLC0415
|
|
194
|
+
|
|
195
|
+
from packaging.version import Version # noqa: PLC0415
|
|
196
|
+
|
|
197
|
+
console.print("\n[bold]Version requirements[/bold]")
|
|
198
|
+
found_mismatch = False
|
|
199
|
+
for pkg, min_ver in _MIN_VERSIONS.items():
|
|
200
|
+
try:
|
|
201
|
+
installed = version(pkg)
|
|
202
|
+
if Version(installed) < Version(min_ver):
|
|
203
|
+
console.print(_fail(f"{pkg} {installed} < {min_ver} (minimum required)"))
|
|
204
|
+
found_mismatch = True
|
|
205
|
+
else:
|
|
206
|
+
console.print(_ok(f"{pkg} {installed} >= {min_ver}"))
|
|
207
|
+
except PackageNotFoundError:
|
|
208
|
+
pass # already reported in _check_packages
|
|
209
|
+
|
|
210
|
+
if not found_mismatch:
|
|
211
|
+
console.print(_ok("All installed qx packages meet minimum version requirements"))
|
|
212
|
+
|
|
213
|
+
|
|
181
214
|
@app.callback(invoke_without_command=True)
|
|
182
215
|
def doctor(
|
|
183
|
-
|
|
184
|
-
False,
|
|
216
|
+
no_connectivity: bool = typer.Option(
|
|
217
|
+
False,
|
|
218
|
+
"--no-connectivity",
|
|
219
|
+
help="Skip Postgres / Redis / NATS TCP reachability probes.",
|
|
185
220
|
),
|
|
186
221
|
fix: bool = typer.Option(
|
|
187
222
|
False, "--fix", help="Print shell commands to resolve detected issues."
|
|
@@ -196,8 +231,9 @@ def doctor(
|
|
|
196
231
|
_check_tools(failures, fix_commands)
|
|
197
232
|
_check_packages()
|
|
198
233
|
_check_env()
|
|
234
|
+
_check_version_mismatches()
|
|
199
235
|
|
|
200
|
-
if
|
|
236
|
+
if not no_connectivity:
|
|
201
237
|
_check_connectivity()
|
|
202
238
|
|
|
203
239
|
console.print()
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
Each subcommand expects the cwd to be a qx service directory
|
|
4
4
|
(containing a ``pyproject.toml`` and a Python package matching the service
|
|
5
5
|
name). The CLI infers the service package from ``pyproject.toml``.
|
|
6
|
+
|
|
7
|
+
Vertical-slice services support a ``<slice>/<Name>`` syntax:
|
|
8
|
+
|
|
9
|
+
qx generate command user/CreateUser # → user/commands/create_user.py
|
|
10
|
+
qx generate query user/GetUser # → user/queries/get_user.py
|
|
11
|
+
qx generate slice payment # → payment/ skeleton
|
|
6
12
|
"""
|
|
7
13
|
|
|
8
14
|
from __future__ import annotations
|
|
@@ -33,7 +39,6 @@ def _service_package(start: Path | None = None) -> tuple[Path, str]:
|
|
|
33
39
|
if not name:
|
|
34
40
|
continue
|
|
35
41
|
pkg = name.replace("-", "_")
|
|
36
|
-
# Validate the package directory actually exists
|
|
37
42
|
if (candidate / "src" / pkg).exists():
|
|
38
43
|
return candidate, pkg
|
|
39
44
|
if (candidate / pkg).exists():
|
|
@@ -68,46 +73,79 @@ def aggregate(
|
|
|
68
73
|
|
|
69
74
|
@app.command()
|
|
70
75
|
def command(
|
|
71
|
-
name: str = typer.Argument(
|
|
76
|
+
name: str = typer.Argument(
|
|
77
|
+
..., help="Command name. Use 'slice/Name' for vertical-slice services."
|
|
78
|
+
),
|
|
72
79
|
aggregate_for: str = typer.Option(
|
|
73
80
|
"",
|
|
74
81
|
"--aggregate",
|
|
75
82
|
"-a",
|
|
76
|
-
help="Target aggregate (for layout hints).",
|
|
83
|
+
help="Target aggregate (for layout hints, layered mode only).",
|
|
77
84
|
),
|
|
78
85
|
force: bool = typer.Option(False, "--force", "-f"),
|
|
79
86
|
) -> None:
|
|
80
|
-
"""Generate a Command class and its handler.
|
|
87
|
+
"""Generate a Command class and its handler.
|
|
88
|
+
|
|
89
|
+
Layered: qx generate command CreateUser
|
|
90
|
+
VS: qx generate command user/CreateUser
|
|
91
|
+
"""
|
|
81
92
|
root, pkg = _service_package()
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
context,
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
if "/" in name:
|
|
94
|
+
slice_name, artifact_name = _parse_slice(name)
|
|
95
|
+
context = _names(artifact_name, pkg)
|
|
96
|
+
context.update(_slice_names(slice_name))
|
|
97
|
+
files = render_tree("qx.cli.scaffolds", "command_vs", root, context, overwrite=force)
|
|
98
|
+
else:
|
|
99
|
+
context = _names(name, pkg)
|
|
100
|
+
context["aggregate"] = _names(aggregate_for, pkg) if aggregate_for else None
|
|
101
|
+
files = render_tree("qx.cli.scaffolds", "command", root, context, overwrite=force)
|
|
91
102
|
console.rule(f"[bold green]command {context['name_pascal']} generated[/bold green]")
|
|
92
103
|
preview_tree(files, root)
|
|
93
104
|
|
|
94
105
|
|
|
95
106
|
@app.command()
|
|
96
107
|
def query(
|
|
97
|
-
name: str = typer.Argument(
|
|
108
|
+
name: str = typer.Argument(
|
|
109
|
+
..., help="Query name. Use 'slice/Name' for vertical-slice services."
|
|
110
|
+
),
|
|
111
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Generate a Query class and its handler.
|
|
114
|
+
|
|
115
|
+
Layered: qx generate query GetUser
|
|
116
|
+
VS: qx generate query user/GetUser
|
|
117
|
+
"""
|
|
118
|
+
root, pkg = _service_package()
|
|
119
|
+
if "/" in name:
|
|
120
|
+
slice_name, artifact_name = _parse_slice(name)
|
|
121
|
+
context = _names(artifact_name, pkg)
|
|
122
|
+
context.update(_slice_names(slice_name))
|
|
123
|
+
files = render_tree("qx.cli.scaffolds", "query_vs", root, context, overwrite=force)
|
|
124
|
+
else:
|
|
125
|
+
context = _names(name, pkg)
|
|
126
|
+
files = render_tree("qx.cli.scaffolds", "query", root, context, overwrite=force)
|
|
127
|
+
console.rule(f"[bold green]query {context['name_pascal']} generated[/bold green]")
|
|
128
|
+
preview_tree(files, root)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command()
|
|
132
|
+
def esaggregate(
|
|
133
|
+
name: str = typer.Argument(..., help="Aggregate name (PascalCase, e.g. 'Shipment')."),
|
|
98
134
|
force: bool = typer.Option(False, "--force", "-f"),
|
|
99
135
|
) -> None:
|
|
100
|
-
"""Generate
|
|
136
|
+
"""Generate an event-sourced aggregate (EventSourcedAggregate subclass, no ORM mapping)."""
|
|
101
137
|
root, pkg = _service_package()
|
|
102
138
|
context = _names(name, pkg)
|
|
103
139
|
files = render_tree(
|
|
104
140
|
"qx.cli.scaffolds",
|
|
105
|
-
"
|
|
141
|
+
"esaggregate",
|
|
106
142
|
root,
|
|
107
143
|
context,
|
|
108
144
|
overwrite=force,
|
|
109
145
|
)
|
|
110
|
-
console.rule(
|
|
146
|
+
console.rule(
|
|
147
|
+
f"[bold green]event-sourced aggregate {context['name_pascal']} generated[/bold green]"
|
|
148
|
+
)
|
|
111
149
|
preview_tree(files, root)
|
|
112
150
|
|
|
113
151
|
|
|
@@ -158,19 +196,67 @@ def endpoint(
|
|
|
158
196
|
preview_tree(files, root)
|
|
159
197
|
|
|
160
198
|
|
|
199
|
+
@app.command(name="slice")
|
|
200
|
+
def generate_slice(
|
|
201
|
+
name: str = typer.Argument(..., help="Slice name (e.g. 'payment', 'user')."),
|
|
202
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Generate a new vertical slice (application/<slice>/commands/, queries/, presentation/<slice>.py).
|
|
205
|
+
|
|
206
|
+
After generating, mount the new router in main.py:
|
|
207
|
+
|
|
208
|
+
from <service>.presentation.payment import router as payment_router
|
|
209
|
+
app.include_router(payment_router, prefix="/v1")
|
|
210
|
+
"""
|
|
211
|
+
root, pkg = _service_package()
|
|
212
|
+
context = _slice_names(name)
|
|
213
|
+
context["service_pkg"] = pkg
|
|
214
|
+
files = render_tree("qx.cli.scaffolds", "slice", root, context, overwrite=force)
|
|
215
|
+
console.rule(f"[bold green]slice {context['slice_name_pascal']} generated[/bold green]")
|
|
216
|
+
preview_tree(files, root)
|
|
217
|
+
console.print(
|
|
218
|
+
"\n[bold]Wire the router in main.py:[/bold]\n"
|
|
219
|
+
f" from {pkg}.presentation.{context['slice_name_snake']} import router as "
|
|
220
|
+
f"{context['slice_name_snake']}_router\n"
|
|
221
|
+
f' app.include_router({context["slice_name_snake"]}_router, prefix="/v1")\n'
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
161
225
|
# ---- helpers ----
|
|
162
226
|
|
|
163
227
|
|
|
164
228
|
def _names(name: str, pkg: str) -> dict[str, Any]:
|
|
165
229
|
"""Build the standard naming variants for a generated artifact."""
|
|
230
|
+
snake = _snake(name)
|
|
166
231
|
return {
|
|
167
232
|
"name_pascal": _pascal(name),
|
|
168
|
-
"name_snake":
|
|
233
|
+
"name_snake": snake,
|
|
169
234
|
"name_kebab": _kebab(name),
|
|
235
|
+
"name_upper": snake.upper(),
|
|
170
236
|
"service_pkg": pkg,
|
|
171
237
|
}
|
|
172
238
|
|
|
173
239
|
|
|
240
|
+
def _slice_names(slice_name: str) -> dict[str, Any]:
|
|
241
|
+
"""Build naming variants for a slice."""
|
|
242
|
+
snake = _snake(slice_name)
|
|
243
|
+
return {
|
|
244
|
+
"slice_name_snake": snake,
|
|
245
|
+
"slice_name_pascal": _pascal(slice_name),
|
|
246
|
+
"slice_name_kebab": _kebab(slice_name),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_slice(name: str) -> tuple[str, str]:
|
|
251
|
+
"""Split 'user/CreateUser' → ('user', 'CreateUser')."""
|
|
252
|
+
parts = name.split("/", 1)
|
|
253
|
+
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
254
|
+
raise typer.BadParameter(
|
|
255
|
+
f"Invalid format '{name}'. Use '<slice>/<Name>' (e.g. 'user/CreateUser')."
|
|
256
|
+
)
|
|
257
|
+
return parts[0], parts[1]
|
|
258
|
+
|
|
259
|
+
|
|
174
260
|
def _snake(s: str) -> str:
|
|
175
261
|
import re # noqa: PLC0415
|
|
176
262
|
|
|
@@ -22,11 +22,23 @@ def service(
|
|
|
22
22
|
help="Directory to create the project in.",
|
|
23
23
|
),
|
|
24
24
|
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files."),
|
|
25
|
+
slices: bool = typer.Option(
|
|
26
|
+
False,
|
|
27
|
+
"--slices/--no-slices",
|
|
28
|
+
help="Use vertical-slice layout (feature-first) instead of layered.",
|
|
29
|
+
),
|
|
30
|
+
domain: str = typer.Option(
|
|
31
|
+
"",
|
|
32
|
+
"--domain",
|
|
33
|
+
"-d",
|
|
34
|
+
help="Starter slice name for --slices (defaults to service name).",
|
|
35
|
+
),
|
|
25
36
|
) -> None:
|
|
26
37
|
"""Scaffold a new qx service.
|
|
27
38
|
|
|
28
|
-
|
|
29
|
-
presentation
|
|
39
|
+
By default generates a layered service (application/domain/infrastructure/
|
|
40
|
+
presentation). With --slices, generates a vertical-slice layout where each
|
|
41
|
+
feature is self-contained under its own directory.
|
|
30
42
|
"""
|
|
31
43
|
pkg_name = _snake(name)
|
|
32
44
|
context = {
|
|
@@ -36,6 +48,16 @@ def service(
|
|
|
36
48
|
"service_kebab": _kebab(name),
|
|
37
49
|
"service_pkg_path": pkg_name.replace("_", "/"),
|
|
38
50
|
}
|
|
51
|
+
|
|
52
|
+
if slices:
|
|
53
|
+
domain_snake = _snake(domain) if domain else pkg_name
|
|
54
|
+
context["domain_snake"] = domain_snake
|
|
55
|
+
context["domain_pascal"] = _pascal(domain_snake)
|
|
56
|
+
context["domain_kebab"] = _kebab(domain_snake)
|
|
57
|
+
template = "service_vs"
|
|
58
|
+
else:
|
|
59
|
+
template = "service"
|
|
60
|
+
|
|
39
61
|
dest = target / context["service_kebab"]
|
|
40
62
|
if dest.exists() and not force and any(dest.iterdir()):
|
|
41
63
|
console.print(f"[red]error[/red] {dest} exists and is not empty (use --force to overwrite)")
|
|
@@ -44,21 +66,30 @@ def service(
|
|
|
44
66
|
|
|
45
67
|
files = render_tree(
|
|
46
68
|
"qx.cli.scaffolds",
|
|
47
|
-
|
|
69
|
+
template,
|
|
48
70
|
dest,
|
|
49
71
|
context,
|
|
50
72
|
overwrite=force,
|
|
51
73
|
)
|
|
52
74
|
console.rule("[bold green]Service scaffolded[/bold green]")
|
|
53
75
|
preview_tree(files, dest)
|
|
54
|
-
|
|
76
|
+
|
|
77
|
+
next_steps = (
|
|
55
78
|
"\n[bold]Next steps:[/bold]\n"
|
|
56
79
|
f" cd {context['service_kebab']}\n"
|
|
57
80
|
" uv sync\n"
|
|
58
81
|
" qx dev up # start Postgres · Redis · NATS · Grafana\n"
|
|
59
82
|
" uv run alembic upgrade head\n"
|
|
60
|
-
" uv run uvicorn
|
|
83
|
+
f" uv run uvicorn {pkg_name}.main:app --reload\n"
|
|
61
84
|
)
|
|
85
|
+
if slices:
|
|
86
|
+
next_steps += (
|
|
87
|
+
"\n[bold]Add more slices:[/bold]\n"
|
|
88
|
+
" qx generate slice <slice_name>\n"
|
|
89
|
+
" qx generate command <slice_name>/<CommandName>\n"
|
|
90
|
+
" qx generate query <slice_name>/<QueryName>\n"
|
|
91
|
+
)
|
|
92
|
+
console.print(next_steps)
|
|
62
93
|
|
|
63
94
|
|
|
64
95
|
def _snake(s: str) -> str:
|