feat(standards): fix F11-F16 and rebuild bundle (43 components) + trial findings

- F11 PopulateRule field_name/collection_name corrections
- F12 service adaptor update handles PopulatingRepository
- F13 nested-references section; F14 authless /users/me guidance
- F15 template ruff-clean; F16 stop vendoring .ruff_cache, ship .gitignore
- document --template-style in build_recipe/spec_first_routing; refresh collected
  openapi-first + standards bundle (12 modules, 43 components)
- add trial.md and trial_findings.md (F1-F16, re-trial A/B evidence)
This commit is contained in:
2026-10-13 19:35:12 +05:30
parent fced40af59
commit 6c0d859a1e
56 changed files with 521 additions and 165 deletions

View File

@@ -192,6 +192,12 @@
"placeholder": "",
"description": "Docker build-context exclusions."
},
{
"kind": "gitignore",
"path": ".gitignore",
"placeholder": "",
"description": "VCS exclusions (caches, venv, generated _gen/)."
},
{
"kind": "compose",
"path": "docker-compose.yaml",

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"module": "crud_template",
"content": {
"path": "crud_template",
"docstring": "# CRUD Template\n\n## Summary\n\nEvery resource follows the same four-file slice. Repetition is intentional: it\nmakes the codebase predictable and generatable.\n\n---\n\n## The slice\n\n```\ndomain/<resource>/\n models.py # pure Pydantic model (no DB fields)\n ports/\n db.py # document model + repository factory\n service.py # service class + factory\n converters.py # (optional) transforms\n```\n\n## The id rule (model vs. document)\n\nTwo different objects, two different jobs:\n\n| Object | Lives in | Has `id`? | Purpose |\n|---|---|---|---|\n| **Domain model** | `domain/<resource>/models.py` | **No** | business shape, framework-free |\n| **Document** | `domain/<resource>/ports/db.py` | **Yes** (from `BaseDocument`) | Mongo persistence shape |\n\n`BaseDocument` (mongo-ops) supplies `id: Optional[PyObjectId]` (aliased to\n`_id`) plus `created_at` / `updated_at`. The domain model must **not** declare\n`id`; Mongo owns it.\n\n> **Trap — generated wire models.** `openapi-first models` produces the HTTP\n> *contract* (see the [Build Recipe](01_build_recipe.md), step 3). Response\n> schemas frequently declare a required `id: str`, so a generated class is\n> **not** a domain model. If you write\n> `class ItemDocument(Item, BaseDocument)` and `Item` re-exports a required\n> `id: str`, that field shadows `BaseDocument.id` and no Mongo `_id` can ever be\n> set. Fix it one of three ways:\n>\n> 1. **Preferred:** keep the domain model id-free (hand-author it from the CRUD\n> template), and keep the required `id` only on the wire model.\n> 2. Order the bases so the document wins:\n> `class ItemDocument(BaseDocument, Item)`.\n> 3. Keep a separate document type and translate with `ports/converters.py`.\n\n## models.py\n\nA framework-free Pydantic model describing the business shape (no `id`).\n\n```python\nclass Item(BaseModel):\n name: str\n quantity: int = 0\n```\n\n## ports/db.py\n\nThe persistence model extends the business model and adds database concerns.\nThe repository factory selects the right repository flavour.\n\n```python\n# Item is id-free; BaseDocument adds id/created_at/updated_at.\nclass ItemDocument(Item, BaseDocument):\n pass\n\ndef create_item_repo(*, owner_repo=None) -> ItemRepository:\n return repo_factory(\n collection=\"items\",\n document=ItemDocument,\n populate_rules=[PopulateRule(field=\"owner\", collection=\"users\")],\n cache=None, # or a CachedBaseRepository config\n )\n```\n\n## ports/service.py\n\nThe service is built through the DI library, injecting the repository. The\ngeneric CRUD base provides `create/read/update/patch/delete/search/...`.\n\n```python\nclass ItemService(CrudsServiceAdaptor[Item, ItemDocument]):\n model = Item\n schema = ItemDocument\n\ndef get_item_service(repo) -> ItemService:\n return build(ItemService, instances={\"repo\": repo})\n```\n\n## Naming conventions\n\n| Concept | Convention |\n|---|---|\n| collection | plural snake case (`items`) |\n| document | `<Resource>Document` |\n| repository factory | `create_<resource>_repo` |\n| service | `<Resource>Service` |\n| service factory | `get_<resource>_service` |\n| resource key | `<resource>_svc` |\n| operationId | `list_items`, `create_item`, ... |\n\n## Optional files\n\n- `converters.py` — pure functions mapping between domain types.\n- `ports/enrichment.py` — adapts a pipeline into an async callable the service\n can invoke (used when a resource is populated by a pipeline).\n",
"docstring": "# CRUD Template\n\n## Summary\n\nEvery resource follows the same four-file slice. Repetition is intentional: it\nmakes the codebase predictable and generatable.\n\n---\n\n## The slice\n\n```\ndomain/<resource>/\n models.py # pure Pydantic model (no DB fields)\n ports/\n db.py # document model + repository factory\n service.py # service class + factory\n converters.py # (optional) transforms\n```\n\n## The id rule (model vs. document)\n\nTwo different objects, two different jobs:\n\n| Object | Lives in | Has `id`? | Purpose |\n|---|---|---|---|\n| **Domain model** | `domain/<resource>/models.py` | **No** | business shape, framework-free |\n| **Document** | `domain/<resource>/ports/db.py` | **Yes** (from `BaseDocument`) | Mongo persistence shape |\n\n`BaseDocument` (mongo-ops) supplies `id: Optional[PyObjectId]` (aliased to\n`_id`) plus `created_at` / `updated_at`. The domain model must **not** declare\n`id`; Mongo owns it.\n\n> **Trap — generated wire models.** `openapi-first models` produces the HTTP\n> *contract* (see the [Build Recipe](01_build_recipe.md), step 3). Response\n> schemas frequently declare a required `id: str`, so a generated class is\n> **not** a domain model. If you write\n> `class ItemDocument(Item, BaseDocument)` and `Item` re-exports a required\n> `id: str`, that field shadows `BaseDocument.id` and no Mongo `_id` can ever be\n> set. Fix it one of three ways:\n>\n> 1. **Preferred:** keep the domain model id-free (hand-author it from the CRUD\n> template), and keep the required `id` only on the wire model.\n> 2. Order the bases so the document wins:\n> `class ItemDocument(BaseDocument, Item)`.\n> 3. Keep a separate document type and translate with `ports/converters.py`.\n\n## models.py\n\nA framework-free Pydantic model describing the business shape (no `id`).\n\n```python\nclass Item(BaseModel):\n name: str\n quantity: int = 0\n```\n\n## ports/db.py\n\nThe persistence model extends the business model and adds database concerns.\nThe repository factory selects the right repository flavour.\n\n```python\n# Item is id-free; BaseDocument adds id/created_at/updated_at.\nclass ItemDocument(Item, BaseDocument):\n pass\n\ndef create_item_repo(*, owner_repo=None) -> ItemRepository:\n return repo_factory(\n collection=\"items\",\n document=ItemDocument,\n populate_rules=[PopulateRule(field_name=\"owner\", collection_name=\"users\")],\n cache=None, # or a CachedBaseRepository config\n )\n```\n\n## Nested references\n\nThe wire contract often declares a reference as an expanded object\n(`expense.entity: Entity`), while the domain model stays id-free. Pick one\nconvention and apply it consistently:\n\n- **Embed (default for small, stable refs)** — store the object as a `dict` /\n `list[dict]` on the document; the API returns it verbatim. No populate wiring.\n- **Populate** — store an id and declare a `PopulateRule`; mongo-ops expands it\n on read. The referenced repo must be registered with the `PopulationEngine`.\n- **Id only** — store the id and let the client resolve it.\n\nFor populate-backed repositories change a reference with `update`, not `patch`:\n`patch` rejects changes to populated FK fields by design.\n\n## ports/service.py\n\nThe service is built through the DI library, injecting the repository. The\ngeneric CRUD base provides `create/read/update/patch/delete/search/...`.\n\n```python\nclass ItemService(CrudsServiceAdaptor[Item, ItemDocument]):\n model = Item\n schema = ItemDocument\n\ndef get_item_service(repo) -> ItemService:\n return build(ItemService, instances={\"repo\": repo})\n```\n\n## Naming conventions\n\n| Concept | Convention |\n|---|---|\n| collection | plural snake case (`items`) |\n| document | `<Resource>Document` |\n| repository factory | `create_<resource>_repo` |\n| service | `<Resource>Service` |\n| service factory | `get_<resource>_service` |\n| resource key | `<resource>_svc` |\n| operationId | `list_items`, `create_item`, ... |\n\n## Optional files\n\n- `converters.py` — pure functions mapping between domain types.\n- `ports/enrichment.py` — adapts a pipeline into an async callable the service\n can invoke (used when a resource is populated by a pipeline).\n",
"objects": {}
}
}

