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.
Files changed (41) hide show
  1. qx/cli/__init__.py +1 -0
  2. qx/cli/commands/__init__.py +0 -0
  3. qx/cli/commands/dev.py +86 -0
  4. qx/cli/commands/generate.py +182 -0
  5. qx/cli/commands/new.py +76 -0
  6. qx/cli/main.py +52 -0
  7. qx/cli/py.typed +0 -0
  8. qx/cli/scaffolds/__init__.py +0 -0
  9. qx/cli/scaffolds/aggregate/__init__.py +0 -0
  10. qx/cli/scaffolds/aggregate/src/__service_pkg__/domain/aggregates/__name_snake__/__init__.py.j2 +60 -0
  11. qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/__init__.py.j2 +0 -0
  12. qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/mapping.py.j2 +27 -0
  13. qx/cli/scaffolds/aggregate/src/__service_pkg__/infrastructure/persistence/__name_snake__/repository.py.j2 +21 -0
  14. qx/cli/scaffolds/command/__init__.py +0 -0
  15. qx/cli/scaffolds/command/src/__service_pkg__/application/commands/__name_snake__.py.j2 +32 -0
  16. qx/cli/scaffolds/endpoint/__init__.py +0 -0
  17. qx/cli/scaffolds/endpoint/src/__service_pkg__/presentation/routes/__name_snake__.py.j2 +24 -0
  18. qx/cli/scaffolds/event/__init__.py +0 -0
  19. qx/cli/scaffolds/event/src/__service_pkg__/domain/events/__name_snake__.py.j2 +18 -0
  20. qx/cli/scaffolds/query/__init__.py +0 -0
  21. qx/cli/scaffolds/query/src/__service_pkg__/application/queries/__name_snake__.py.j2 +21 -0
  22. qx/cli/scaffolds/service/.env.example.j2 +16 -0
  23. qx/cli/scaffolds/service/Dockerfile.j2 +19 -0
  24. qx/cli/scaffolds/service/README.md.j2 +59 -0
  25. qx/cli/scaffolds/service/__init__.py +0 -0
  26. qx/cli/scaffolds/service/alembic/env.py.j2 +13 -0
  27. qx/cli/scaffolds/service/alembic/script.py.mako +26 -0
  28. qx/cli/scaffolds/service/alembic.ini.j2 +38 -0
  29. qx/cli/scaffolds/service/pyproject.toml.j2 +47 -0
  30. qx/cli/scaffolds/service/src/__service_pkg__/__init__.py.j2 +3 -0
  31. qx/cli/scaffolds/service/src/__service_pkg__/application/__init__.py.j2 +22 -0
  32. qx/cli/scaffolds/service/src/__service_pkg__/domain/__init__.py.j2 +22 -0
  33. qx/cli/scaffolds/service/src/__service_pkg__/infrastructure/__init__.py.j2 +22 -0
  34. qx/cli/scaffolds/service/src/__service_pkg__/main.py.j2 +88 -0
  35. qx/cli/scaffolds/service/src/__service_pkg__/presentation/__init__.py.j2 +21 -0
  36. qx/cli/scaffolds/service/tests/test_smoke.py.j2 +33 -0
  37. qx/cli/templates/__init__.py +162 -0
  38. qx_cli-0.1.0.dist-info/METADATA +79 -0
  39. qx_cli-0.1.0.dist-info/RECORD +41 -0
  40. qx_cli-0.1.0.dist-info/WHEEL +4 -0
  41. 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,3 @@
1
+ """{{ service_name }} service package."""
2
+
3
+ __version__ = "0.1.0"
@@ -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.