qx-cli 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- qx/cli/commands/__init__.py +0 -0
- qx/cli/commands/dev.py +86 -0
- qx/cli/commands/generate.py +182 -0
- qx/cli/commands/new.py +76 -0
- qx/cli/main.py +52 -0
- qx/cli/py.typed +0 -0
- qx/cli/scaffolds/__init__.py +0 -0
- qx/cli/scaffolds/aggregate/__init__.py +0 -0
- qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +60 -0
- qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/__init__.py.j2 +0 -0
- qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/mapping.py.j2 +27 -0
- qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/repository.py.j2 +21 -0
- qx/cli/scaffolds/command/__init__.py +0 -0
- qx/cli/scaffolds/command/src/__service_pkg__/application/commands/__name_snake__.py.j2 +32 -0
- qx/cli/scaffolds/endpoint/__init__.py +0 -0
- qx/cli/scaffolds/endpoint/src/__service_pkg__/presentation/routes/__name_snake__.py.j2 +24 -0
- qx/cli/scaffolds/event/__init__.py +0 -0
- qx/cli/scaffolds/event/src/__service_pkg__/domain/events/__name_snake__.py.j2 +18 -0
- qx/cli/scaffolds/query/__init__.py +0 -0
- qx/cli/scaffolds/query/src/__service_pkg__/application/queries/__name_snake__.py.j2 +21 -0
- qx/cli/scaffolds/service/.env.example.j2 +16 -0
- qx/cli/scaffolds/service/Dockerfile.j2 +19 -0
- qx/cli/scaffolds/service/README.md.j2 +59 -0
- qx/cli/scaffolds/service/__init__.py +0 -0
- qx/cli/scaffolds/service/alembic/env.py.j2 +13 -0
- qx/cli/scaffolds/service/alembic/script.py.mako +26 -0
- qx/cli/scaffolds/service/alembic.ini.j2 +38 -0
- qx/cli/scaffolds/service/pyproject.toml.j2 +47 -0
- qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +3 -0
- qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +22 -0
- qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +22 -0
- qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +22 -0
- qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +88 -0
- qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +21 -0
- qx/cli/scaffolds/service/tests/test_smoke.py.j2 +33 -0
- qx/cli/templates/__init__.py +162 -0
- qx_cli-0.1.0.dist-info/METADATA +79 -0
- qx_cli-0.1.0.dist-info/RECORD +41 -0
- qx_cli-0.1.0.dist-info/WHEEL +4 -0
- qx_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# {{ service_name }}
|
|
2
|
+
|
|
3
|
+
A [Qx](https://qx.dev) service.
|
|
4
|
+
|
|
5
|
+
## Quickstart
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv sync
|
|
9
|
+
docker compose -f ../deploy/docker-compose.yaml up -d # local Postgres + Redis + NATS
|
|
10
|
+
uv run alembic upgrade head
|
|
11
|
+
uv run uvicorn {{ service_pkg }}.main:app --reload
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The service listens on `http://localhost:8000`. Health checks live at
|
|
15
|
+
`/healthz` and `/readyz`; metrics at `/metrics`; OpenAPI docs at `/docs`.
|
|
16
|
+
|
|
17
|
+
## Project layout
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
src/{{ service_pkg }}/
|
|
21
|
+
domain/ # aggregates, entities, value objects, domain events
|
|
22
|
+
application/ # command/query handlers, DTOs, application services
|
|
23
|
+
infrastructure/ # SQLAlchemy mappings, repositories, NATS adapters
|
|
24
|
+
presentation/ # FastAPI routers, gRPC servicers
|
|
25
|
+
main.py # composition root
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Generating code
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
qx generate aggregate Invoice
|
|
32
|
+
qx generate command CreateInvoice --aggregate Invoice
|
|
33
|
+
qx generate query GetInvoice
|
|
34
|
+
qx generate endpoint --method POST --handler CreateInvoice /invoices
|
|
35
|
+
qx generate event InvoiceCreated
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Testing
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv run pytest
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Integration tests use testcontainers — they require Docker running locally.
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
All settings come from environment variables with the prefix `QX_`.
|
|
49
|
+
The most common:
|
|
50
|
+
|
|
51
|
+
| Env var | Purpose |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `QX_DB__URL` | Postgres connection URL |
|
|
54
|
+
| `QX_CACHE__URL` | Redis URL |
|
|
55
|
+
| `QX_NATS__SERVERS` | NATS servers (JSON array) |
|
|
56
|
+
| `QX_LOGGING__JSON_OUTPUT` | `true` in prod, `false` for human-readable |
|
|
57
|
+
| `QX_LOGGING__LEVEL` | `INFO`/`DEBUG`/etc. |
|
|
58
|
+
|
|
59
|
+
See the framework docs for the full list.
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Alembic env — defers to the framework helper.
|
|
2
|
+
|
|
3
|
+
Service-specific MetaData comes from the infrastructure layer; the framework
|
|
4
|
+
handles async engine wiring and naming conventions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from qx.db.migrations import run_async_migrations
|
|
10
|
+
|
|
11
|
+
from {{ service_pkg }}.infrastructure import metadata
|
|
12
|
+
|
|
13
|
+
run_async_migrations(metadata)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Sequence, Union
|
|
10
|
+
|
|
11
|
+
from alembic import op
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
${imports if imports else ""}
|
|
14
|
+
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
${upgrades if upgrades else "pass"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def downgrade() -> None:
|
|
26
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = alembic
|
|
3
|
+
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/{{ service_pkg }}
|
|
4
|
+
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
5
|
+
|
|
6
|
+
[loggers]
|
|
7
|
+
keys = root,sqlalchemy,alembic
|
|
8
|
+
|
|
9
|
+
[handlers]
|
|
10
|
+
keys = console
|
|
11
|
+
|
|
12
|
+
[formatters]
|
|
13
|
+
keys = generic
|
|
14
|
+
|
|
15
|
+
[logger_root]
|
|
16
|
+
level = WARN
|
|
17
|
+
handlers = console
|
|
18
|
+
qualname =
|
|
19
|
+
|
|
20
|
+
[logger_sqlalchemy]
|
|
21
|
+
level = WARN
|
|
22
|
+
handlers =
|
|
23
|
+
qualname = sqlalchemy.engine
|
|
24
|
+
|
|
25
|
+
[logger_alembic]
|
|
26
|
+
level = INFO
|
|
27
|
+
handlers =
|
|
28
|
+
qualname = alembic
|
|
29
|
+
|
|
30
|
+
[handler_console]
|
|
31
|
+
class = StreamHandler
|
|
32
|
+
args = (sys.stderr,)
|
|
33
|
+
level = NOTSET
|
|
34
|
+
formatter = generic
|
|
35
|
+
|
|
36
|
+
[formatter_generic]
|
|
37
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
38
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "{{ service_kebab }}"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "{{ service_name }} — a Qx service."
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"qx-core",
|
|
8
|
+
"qx-di",
|
|
9
|
+
"qx-cqrs",
|
|
10
|
+
"qx-db",
|
|
11
|
+
"qx-cache",
|
|
12
|
+
"qx-events",
|
|
13
|
+
"qx-http",
|
|
14
|
+
"qx-observability",
|
|
15
|
+
"qx-worker",
|
|
16
|
+
"uvicorn[standard]>=0.32.0",
|
|
17
|
+
"alembic>=1.13.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"qx-testing",
|
|
23
|
+
"pytest>=8.0.0",
|
|
24
|
+
"pytest-asyncio>=0.24.0",
|
|
25
|
+
"ruff>=0.6.0",
|
|
26
|
+
"mypy>=1.11.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/{{ service_pkg }}"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
asyncio_mode = "auto"
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
target-version = "py312"
|
|
43
|
+
|
|
44
|
+
[tool.mypy]
|
|
45
|
+
python_version = "3.12"
|
|
46
|
+
strict = true
|
|
47
|
+
plugins = ["pydantic.mypy"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Application layer.
|
|
2
|
+
|
|
3
|
+
Command/query handlers, application services, and DTOs.
|
|
4
|
+
|
|
5
|
+
When you add a command via ``qx generate command Foo``, the handler
|
|
6
|
+
is registered automatically by walking the ``application`` module here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from qx.cqrs import Mediator
|
|
12
|
+
from qx.di import Container
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_handlers(mediator: Mediator, _container: Container) -> int:
|
|
16
|
+
"""Discover and register every CQRS handler in this package.
|
|
17
|
+
|
|
18
|
+
Returns the number of handlers registered.
|
|
19
|
+
"""
|
|
20
|
+
from {{ service_pkg }} import application as app_pkg
|
|
21
|
+
|
|
22
|
+
return mediator.register_decorated(app_pkg)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Domain layer.
|
|
2
|
+
|
|
3
|
+
Aggregates, entities, value objects, and domain events live here. This layer
|
|
4
|
+
has no dependencies on infrastructure (no SQLAlchemy, no HTTP, no NATS).
|
|
5
|
+
|
|
6
|
+
When you add an aggregate via ``qx generate aggregate Foo``, it
|
|
7
|
+
appears under ``aggregates/``. Register your integration event types in
|
|
8
|
+
``register_events`` so the worker knows how to decode them off the broker.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from qx.events import EventRegistry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_events(registry: EventRegistry) -> None:
|
|
17
|
+
"""Register every IntegrationEvent class this service publishes or consumes.
|
|
18
|
+
|
|
19
|
+
The registry is used by the worker to deserialize incoming NATS messages.
|
|
20
|
+
"""
|
|
21
|
+
# registry.register(SomeIntegrationEvent)
|
|
22
|
+
pass
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Infrastructure layer.
|
|
2
|
+
|
|
3
|
+
Concrete adapters: SQLAlchemy mappings, repositories, NATS adapters,
|
|
4
|
+
external HTTP clients. This layer depends on everything; domain and
|
|
5
|
+
application depend on nothing here directly — only on the interfaces
|
|
6
|
+
they declare.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import MetaData
|
|
12
|
+
|
|
13
|
+
from qx.db import make_metadata
|
|
14
|
+
from qx.db.outbox import include_outbox_table
|
|
15
|
+
|
|
16
|
+
# Single metadata for the service. Add Table() definitions in the same
|
|
17
|
+
# module that defines the aggregate (under domain/aggregates/<name>/mapping.py)
|
|
18
|
+
# and they will all share this MetaData.
|
|
19
|
+
metadata: MetaData = make_metadata()
|
|
20
|
+
|
|
21
|
+
# The outbox table is always present; aggregates plug in alongside.
|
|
22
|
+
include_outbox_table(metadata)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""{{ service_name }} entrypoint.
|
|
2
|
+
|
|
3
|
+
Wires the DI container, mediator, database, observability, and FastAPI app
|
|
4
|
+
together. ``uvicorn {{ service_pkg }}.main:app`` boots the service.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
|
|
14
|
+
from qx.core import QxSettings
|
|
15
|
+
from qx.cqrs import ExceptionTranslationBehavior, LoggingBehavior, Mediator
|
|
16
|
+
from qx.db import (
|
|
17
|
+
DatabaseSettings,
|
|
18
|
+
SessionFactory,
|
|
19
|
+
UnitOfWork,
|
|
20
|
+
create_engine,
|
|
21
|
+
make_session_factory,
|
|
22
|
+
)
|
|
23
|
+
from qx.di import Container
|
|
24
|
+
from qx.events import EventRegistry, MediatorEventDispatcher
|
|
25
|
+
from qx.http import setup_qx_app
|
|
26
|
+
from qx.observability import setup_observability
|
|
27
|
+
|
|
28
|
+
from {{ service_pkg }}.application import register_handlers
|
|
29
|
+
from {{ service_pkg }}.domain import register_events
|
|
30
|
+
from {{ service_pkg }}.presentation import register_routes
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_app() -> FastAPI:
|
|
34
|
+
settings = QxSettings(app={"name": "{{ service_kebab }}"})
|
|
35
|
+
metrics, health = setup_observability(settings)
|
|
36
|
+
container = Container()
|
|
37
|
+
|
|
38
|
+
# ---- Settings + infrastructure singletons ----
|
|
39
|
+
db_settings = DatabaseSettings()
|
|
40
|
+
engine = create_engine(db_settings)
|
|
41
|
+
session_factory = make_session_factory(engine)
|
|
42
|
+
container.register_instance(SessionFactory, session_factory)
|
|
43
|
+
|
|
44
|
+
# ---- Mediator ----
|
|
45
|
+
mediator = Mediator(
|
|
46
|
+
container,
|
|
47
|
+
command_behaviors=(LoggingBehavior(), ExceptionTranslationBehavior()),
|
|
48
|
+
query_behaviors=(LoggingBehavior(), ExceptionTranslationBehavior()),
|
|
49
|
+
)
|
|
50
|
+
container.register_instance(Mediator, mediator)
|
|
51
|
+
|
|
52
|
+
# ---- Event registry (integration event types this service knows about) ----
|
|
53
|
+
event_registry = EventRegistry()
|
|
54
|
+
register_events(event_registry)
|
|
55
|
+
container.register_instance(EventRegistry, event_registry)
|
|
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
|
+
)
|
|
65
|
+
container.register_scoped(UnitOfWork, _uow_factory)
|
|
66
|
+
|
|
67
|
+
# ---- Handlers (commands, queries, integration handlers) ----
|
|
68
|
+
register_handlers(mediator, container)
|
|
69
|
+
|
|
70
|
+
@asynccontextmanager
|
|
71
|
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
72
|
+
try:
|
|
73
|
+
yield
|
|
74
|
+
finally:
|
|
75
|
+
await engine.dispose()
|
|
76
|
+
|
|
77
|
+
app = setup_qx_app(
|
|
78
|
+
container,
|
|
79
|
+
settings,
|
|
80
|
+
metrics=metrics,
|
|
81
|
+
health=health,
|
|
82
|
+
extra_lifespan=lifespan,
|
|
83
|
+
)
|
|
84
|
+
register_routes(app)
|
|
85
|
+
return app
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
app = build_app()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Presentation layer.
|
|
2
|
+
|
|
3
|
+
FastAPI routers, gRPC servicers, CLI commands. This is where wire-format
|
|
4
|
+
concerns live: serialization, deserialization, HTTP status mapping. No
|
|
5
|
+
business logic — that's in the application/domain layers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_routes(app: FastAPI) -> None:
|
|
14
|
+
"""Mount every router this service exposes.
|
|
15
|
+
|
|
16
|
+
Generated endpoints register themselves here. Wire your routers as the
|
|
17
|
+
CLI generates them.
|
|
18
|
+
"""
|
|
19
|
+
# Example: from {{ service_pkg }}.presentation.routes.users import router as users_router
|
|
20
|
+
# app.include_router(users_router, prefix="/v1")
|
|
21
|
+
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Smoke test for the {{ service_name }} service.
|
|
2
|
+
|
|
3
|
+
Exercises the FastAPI app boot and the framework probe endpoints. Doesn't
|
|
4
|
+
require any external infrastructure.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
|
|
12
|
+
from {{ service_pkg }}.main import build_app
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def client() -> TestClient:
|
|
17
|
+
return TestClient(build_app())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_healthz(client: TestClient) -> None:
|
|
21
|
+
r = client.get("/healthz")
|
|
22
|
+
assert r.status_code == 200
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_readyz(client: TestClient) -> None:
|
|
26
|
+
r = client.get("/readyz")
|
|
27
|
+
assert r.status_code == 200
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_metrics(client: TestClient) -> None:
|
|
31
|
+
r = client.get("/metrics")
|
|
32
|
+
assert r.status_code == 200
|
|
33
|
+
assert b"qx_command_total" in r.content
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Jinja2 template rendering helpers.
|
|
2
|
+
|
|
3
|
+
Templates live in ``qx.cli.scaffolds`` as text files and get rendered
|
|
4
|
+
with a small context. The conventions:
|
|
5
|
+
|
|
6
|
+
- A template file's path under ``scaffolds/`` (with ``.j2`` stripped from the
|
|
7
|
+
end) becomes its output path under the target directory.
|
|
8
|
+
- ``__name__`` substitutions are done in path components for paths that
|
|
9
|
+
vary per generation (e.g., command name, aggregate name).
|
|
10
|
+
- The Jinja2 environment is sandboxed (no filesystem loaders into arbitrary
|
|
11
|
+
paths, no exec filters).
|
|
12
|
+
|
|
13
|
+
The rendering loop intentionally keeps it boring: walk a template tree,
|
|
14
|
+
render each file, write to disk. No conditional file inclusion based on
|
|
15
|
+
template flags — if a scaffold needs variations, split it into multiple
|
|
16
|
+
templates and have the command pick one.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import shutil
|
|
22
|
+
from importlib import resources
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from jinja2 import Environment, StrictUndefined
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
from rich.tree import Tree
|
|
29
|
+
|
|
30
|
+
__all__ = ["preview_tree", "render_tree"]
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_env() -> Environment:
|
|
36
|
+
env = Environment(
|
|
37
|
+
autoescape=False,
|
|
38
|
+
undefined=StrictUndefined,
|
|
39
|
+
keep_trailing_newline=True,
|
|
40
|
+
block_start_string="{%",
|
|
41
|
+
block_end_string="%}",
|
|
42
|
+
variable_start_string="{{",
|
|
43
|
+
variable_end_string="}}",
|
|
44
|
+
)
|
|
45
|
+
env.filters["snake"] = _snake_case
|
|
46
|
+
env.filters["pascal"] = _pascal_case
|
|
47
|
+
env.filters["kebab"] = _kebab_case
|
|
48
|
+
return env
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _snake_case(s: str) -> str:
|
|
52
|
+
import re # noqa: PLC0415
|
|
53
|
+
|
|
54
|
+
s = re.sub(r"[\s\-]+", "_", s.strip())
|
|
55
|
+
s = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", s)
|
|
56
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
|
57
|
+
return s.lower()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pascal_case(s: str) -> str:
|
|
61
|
+
return "".join(p.capitalize() for p in _snake_case(s).split("_"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _kebab_case(s: str) -> str:
|
|
65
|
+
return _snake_case(s).replace("_", "-")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def render_tree(
|
|
69
|
+
template_pkg: str,
|
|
70
|
+
template_root: str,
|
|
71
|
+
dest: Path,
|
|
72
|
+
context: dict[str, Any],
|
|
73
|
+
*,
|
|
74
|
+
overwrite: bool = False,
|
|
75
|
+
) -> list[Path]:
|
|
76
|
+
"""Render every template under ``template_pkg/template_root`` into ``dest``.
|
|
77
|
+
|
|
78
|
+
``template_pkg`` is a dotted Python package containing the templates
|
|
79
|
+
(e.g., ``qx.cli.scaffolds.service``). Templates are discovered via
|
|
80
|
+
``importlib.resources`` so they work after install (no filesystem
|
|
81
|
+
assumptions).
|
|
82
|
+
|
|
83
|
+
Returns the list of files written.
|
|
84
|
+
"""
|
|
85
|
+
env = _make_env()
|
|
86
|
+
written: list[Path] = []
|
|
87
|
+
|
|
88
|
+
source_root = resources.files(template_pkg)
|
|
89
|
+
if template_root:
|
|
90
|
+
source_root = source_root / template_root
|
|
91
|
+
|
|
92
|
+
for entry in _walk(source_root):
|
|
93
|
+
rel = entry["rel"]
|
|
94
|
+
# Skip the empty __init__.py markers we use to make scaffold trees
|
|
95
|
+
# importable via importlib.resources — they're package metadata, not content.
|
|
96
|
+
basename = rel.rsplit("/", 1)[-1]
|
|
97
|
+
if basename == "__init__.py" and not entry["text"].strip():
|
|
98
|
+
continue
|
|
99
|
+
text = entry["text"]
|
|
100
|
+
# Substitute path components: __thing__ in filenames → context["thing"].
|
|
101
|
+
out_rel = _substitute_path(rel, context)
|
|
102
|
+
# Drop trailing .j2 from file names so output looks natural.
|
|
103
|
+
if out_rel.endswith(".j2"):
|
|
104
|
+
out_rel = out_rel[:-3]
|
|
105
|
+
out_path = dest / out_rel
|
|
106
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
if out_path.exists() and not overwrite:
|
|
108
|
+
console.print(f"[yellow]skip[/yellow] {out_path}")
|
|
109
|
+
continue
|
|
110
|
+
template = env.from_string(text)
|
|
111
|
+
rendered = template.render(**context)
|
|
112
|
+
out_path.write_text(rendered, encoding="utf-8")
|
|
113
|
+
written.append(out_path)
|
|
114
|
+
console.print(f"[green]write[/green] {out_path}")
|
|
115
|
+
return written
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _walk(source_root: Any) -> list[dict[str, Any]]:
|
|
119
|
+
"""Recursively gather template files from a resources Traversable."""
|
|
120
|
+
out: list[dict[str, Any]] = []
|
|
121
|
+
|
|
122
|
+
def visit(node: Any, rel: str) -> None:
|
|
123
|
+
if node.is_file():
|
|
124
|
+
out.append({"rel": rel, "text": node.read_text(encoding="utf-8")})
|
|
125
|
+
return
|
|
126
|
+
for child in node.iterdir():
|
|
127
|
+
visit(child, f"{rel}/{child.name}" if rel else child.name)
|
|
128
|
+
|
|
129
|
+
visit(source_root, "")
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _substitute_path(path: str, context: dict[str, Any]) -> str:
|
|
134
|
+
"""Replace ``__key__`` occurrences in path with ``context[key]``."""
|
|
135
|
+
import re # noqa: PLC0415
|
|
136
|
+
|
|
137
|
+
def repl(m: re.Match[str]) -> str:
|
|
138
|
+
key = m.group(1)
|
|
139
|
+
if key not in context:
|
|
140
|
+
return m.group(0)
|
|
141
|
+
return str(context[key])
|
|
142
|
+
|
|
143
|
+
return re.sub(r"__([a-zA-Z_][a-zA-Z0-9_]*)__", repl, path)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def preview_tree(files: list[Path], dest: Path) -> None:
|
|
147
|
+
"""Render a Rich tree showing what was generated."""
|
|
148
|
+
tree = Tree(f"[bold]{dest}[/bold]")
|
|
149
|
+
rels = sorted(p.relative_to(dest) for p in files)
|
|
150
|
+
nodes: dict[Path, Any] = {Path(): tree}
|
|
151
|
+
for rel in rels:
|
|
152
|
+
accumulated = Path()
|
|
153
|
+
parent_node = tree
|
|
154
|
+
for part in rel.parts:
|
|
155
|
+
accumulated = accumulated / part
|
|
156
|
+
if accumulated in nodes:
|
|
157
|
+
parent_node = nodes[accumulated]
|
|
158
|
+
continue
|
|
159
|
+
node = parent_node.add(part)
|
|
160
|
+
nodes[accumulated] = node
|
|
161
|
+
parent_node = node
|
|
162
|
+
console.print(tree)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qx-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Qx CLI: scaffolding, code generation, local dev orchestration
|
|
5
|
+
Author: Qx Engineering
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Requires-Dist: jinja2>=3.1.0
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Requires-Dist: qx-core
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
12
|
+
Requires-Dist: typer>=0.12.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# qx-cli
|
|
16
|
+
|
|
17
|
+
Scaffolding, code generation, and local-dev orchestration for the Qx framework. Invoked as `qx` once installed, or `uv run qx` during development.
|
|
18
|
+
|
|
19
|
+
## What lives here
|
|
20
|
+
|
|
21
|
+
- **`qx new service NAME`** — scaffold a complete new Qx service: directory structure, pyproject.toml, DI bootstrap, FastAPI app factory, Alembic config, Docker Compose overrides, and a `README.md`.
|
|
22
|
+
- **`qx generate aggregate NAME`** — add a domain aggregate with `Entity`, events, and a stub repository to an existing service.
|
|
23
|
+
- **`qx generate command NAME`** — add a `Command` and handler class with the standard boilerplate.
|
|
24
|
+
- **`qx generate query NAME`** — add a `Query` and handler class.
|
|
25
|
+
- **`qx generate endpoint`** — add a FastAPI route file wired to the Mediator.
|
|
26
|
+
- **`qx generate event NAME`** — add an `IntegrationEvent` and its handler skeleton.
|
|
27
|
+
- **`qx dev up`** — start the local Docker Compose stack (Postgres, Redis, NATS, Prometheus, Tempo, Grafana, MailHog, MinIO).
|
|
28
|
+
- **`qx dev down`** — stop and remove the local stack.
|
|
29
|
+
- **`qx version`** — print the framework version.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Install (or use via uv in the workspace)
|
|
35
|
+
pip install qx-cli
|
|
36
|
+
|
|
37
|
+
# Scaffold a new service
|
|
38
|
+
qx new service payments-service
|
|
39
|
+
cd payments-service
|
|
40
|
+
|
|
41
|
+
# Add domain objects
|
|
42
|
+
qx generate aggregate Payment
|
|
43
|
+
qx generate command ProcessPayment
|
|
44
|
+
qx generate query GetPayment
|
|
45
|
+
qx generate event PaymentProcessed
|
|
46
|
+
|
|
47
|
+
# Start local infra
|
|
48
|
+
qx dev up
|
|
49
|
+
|
|
50
|
+
# Run
|
|
51
|
+
uv run uvicorn payments_service.main:app --reload
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Generated structure
|
|
55
|
+
|
|
56
|
+
`qx new service` produces:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
my-service/
|
|
60
|
+
├── src/my_service/
|
|
61
|
+
│ ├── domain/aggregates/ # domain model
|
|
62
|
+
│ ├── application/
|
|
63
|
+
│ │ ├── commands/ # command handlers
|
|
64
|
+
│ │ └── queries/ # query handlers
|
|
65
|
+
│ ├── infrastructure/
|
|
66
|
+
│ │ └── persistence/ # repositories, SA mapping
|
|
67
|
+
│ └── presentation/routes/ # FastAPI routes
|
|
68
|
+
├── tests/
|
|
69
|
+
│ ├── unit/
|
|
70
|
+
│ └── integration/
|
|
71
|
+
├── alembic/
|
|
72
|
+
├── pyproject.toml
|
|
73
|
+
└── README.md
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Design rules
|
|
77
|
+
|
|
78
|
+
- Generated code follows the same conventions as `examples/identity-service` — it is the canonical reference for what the generator should produce.
|
|
79
|
+
- `qx dev up/down` is a thin wrapper around `docker compose` pointing at `deploy/docker-compose.yaml` in the workspace root. It does not manage application containers, only infrastructure.
|