View File

@@ -2,7 +2,7 @@
"module": "infra_adaptors",
"content": {
"path": "infra_adaptors",
"docstring": "# Infra Adapters\n\n## Summary\n\nAll external systems are reached through adapters in `infra/`. Prefer an\nexisting internal library over writing a new adapter; where no library exists,\ncopy the Tier B scaffold from `templates/app`. See the Genericity Matrix for the\nTier A / B / C split.\n\n---\n\n## Database (MongoDB)\n\n- A single connection manager owns the client lifecycle.\n `setup_database(uri, db_name)` / `shutdown_database()`.\n- A repository **factory** selects the repository flavour:\n\n ```python\n repo_factory(collection, document, engine=None,\n populate_rules=None, cache=None)\n ```\n\n - base repository — plain CRUD;\n - populating repository — resolves referenced documents (foreign keys);\n - cached repository — an LRU/TTL cache in front of reads.\n\n- Referenced documents are described declaratively:\n\n ```python\n PopulateRule(field=\"owner\", collection=\"users\",\n nested_rules=[], max_depth=2, projection=None)\n ```\n\n- Transactions use a session context manager provided by the library.\n\n**Tier A — Library (Mongo Ops).** Do not write raw Motor calls in the domain;\nconsult the registry and the library's MCP before touching the driver.\n\n## HTTP response cache\n\n**Tier B — template.** Copy from `templates/app`; not a standalone library.\n\nA generic GET cache sits behind a decorator.\n\n```python\n@cache_response(ttl=60)\nasync def list_items(request): ...\n```\n\n- Keyed on path + query + user.\n- No-op when no store is wired.\n- `invalidate(*resources)` bumps a generation counter for write-triggered\n invalidation.\n\n## Event / progress bus\n\n**Tier B — template.** Copy from `templates/app`.\n\nA port with an in-memory default adapter (`publish`, `get_events`). Swap for a\ndurable broker in production (see Worker & Progress).\n\n## External clients (ingestion)\n\nExternal fetchers (files, mail, HTTP) are adapters that wrap ingestion\nlibraries. Construct them in the composition root and inject into\norchestrators.\n\n**Tier A — Libraries:** Omniread (formats) and Mail Intake (email).\n\n## LLM clients\n\n**Tier B — template.** Copy the pool scaffold from `templates/app`.\n\nWhen a pipeline calls a model provider, route through a pool adaptor that\nhandles provider selection, retries, and client lifecycle:\n\n```python\npool = setup_async_pool(provider=settings.llm_provider)\nresult = await pool.complete(model=\"...\", messages=[...])\n```\n\nProviders are selected by `LLM_PROVIDER` and configured by environment.\n\n## Key-value store\n\n**Tier B — template.** Copy from `templates/app`.\n\nFor report/snapshot storage, provide a pluggable store port with a Redis\nadapter and an in-memory fallback.\n\n## Rule\n\n- **Tier A (libraries: DB, DI, ingestion, mail, auth, docs, pipelines)** — use\n the library; writing a private copy is a defect. Query `find_library` first.\n- **Tier B (cache, progress bus, LLM pool, KV store, composition shape, worker,\n tests)** — these are template code, not libraries: copy them from\n `templates/app` rather than searching the registry.\n- **Tier C (models, business logic, pipeline families, rules)** — author them\n yourself.\n",
"docstring": "# Infra Adapters\n\n## Summary\n\nAll external systems are reached through adapters in `infra/`. Prefer an\nexisting internal library over writing a new adapter; where no library exists,\ncopy the Tier B scaffold from `templates/app`. See the Genericity Matrix for the\nTier A / B / C split.\n\n---\n\n## Database (MongoDB)\n\n- A single connection manager owns the client lifecycle.\n `setup_database(uri, db_name)` / `shutdown_database()`.\n- A repository **factory** selects the repository flavour:\n\n ```python\n repo_factory(collection, document, engine=None,\n populate_rules=None, cache=None)\n ```\n\n - base repository — plain CRUD;\n - populating repository — resolves referenced documents (foreign keys);\n - cached repository — an LRU/TTL cache in front of reads.\n\n- Referenced documents are described declaratively:\n\n ```python\n PopulateRule(field_name=\"owner\", collection_name=\"users\",\n nested_rules=[], max_depth=2, projection=None)\n ```\n\n- Transactions use a session context manager provided by the library.\n\n**Tier A — Library (Mongo Ops).** Do not write raw Motor calls in the domain;\nconsult the registry and the library's MCP before touching the driver.\n\n## HTTP response cache\n\n**Tier B — template.** Copy from `templates/app`; not a standalone library.\n\nA generic GET cache sits behind a decorator.\n\n```python\n@cache_response(ttl=60)\nasync def list_items(request): ...\n```\n\n- Keyed on path + query + user.\n- No-op when no store is wired.\n- `invalidate(*resources)` bumps a generation counter for write-triggered\n invalidation.\n\n## Event / progress bus\n\n**Tier B — template.** Copy from `templates/app`.\n\nA port with an in-memory default adapter (`publish`, `get_events`). Swap for a\ndurable broker in production (see Worker & Progress).\n\n## External clients (ingestion)\n\nExternal fetchers (files, mail, HTTP) are adapters that wrap ingestion\nlibraries. Construct them in the composition root and inject into\norchestrators.\n\n**Tier A — Libraries:** Omniread (formats) and Mail Intake (email).\n\n## LLM clients\n\n**Tier B — template.** Copy the pool scaffold from `templates/app`.\n\nWhen a pipeline calls a model provider, route through a pool adaptor that\nhandles provider selection, retries, and client lifecycle:\n\n```python\npool = setup_async_pool(provider=settings.llm_provider)\nresult = await pool.complete(model=\"...\", messages=[...])\n```\n\nProviders are selected by `LLM_PROVIDER` and configured by environment.\n\n## Key-value store\n\n**Tier B — template.** Copy from `templates/app`.\n\nFor report/snapshot storage, provide a pluggable store port with a Redis\nadapter and an in-memory fallback.\n\n## Rule\n\n- **Tier A (libraries: DB, DI, ingestion, mail, auth, docs, pipelines)** — use\n the library; writing a private copy is a defect. Query `find_library` first.\n- **Tier B (cache, progress bus, LLM pool, KV store, composition shape, worker,\n tests)** — these are template code, not libraries: copy them from\n `templates/app` rather than searching the registry.\n- **Tier C (models, business logic, pipeline families, rules)** — author them\n yourself.\n",
"objects": {}
}
}

