docs(standards): add reusable Tier-B template scaffolds (pipeline family, shared loader/registry, converters, scripts)
This commit is contained in:
18
standards/src/templates/app/domain/item/ports/converters.py
Normal file
18
standards/src/templates/app/domain/item/ports/converters.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Optional converters for the item slice — pure functions mapping between domain
|
||||||
|
types. Add a converter here when a service must reshape data; keep it pure.
|
||||||
|
"""
|
||||||
|
from domain.item.models import Item
|
||||||
|
|
||||||
|
|
||||||
|
def to_item(data: dict) -> Item:
|
||||||
|
"""Build an :class:`Item` from a raw mapping.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (dict): Field mapping matching the item model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item: The constructed item model.
|
||||||
|
"""
|
||||||
|
return Item(**data)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Example pipeline family — a reusable skeleton, not an app-specific pipeline.
|
||||||
|
|
||||||
|
Replace ``Example*`` names and the three stages with your family's stages. The
|
||||||
|
shape (abc → impl → pipeline.yaml → resources/) stays the same.
|
||||||
|
"""
|
||||||
53
standards/src/templates/app/pipelines/example/abc.py
Normal file
53
standards/src/templates/app/pipelines/example/abc.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Example pipeline family — port contracts (stage interfaces + root interface).
|
||||||
|
|
||||||
|
Ports are declared as a DI tree: the root's annotated slots *are* the dependency
|
||||||
|
decisions. The concrete subclass in ``impl.py`` pins the stage types.
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pipelines.shared.ports import FinalizePort
|
||||||
|
|
||||||
|
|
||||||
|
class IngestPort(ABC):
|
||||||
|
"""Obtain the raw input as text/bytes."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def materialize(self, source: Any) -> str:
|
||||||
|
"""Return the raw input materialized as text."""
|
||||||
|
|
||||||
|
|
||||||
|
class ReadPort(ABC):
|
||||||
|
"""Parse materialized input into records."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def read(self, content: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return parsed records."""
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizePort(ABC):
|
||||||
|
"""Normalize raw records into the target shape."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def build(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Return normalized records."""
|
||||||
|
|
||||||
|
|
||||||
|
class ExamplePipelinePort(ABC):
|
||||||
|
"""Root of the example family.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
The slot annotations below are contracts. A concrete subclass re-annotates
|
||||||
|
them with stage implementations; the DI container instantiates the tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ingest: IngestPort
|
||||||
|
reader: ReadPort
|
||||||
|
normalize: NormalizePort
|
||||||
|
finalize: FinalizePort
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def run(self, source: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Run the full stage chain and return the normalized records."""
|
||||||
71
standards/src/templates/app/pipelines/example/impl.py
Normal file
71
standards/src/templates/app/pipelines/example/impl.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Example pipeline family — concrete stages, the wired root, and its registration.
|
||||||
|
|
||||||
|
The concrete subclass re-annotates each slot with a stage implementation; the DI
|
||||||
|
container builds the whole tree. Register the family so the shared registry can
|
||||||
|
resolve it by ``(kind, variant, mode)``.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pipelines.example.abc import (
|
||||||
|
ExamplePipelinePort,
|
||||||
|
IngestPort,
|
||||||
|
NormalizePort,
|
||||||
|
ReadPort,
|
||||||
|
)
|
||||||
|
from pipelines.shared.loader import build_pipeline
|
||||||
|
from pipelines.shared.ports import FinalizePort
|
||||||
|
from pipelines.shared.registry import register
|
||||||
|
|
||||||
|
|
||||||
|
class TextIngest(IngestPort):
|
||||||
|
"""Materialize any source as text."""
|
||||||
|
|
||||||
|
def materialize(self, source: Any) -> str:
|
||||||
|
return source if isinstance(source, str) else str(source)
|
||||||
|
|
||||||
|
|
||||||
|
class LineReader(ReadPort):
|
||||||
|
"""Read one record per non-empty line."""
|
||||||
|
|
||||||
|
def read(self, content: str) -> list[dict[str, Any]]:
|
||||||
|
return [{"line": line} for line in content.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
class PassthroughNormalize(NormalizePort):
|
||||||
|
"""Return records unchanged (replace with real normalization)."""
|
||||||
|
|
||||||
|
def build(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
class NoopFinalize(FinalizePort):
|
||||||
|
"""Drop records (replace with a service-backed finalize stage)."""
|
||||||
|
|
||||||
|
async def finalize(self, records: list[dict[str, Any]]) -> list[Any]:
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
class ExamplePipeline(ExamplePipelinePort):
|
||||||
|
"""Concrete example family — the dependency decisions live here."""
|
||||||
|
|
||||||
|
ingest: TextIngest
|
||||||
|
reader: LineReader
|
||||||
|
normalize: PassthroughNormalize
|
||||||
|
finalize: NoopFinalize
|
||||||
|
|
||||||
|
async def run(self, source: Any) -> list[dict[str, Any]]:
|
||||||
|
content = self.ingest.materialize(source)
|
||||||
|
records = self.reader.read(content)
|
||||||
|
return self.normalize.build(records)
|
||||||
|
|
||||||
|
|
||||||
|
def get_example_pipeline() -> ExamplePipeline:
|
||||||
|
"""Wire and return the example pipeline through the shared loader."""
|
||||||
|
resources = Path(__file__).parent / "resources"
|
||||||
|
return build_pipeline(ExamplePipeline, resources_dir=resources)
|
||||||
|
|
||||||
|
|
||||||
|
register("example", "default", ExamplePipeline)
|
||||||
18
standards/src/templates/app/pipelines/example/pipeline.yaml
Normal file
18
standards/src/templates/app/pipelines/example/pipeline.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Graph spec for the example family: nodes, edges, and the root.
|
||||||
|
# Mirrors the port tree in abc.py. Replace with your family's graph.
|
||||||
|
schema: ExampleRecord
|
||||||
|
nodes:
|
||||||
|
ingest:
|
||||||
|
port: IngestPort
|
||||||
|
reader:
|
||||||
|
port: ReadPort
|
||||||
|
normalize:
|
||||||
|
port: NormalizePort
|
||||||
|
finalize:
|
||||||
|
port: FinalizePort
|
||||||
|
edges:
|
||||||
|
- ingest -> reader
|
||||||
|
- reader -> normalize
|
||||||
|
- normalize -> finalize
|
||||||
|
roots:
|
||||||
|
default: ExamplePipeline
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Pattern / rule fragments for the example family.
|
||||||
|
# The loader merges every patterns/*.yaml into one registry by key.
|
||||||
|
example:
|
||||||
|
enabled: true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Runtime spec consumed by the shared loader.
|
||||||
|
kind: example
|
||||||
|
variant: default
|
||||||
|
mode: default
|
||||||
|
patterns: []
|
||||||
5
standards/src/templates/app/pipelines/shared/__init__.py
Normal file
5
standards/src/templates/app/pipelines/shared/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Shared pipeline scaffolding — ports, loader, and registry reused by every
|
||||||
|
pipeline family. See standards "06_pipeline_scaffold".
|
||||||
|
"""
|
||||||
54
standards/src/templates/app/pipelines/shared/loader.py
Normal file
54
standards/src/templates/app/pipelines/shared/loader.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Pipeline loader — wire a family root through the DI library and attach the
|
||||||
|
merged pattern registry from ``resources/patterns/*.yaml``.
|
||||||
|
|
||||||
|
All families share this loader so wiring is never hand-rolled.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from hexa import build
|
||||||
|
|
||||||
|
|
||||||
|
def load_patterns(resources_dir: str | Path) -> dict[str, Any]:
|
||||||
|
"""Merge every pattern fragment under ``resources/patterns`` into one map.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resources_dir (str | Path): Family resources directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Merged pattern/rule fragments keyed by fragment name.
|
||||||
|
"""
|
||||||
|
registry: dict[str, Any] = {}
|
||||||
|
patterns = Path(resources_dir) / "patterns"
|
||||||
|
if patterns.is_dir():
|
||||||
|
for path in sorted(patterns.glob("*.yaml")):
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
registry.update(data)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def build_pipeline(
|
||||||
|
root_cls,
|
||||||
|
*,
|
||||||
|
resources_dir: str | Path = "resources",
|
||||||
|
instances: dict[str, Any] | None = None,
|
||||||
|
config: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
"""Wire ``root_cls`` and inject the merged pattern registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root_cls: The pipeline root class (a port tree).
|
||||||
|
resources_dir (str | Path): Directory holding ``patterns/*.yaml``.
|
||||||
|
instances (dict[str, Any] | None): Runtime objects injected by slot name.
|
||||||
|
config (dict[str, Any] | None): Config values propagated by field name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The fully wired pipeline root.
|
||||||
|
"""
|
||||||
|
merged_instances = dict(instances or {})
|
||||||
|
merged_instances.setdefault("registry", load_patterns(resources_dir))
|
||||||
|
return build(root_cls, instances=merged_instances, config=config or {})
|
||||||
25
standards/src/templates/app/pipelines/shared/ports.py
Normal file
25
standards/src/templates/app/pipelines/shared/ports.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Shared pipeline ports — the contracts every pipeline family reuses.
|
||||||
|
|
||||||
|
A port is an abstract stage contract. Concrete families pin their implementations
|
||||||
|
on a root's annotated slots; the DI library instantiates the tree.
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class FinalizePort(ABC):
|
||||||
|
"""Terminal stage — turn normalized records into persisted resources."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def finalize(self, records: list[dict[str, Any]]) -> list[Any]:
|
||||||
|
"""Persist records and return the created resources."""
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryPort(ABC):
|
||||||
|
"""Resolve a pattern / rule fragment by key."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def resolve(self, key: str) -> dict[str, Any]:
|
||||||
|
"""Return the fragment registered under ``key``."""
|
||||||
29
standards/src/templates/app/pipelines/shared/registry.py
Normal file
29
standards/src/templates/app/pipelines/shared/registry.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
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]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register(kind: str, variant: str, root_cls: Type, mode: str = "default") -> Type:
|
||||||
|
"""Register ``root_cls`` for a ``(kind, variant, mode)`` triple.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Type: The registered class (so it can be used as a decorator).
|
||||||
|
"""
|
||||||
|
_FAMILIES.setdefault(kind, {}).setdefault(variant, {})[mode] = root_cls
|
||||||
|
return root_cls
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_root(kind: str, variant: str, mode: str = "default") -> Type:
|
||||||
|
"""Return the root class for a ``(kind, variant, mode)`` triple.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: When no family matches the triple.
|
||||||
|
"""
|
||||||
|
return _FAMILIES[kind][variant][mode]
|
||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"pydantic>=2",
|
"pydantic>=2",
|
||||||
"motor",
|
"motor",
|
||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
|
"pyyaml",
|
||||||
"openapi-first",
|
"openapi-first",
|
||||||
"mongo-ops",
|
"mongo-ops",
|
||||||
"hexa",
|
"hexa",
|
||||||
@@ -29,7 +30,7 @@ dev = [
|
|||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["domain*", "infra*", "interfaces*", "pipelines*"]
|
include = ["domain*", "infra*", "interfaces*", "pipelines*", "scripts*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|||||||
7
standards/src/templates/app/scripts/__init__.py
Normal file
7
standards/src/templates/app/scripts/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Operator scripts — run from the project root as ``python -m scripts.<name>``.
|
||||||
|
|
||||||
|
They use the composition root (:func:`init.init` / :func:`init.shutdown`) so they
|
||||||
|
share the exact wiring the API and worker use.
|
||||||
|
"""
|
||||||
32
standards/src/templates/app/scripts/drop.py
Normal file
32
standards/src/templates/app/scripts/drop.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Operator script — drop the collections owned by each service for a clean slate.
|
||||||
|
|
||||||
|
Run from the project root:
|
||||||
|
|
||||||
|
python -m scripts.drop
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from init import init, shutdown
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Delete every service collection, best-effort, then shut down."""
|
||||||
|
resources = await init()
|
||||||
|
try:
|
||||||
|
dropped = 0
|
||||||
|
for name, svc in resources.items():
|
||||||
|
if not name.endswith("_svc"):
|
||||||
|
continue
|
||||||
|
delete_many = getattr(svc.repo, "delete_many", None)
|
||||||
|
if delete_many is not None:
|
||||||
|
await delete_many({})
|
||||||
|
dropped += 1
|
||||||
|
print(f"Dropped {dropped} service collection(s).")
|
||||||
|
finally:
|
||||||
|
await shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
26
standards/src/templates/app/scripts/seed.py
Normal file
26
standards/src/templates/app/scripts/seed.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""# Summary
|
||||||
|
|
||||||
|
Operator script — seed demo data through the composition root.
|
||||||
|
|
||||||
|
Run from the project root:
|
||||||
|
|
||||||
|
python -m scripts.seed
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from init import init, shutdown
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Seed one demo item and shut down cleanly."""
|
||||||
|
resources = await init()
|
||||||
|
try:
|
||||||
|
item_svc = resources["item_svc"]
|
||||||
|
await item_svc.create({"name": "seed", "quantity": 1})
|
||||||
|
print("Seeded one item.")
|
||||||
|
finally:
|
||||||
|
await shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user