View File

@@ -2,7 +2,7 @@
"module": "spec_first_routing",
"content": {
"path": "spec_first_routing",
"docstring": "# Spec-First Routing\n\n## Summary\n\nThe OpenAPI document is the single source of truth for the HTTP contract.\nHandlers are bound to it by name; there is no decorator router.\n\n---\n\n## The contract\n\n- `openapi.yaml` (or `.json`) declares paths, schemas, parameters, responses,\n and security.\n- A custom `operationId` names the handler function.\n- The framework (`openapi_first`) validates requests/responses against the\n spec and dispatches to the handler whose name equals `operationId`.\n\n## App bootstrap\n\n```python\n# main.py\nfrom openapi_first.app import OpenAPIFirstApp\n\napp = OpenAPIFirstApp(\n openapi_path=\"openapi.yaml\",\n routes_module=interfaces.openapi, # module whose namespace holds handlers\n title=\"<App> API\",\n version=\"0.1.0\",\n lifespan=lifespan,\n)\n```\n\n`lifespan` calls the composition root:\n\n```python\n@asynccontextmanager\nasync def lifespan(app):\n app.state.db_resources = await init()\n yield\n await shutdown()\n```\n\n## Handler registry\n\n`interfaces/openapi.py` is a flat re-export of every handler, so the framework\ncan resolve `operationId` across the whole app:\n\n```python\nfrom interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n```\n\n## Handler shape\n\nHandlers are thin and synchronous with respect to business logic:\n\n```python\nasync def create_item(request: Request, body: dict):\n svc = request.app.state.db_resources[\"item_svc\"]\n return await svc.create(Item.model_validate(body))\n```\n\n## Conventions\n\n- `operationId` naming: `list_<resource>`, `create_<resource>`,\n `get_<resource>`, `update_<resource>`, `delete_<resource>`.\n- Read-only endpoints may use a cache decorator keyed on path + query + user.\n- Validation errors -> `400`; not found -> `404`; delete -> `204`.\n- Security is declared once in the spec and enforced by the framework; handlers\n contain no auth code.\n\n## Authless apps\n\nAuth is **deferred** in the standard: the same spec-first app runs with or\nwithout it, because security lives entirely in the spec, never in handlers.\nFor an app that needs no auth yet, do **not** invent per-handler auth checks —\njust omit the security keys:\n\n- Remove the top-level `security:` block.\n- Remove `components.securitySchemes`.\n- Do **not** add `security: []` to operations. An explicit empty array is a\n per-operation *override* that means \"public\", and it only matters when a\n global `security` block exists. Leaving every operation without a `security`\n key is the cleanest authless spec.\n\nThe handlers, services, and composition root are unchanged. When auth is needed\nlater, add the global `security` block and an introspecting dependency; only\nhandlers that must opt out get `security: []`.\n\n## Codegen\n\n`openapi_first` scaffolds models and route stubs from the spec (see the\n[Build Recipe](01_build_recipe.md), step 3):\n\n```bash\nopenapi-first models openapi.yaml -o _gen/models.py\nopenapi-first routes openapi.yaml -o _gen/routes \\\n --use-models --models-module _gen.models --inject-request --package\n```\n\nPrefer codegen over hand-writing boilerplate. The result is the wire contract\nand a set of `operationId`-named signatures — not the canonical handlers.\n",
"docstring": "# Spec-First Routing\n\n## Summary\n\nThe OpenAPI document is the single source of truth for the HTTP contract.\nHandlers are bound to it by name; there is no decorator router.\n\n---\n\n## The contract\n\n- `openapi.yaml` (or `.json`) declares paths, schemas, parameters, responses,\n and security.\n- A custom `operationId` names the handler function.\n- The framework (`openapi_first`) validates requests/responses against the\n spec and dispatches to the handler whose name equals `operationId`.\n\n## App bootstrap\n\n```python\n# main.py\nfrom openapi_first.app import OpenAPIFirstApp\n\napp = OpenAPIFirstApp(\n openapi_path=\"openapi.yaml\",\n routes_module=interfaces.openapi, # module whose namespace holds handlers\n title=\"<App> API\",\n version=\"0.1.0\",\n lifespan=lifespan,\n)\n```\n\n`lifespan` calls the composition root:\n\n```python\n@asynccontextmanager\nasync def lifespan(app):\n app.state.db_resources = await init()\n yield\n await shutdown()\n```\n\n## Handler registry\n\n`interfaces/openapi.py` is a flat re-export of every handler, so the framework\ncan resolve `operationId` across the whole app:\n\n```python\nfrom interfaces.item.routes import *\nfrom interfaces.wallet.routes import *\nfrom interfaces.progress.sse import *\n```\n\n## Handler shape\n\nHandlers are thin and synchronous with respect to business logic:\n\n```python\nasync def create_item(request: Request, body: dict):\n svc = request.app.state.db_resources[\"item_svc\"]\n return await svc.create(Item.model_validate(body))\n```\n\n**Generated stubs.** `openapi-first routes` emits this exact shape (`async def`\nhandlers taking `body`) when given `--template-style`; without that flag the\nstub is `def create_item(payload: ItemInput)` — sync, with a `payload`\nparameter. Both shapes bind identically: the router resolves handlers by\n`operationId` and introspects the FastAPI signature, and it never inspects the\nrequest body. Pass `--template-style` so the generated signatures match the\nhandlers you keep in `interfaces/`.\n\n## Conventions\n\n- `operationId` naming: `list_<resource>`, `create_<resource>`,\n `get_<resource>`, `update_<resource>`, `delete_<resource>`.\n- Read-only endpoints may use a cache decorator keyed on path + query + user.\n- Validation errors -> `400`; not found -> `404`; delete -> `204`.\n- Security is declared once in the spec and enforced by the framework; handlers\n contain no auth code.\n\n## Authless apps\n\nAuth is **deferred** in the standard: the same spec-first app runs with or\nwithout it, because security lives entirely in the spec, never in handlers.\nFor an app that needs no auth yet, do **not** invent per-handler auth checks —\njust omit the security keys:\n\n- Remove the top-level `security:` block.\n- Remove `components.securitySchemes`.\n- Do **not** add `security: []` to operations. An explicit empty array is a\n per-operation *override* that means \"public\", and it only matters when a\n global `security` block exists. Leaving every operation without a `security`\n key is the cleanest authless spec.\n\nProfile endpoints (`/users/me`) and profile headers (`x-profile`) still exist in\nan authless app. Define a stand-in identity in the composition root — for\nexample `get_current_user` returns or lazily creates a fixed \"Demo User\" — so\nhandlers stay auth-agnostic and only the wiring changes when real auth arrives.\n\nThe handlers, services, and composition root are unchanged. When auth is needed\nlater, add the global `security` block and an introspecting dependency; only\nhandlers that must opt out get `security: []`.\n\n## Codegen\n\n`openapi_first` scaffolds models and route stubs from the spec (see the\n[Build Recipe](01_build_recipe.md), step 3):\n\n```bash\nopenapi-first models openapi.yaml -o _gen/models.py\nopenapi-first routes openapi.yaml -o _gen/routes \\\n --use-models --models-module _gen.models --inject-request --package --template-style\n```\n\nPrefer codegen over hand-writing boilerplate. The result is the wire contract\nand a set of `operationId`-named signatures — not the canonical handlers.\n",
"objects": {}
}
}

23
standards/mcp/templates/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Byte-compiled / caches
__pycache__/
*.py[cod]
.ruff_cache/
.pytest_cache/
.mypy_cache/
# Virtual environments
.venv/
venv/
# Env
.env
# Generated wire contract (regenerate from openapi.yaml)
_gen/
# Packaging / coverage
dist/
build/
*.egg-info/
htmlcov/
.coverage

View File

@@ -2,9 +2,10 @@
Mongo persistence for items: document schema and repository factory.
"""
from mongo_ops import BaseDocument, BaseRepository
from domain.item.models import Item
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
from mongo_ops import BaseDocument, BaseRepository
class ItemDocument(Item, BaseDocument):

View File

@@ -3,6 +3,7 @@
Job domain model — a pipeline work item consumed by the background worker.
"""
from typing import Literal
from pydantic import BaseModel, Field

View File

@@ -2,9 +2,10 @@
Mongo persistence for jobs: document schema and repository factory.
"""
from mongo_ops import BaseDocument, BaseRepository
from domain.job.models import Job
from infra.db.repos.mongo import _repo_factory as mongo_repo_factory
from mongo_ops import BaseDocument, BaseRepository
class JobDocument(Job, BaseDocument):

View File

@@ -21,18 +21,17 @@ Example:
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Dict, List, Optional
class KVStore(ABC):
"""Abstract key-value store port."""
@abstractmethod
async def get(self, key: str) -> Optional[str]:
async def get(self, key: str) -> str | None:
"""Return the value at ``key``, or ``None``."""
@abstractmethod
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
@abstractmethod
@@ -40,7 +39,7 @@ class KVStore(ABC):
"""Delete ``key`` if present."""
@abstractmethod
async def scan(self, pattern: str) -> List[str]:
async def scan(self, pattern: str) -> list[str]:
"""Return keys matching a glob ``pattern``."""
async def clear(self, pattern: str = "*") -> None:
@@ -67,8 +66,8 @@ class InMemoryKV(KVStore):
def __init__(self) -> None:
"""Initialize an empty store with no imports or connections."""
self._data: Dict[str, str] = {}
self._expiry: Dict[str, float] = {}
self._data: dict[str, str] = {}
self._expiry: dict[str, float] = {}
def _expired(self, key: str) -> bool:
import time
@@ -80,13 +79,13 @@ class InMemoryKV(KVStore):
return True
return False
async def get(self, key: str) -> Optional[str]:
async def get(self, key: str) -> str | None:
"""Return the value at ``key``, or ``None`` when absent/expired."""
if self._expired(key):
return None
return self._data.get(key)
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
import time
@@ -98,7 +97,7 @@ class InMemoryKV(KVStore):
self._data.pop(key, None)
self._expiry.pop(key, None)
async def scan(self, pattern: str) -> List[str]:
async def scan(self, pattern: str) -> list[str]:
"""Return live keys matching a glob ``pattern``."""
import fnmatch
@@ -126,11 +125,11 @@ class RedisKV(KVStore):
self._redis = Redis.from_url(url, decode_responses=decode_responses)
async def get(self, key: str) -> Optional[str]:
async def get(self, key: str) -> str | None:
"""Return the value at ``key``, or ``None``."""
return await self._redis.get(key)
async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None:
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
"""Store ``value`` at ``key`` with an optional TTL in seconds."""
await self._redis.set(key, value, ex=ttl)
@@ -138,7 +137,7 @@ class RedisKV(KVStore):
"""Delete ``key`` if present."""
await self._redis.delete(key)
async def scan(self, pattern: str) -> List[str]:
async def scan(self, pattern: str) -> list[str]:
"""Return keys matching a glob ``pattern``."""
return [key async for key in self._redis.scan_iter(match=pattern)]

View File

@@ -5,14 +5,14 @@ Generic CRUD service adaptor over a Mongo repository.
Bridge pydantic models and mongo-ops documents, exposing create, read, update,
patch, delete, and search operations for every domain that needs them.
"""
from typing import Generic, Type, TypeVar
from typing import Generic, TypeVar
from mongo_ops import BaseDocument, PopulatingRepository
from mongo_ops.models import PyObjectId
from pydantic import BaseModel
from infra.db.repos import mongo
from pydantic import BaseModel
from mongo_ops import BaseDocument
from mongo_ops.models import PyObjectId
Model = TypeVar("Model", bound=BaseModel)
Schema = TypeVar("Schema", bound=BaseDocument)
@@ -27,8 +27,8 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
"""
repo: mongo.BaseRepository
model: Type[Model]
schema: Type[Schema]
model: type[Model]
schema: type[Schema]
def __init__(self, *, repo=None):
"""Initialize the adaptor with a backing repository.
@@ -83,7 +83,17 @@ class CrudsServiceAdaptor(Generic[Model, Schema]):
Returns:
Schema | None: Updated document, or ``None`` when absent.
Notes:
A populating repository expects a document *model* (so FK fields can
be depopulated), while a base repository expects a raw mapping. This
method normalizes both inputs to whatever the repo requires.
"""
if isinstance(self.repo, PopulatingRepository):
if not isinstance(data, self.schema):
fields = data if isinstance(data, dict) else self.model_to_dict(data)
data = self.schema(**fields)
return await self.repo.update(id, data)
return await self.repo.update(
id,
data if isinstance(data, dict) else self.model_to_dict(data),

View File

@@ -7,7 +7,7 @@ SSEInMemoryBus — in-memory event bus for SSE progress events.
Buffer published progress events so they can be replayed (and streamed) per
correlation key, such as a job, upload, or fetch-request id.
"""
from typing import Generic, List, TypeVar
from typing import Generic, TypeVar
T = TypeVar("T")
@@ -27,7 +27,7 @@ class SSEInMemoryBus(Generic[T]):
key_attr (str): Attribute name on each event holding the stream
correlation key (for example ``"job_id"``).
"""
self._events: List[T] = []
self._events: list[T] = []
self._key_attr = key_attr
async def publish(self, event: T) -> None:
@@ -38,7 +38,7 @@ class SSEInMemoryBus(Generic[T]):
"""
self._events.append(event)
def get_events(self, key: str) -> List[T]:
def get_events(self, key: str) -> list[T]:
"""Return buffered events for a correlation key.
Args:

View File

@@ -5,21 +5,20 @@ Generic MongoDB CRUD repo — single implementation for all domains.
Select a concrete mongo-ops repository (base, cached, or populating) from the
options supplied to the factory.
"""
from typing import List, Optional, Type
from mongo_ops import BaseRepository, CachedBaseRepository, PopulatingRepository
from mongo_ops.cache import CacheBackend, CacheConfig
from mongo_ops.models import BaseDocument
from mongo_ops.populate import PopulationEngine, PopulateRule
from mongo_ops.populate import PopulateRule, PopulationEngine
def _repo_factory(
collection: str,
db_model: Type[BaseDocument],
cache_backend: Optional[CacheBackend] = None,
cache_config: Optional[CacheConfig] = None,
engine: Optional[PopulationEngine] = None,
populate_rules: Optional[List[PopulateRule]] = None,
db_model: type[BaseDocument],
cache_backend: CacheBackend | None = None,
cache_config: CacheConfig | None = None,
engine: PopulationEngine | None = None,
populate_rules: list[PopulateRule] | None = None,
) -> BaseRepository:
"""Build the appropriate mongo-ops repository.

View File

@@ -23,8 +23,9 @@ Notes:
"""
import hashlib
import json
from collections.abc import Callable
from functools import wraps
from typing import Any, Callable, Optional
from typing import Any
from fastapi import Request, Response
from fastapi.encoders import jsonable_encoder
@@ -55,7 +56,7 @@ class HttpCacheStore:
self._redis = Redis.from_url(url, decode_responses=decode_responses)
async def get(self, key: str) -> Optional[str]:
async def get(self, key: str) -> str | None:
"""Read the value stored at ``key``.
Args:
@@ -173,7 +174,7 @@ def _cache_key(request: Request, generation: str = "0") -> str:
return hashlib.sha256("|".join(parts).encode()).hexdigest()
def cache_response(ttl: Optional[int] = None) -> Callable:
def cache_response(ttl: int | None = None) -> Callable:
"""Decorate a GET-style route handler to cache its JSON response.
Args:
@@ -203,7 +204,7 @@ def cache_response(ttl: Optional[int] = None) -> Callable:
return await func(*args, **kwargs)
db_resources = getattr(request.app.state, "db_resources", None) or {}
store: Optional[HttpCacheStore] = db_resources.get("http_cache_store")
store: HttpCacheStore | None = db_resources.get("http_cache_store")
if store is None:
return await func(*args, **kwargs)

View File

@@ -4,7 +4,7 @@ Infrastructure initialization — connects MongoDB and wires all repos and servi
Shared bootstrap for both the FastAPI app and the worker, guarded against
repeated initialization via a module-level flag.
"""
from typing import Any, Dict
from typing import Any
from config import settings
from domain.item.ports.db import create_item_repo
@@ -13,11 +13,11 @@ from domain.job.ports.db import create_job_repo
from domain.job.ports.service import get_job_service
from infra.db.setup import setup_database, shutdown_database
_resources: Dict[str, Any] = {}
_resources: dict[str, Any] = {}
_initialized: bool = False
async def init() -> Dict[str, Any]:
async def init() -> dict[str, Any]:
"""Connect MongoDB, wire all repos and services, and return the resources dict.
Idempotent: repeated calls short-circuit and return the cached resources.

View File

@@ -4,5 +4,6 @@ All handler functions — names must match openapi.yaml operationId values. The
OpenAPI-first router resolves each operation to a handler by name, so this module
re-exports every route handler from the subpackages into one flat namespace.
"""
# ruff: noqa: F403
from interfaces.item.routes import *
from interfaces.job.routes import *

View File

@@ -4,6 +4,7 @@ FastAPI application bootstrap — uses OpenAPIFirstApp for spec-driven routing.
Handlers are looked up by operationId in interfaces.openapi, so no router tables
are declared here; the lifespan initializes and shuts down app resources.
"""
# ruff: noqa: E402
import logging
from contextlib import asynccontextmanager

View File

@@ -9,7 +9,6 @@ from pathlib import Path
from typing import Any
import yaml
from hexa import build

View File

@@ -4,13 +4,12 @@ Family registry — resolve a ``(kind, variant, mode)`` triple to a root class.
A single lookup replaces hand-rolled ``if/else`` family selection.
"""
from typing import Dict, Type
# Populated by ``register`` as families import.
_FAMILIES: Dict[str, Dict[str, Dict[str, Type]]] = {}
_FAMILIES: dict[str, dict[str, dict[str, type]]] = {}
def register(kind: str, variant: str, root_cls: Type, mode: str = "default") -> Type:
def register(kind: str, variant: str, root_cls: type, mode: str = "default") -> type:
"""Register ``root_cls`` for a ``(kind, variant, mode)`` triple.
Returns:
@@ -20,7 +19,7 @@ def register(kind: str, variant: str, root_cls: Type, mode: str = "default") ->
return root_cls
def resolve_root(kind: str, variant: str, mode: str = "default") -> Type:
def resolve_root(kind: str, variant: str, mode: str = "default") -> type:
"""Return the root class for a ``(kind, variant, mode)`` triple.
Raises:

View File

@@ -40,6 +40,7 @@ testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
extend-exclude = ["_gen"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]

View File

@@ -1,9 +1,9 @@
import importlib
import interfaces.openapi as routes_module
from openapi_first.app import OpenAPIFirstApp
import interfaces.openapi as routes_module
def test_openapi_app_builds():
"""The spec-first app must build and plant each route handler."""

View File

@@ -1,11 +1,10 @@
import importlib
import yaml
from openapi_first.app import OpenAPIFirstApp
import interfaces.openapi as routes_module
from openapi_first.app import OpenAPIFirstApp
_HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}