1 line
65 KiB
JSON
1 line
65 KiB
JSON
{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"\ud83e\udde9 hexa \u2014 Type-Declared Dependency Injection for Python","text":"<p><code>hexa</code> is a pattern and utility library for structuring complex hierarchical pipelines. It rests on one core idea: a concrete class's annotated slot type is the dependency decision. Variation is expressed entirely as class annotations \u2014 never as imperative wiring, never as <code>__init__</code> parameters, never as a composition root that assembles objects by hand.</p> <p>Doc model: this wiki is written for humans \u2014 how\u2011to guides, examples, and testing recipes. The authoritative API contracts live in the code (docstrings) and the machine\u2011readable bundle under <code>docs/mcp/</code>.</p>"},{"location":"#key-features","title":"\ud83d\ude80 Key Features","text":"<ul> <li>\ud83e\uddec Three fixed layers \u2014 ABC contracts declare what a stage requires, <code>Impl*</code> classes hold the shared default, bank-like specializations override only what differs</li> <li>\ud83e\udea2 <code>build</code> container \u2014 recursively wires a full pipeline from <code>__annotations__</code> across the MRO; <code>instances=</code> and <code>config=</code> inject runtime values by name</li> <li>\ud83d\udcd0 One neutral model \u2014 every converter serializes through a <code>PortNode</code> tree; the model is not tied to any sample</li> <li>\ud83d\udd01 Interchangeable representations \u2014 <code>parse_yaml</code> / <code>generate_abc</code> / <code>parse_abc</code> / <code>generate_yaml</code> translate between YAML specs and ABC modules</li> <li>\u2705 Machine-checked agreement \u2014 <code>check_matches</code> verifies two representations describe the same tree and prints human-readable diffs</li> <li>\ud83d\udda5\ufe0f Thin CLI \u2014 <code>hexa parse-yaml</code>, <code>parse-abc</code>, <code>generate-abc</code>, <code>generate-yaml</code>, <code>check</code> for CLI workflows</li> <li>\ud83e\uddea Sample-driven tests \u2014 <code>samples/minimal</code> and <code>samples/extraction_pipeline</code> double as live examples for the test suite</li> </ul>"},{"location":"#installation","title":"\ud83d\udce6 Installation","text":"<p>From your internal PyPI:</p> Bash<pre><code>pip install --extra-index-url https://$PYPI_USERNAME:$PYPI_PASSWORD@pip.aetoskia.com/simple hexa\n</code></pre> <p>From local source:</p> Bash<pre><code>pip install -e .\n</code></pre>"},{"location":"#documentation-structure","title":"\ud83d\udcc1 Documentation Structure","text":"Section Description Overview The mental model: three layers, two representations, and the container Core Components <code>PortNode</code>, <code>MethodSpec</code>, converters, <code>check_matches</code>, <code>build</code>, CLI Use cases Step-by-step recipes \u00b7 01 \u2013 YAML first Author a spec, generate ABCs, verify \u00b7 02 \u2013 ABC first Author the ABC module, emit a YAML spec \u00b7 03 \u2013 Wiring & runtime <code>build</code>, <code>instances=</code>, <code>config=</code> at runtime Design The extraction pipeline sample, port tree, and container algorithm Philosophy Why type-declared dependencies beats wiring by hand Best Practices Redundancy rules and the two fidelity guarantees Error Handling What can raise and what it means Testing Zero-mock unit testing against the samples"},{"location":"#related-resources","title":"\ud83d\udd17 Related Resources","text":"<ul> <li>Source Code: Gitea Repository</li> <li>Internal PyPI: pip.aetoskia.com/simple/hexa</li> <li>Drone CI: Auto-builds and publishes tagged releases, gated on black / ruff / mypy / pydoclint / pytest.</li> </ul> <p>\u00a9 Aetoskia Internal \u2014 <code>hexa</code> 0.1.0</p>"},{"location":"01_overview/","title":"Library Overview","text":"<p><code>hexa</code> lets you describe a hierarchical pipeline as a tree of typed contracts, then get a fully wired instance back from plain class annotations. This page builds the mental model: the three layers, the two interchangeable representations, and the container that reads annotations at runtime.</p>"},{"location":"01_overview/#architecture","title":"\ud83c\udfd7\ufe0f Architecture","text":"Text Only<pre><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Representation layer (optional, interchangeable) \u2502\n\u2502 \u2502\n\u2502 YAML spec (.yaml) \u25c4\u2500\u2500 parse_yaml \u2500\u2500\u2510 \u2502\n\u2502 \u2502 \u2502 PortNode tree \u2502\n\u2502 \u2502 generate_abc \u2502 (the single model) \u2502\n\u2502 \u25bc \u25bc \u2502\n\u2502 ABC module (_abc.py) \u25c4\u2500 generate_yaml \u2500\u2510 \u2502\n\u2502 (contracts only: ABC + annotations) \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Runtime layer (annotations are decision)\u25bc \u2502\n\u2502 \u2502\n\u2502 root class \u2500\u2500 build(cls, instances=, config=) \u2500\u2500\u25ba wired \u2502\n\u2502 (e.g. ImplParentClass) instance \u2502\n\u2502 \u2502 __annotations__ merged across MRO \u2502\n\u2502 \u25bc \u2502\n\u2502 recursively: instantiate pinned concrete port, setattr on \u2502\n\u2502 parent, inject runtime instances / config values by name \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n</code></pre> <p>Two layers, deliberately decoupled:</p> <ol> <li>Representation \u2014 the same contract can be written as a YAML spec or as a Python ABC module. hexa's converters translate between them through one neutral <code>PortNode</code> tree. This layer is optional: you may hand-write only the ABC file, only the YAML, or both.</li> <li>Runtime \u2014 <code>build(cls)</code> reads the merged annotations of a root class. Every port slot annotated with a concrete type is the decision; the container instantiates the tree recursively and <code>setattr</code>s each child onto its parent.</li> </ol>"},{"location":"01_overview/#the-three-layers-the-mental-model","title":"\ud83d\udd04 The Three Layers (the mental model)","text":"<p>Whatever you author, the pattern is built from three fixed layers:</p> Layer Example file Role 1. ABC contracts <code>samples/minimal/abc.py</code> Declaration of need \u2014 attributes, port slots, <code>@abstractmethod</code> bodies. Never runs. 2. Generic <code>Impl*</code> / concrete defaults <code>samples/minimal/impl.py</code> Shared behavior \u2014 subclasses the ABC, implements every <code>@abstractmethod</code>, re-pins slots to concrete types. 3. Specialization (optional) <code>samples/extraction_pipeline/banks/{axis,icici}/pdf.py</code> Variation \u2014 overrides only the members that differ from <code>Impl*</code>. <p>The root class you hand to <code>build</code> is always a concrete type (layer 2 or 3). The ABC (layer 1) is only a contract \u2014 it is never instantiated.</p>"},{"location":"01_overview/#lifecycle-rules","title":"\ud83d\udd04 Lifecycle Rules","text":"Step Call Why 1 Author the contract As YAML (<code>.yaml</code>) or ABC (<code>_abc.py</code>) \u2014 or both, and let <code>check_matches</code> prove they agree. 2 Generate one from the other (optional) <code>generate_abc(spec.yaml)</code> when you want ABCs from YAML; <code>generate_yaml(abc.py)</code> for the reverse. 3 Write concrete implementations (<code>Impl*</code>) Subclass the ABC, implement abstracts, pin each slot to a concrete port type. 4 Wire <code>build(ImplRoot, instances={\"repo\": repo}, config={\"retries\": 3})</code>. 5 Verify (optional) <code>check_matches(spec.yaml, abc.py)</code> returns <code>True</code> or a list of difference strings. <p>Steps 1\u20133 choose what the pipeline needs; step 4 is automatic. Variation between pipelines happens purely by which root class you build.</p>"},{"location":"01_overview/#which-entry-point-should-you-use","title":"\ud83e\udded Which Entry Point Should You Use?","text":"Entry point Use when Adds <code>parse_yaml(path)</code> You author specs in YAML YAML \u2192 <code>PortNode</code> tree (structural validation only) <code>generate_abc(node)</code> You want ABC source from a spec YAML model \u2192 Python ABC module source <code>parse_abc(mod)</code> You author contracts in Python ABC module \u2192 <code>PortNode</code> tree (reflection) <code>generate_yaml(node)</code> You want a spec for your ABCs Python ABC module \u2192 YAML source <code>check_matches(a, b)</code> You keep both representations Verifies the two trees agree; prints diffs <code>build(cls, ...)</code> You want a wired runtime instance Recursively instantiates the port tree from annotations"},{"location":"01_overview/#read-next","title":"\u27a1\ufe0f Read Next","text":"<ul> <li>Core Components \u2014 the public API surface in detail.</li> <li>Use case 01 \u2013 YAML first \u2014 the minimal spec-to-ABC workflow.</li> <li>Philosophy \u2014 the why behind type-declared dependencies.</li> </ul>"},{"location":"02_components/","title":"Core Components","text":"<p>A validated reference to the public API of <code>hexa</code> (package root exports from <code>hexa/__init__.py</code>). Signatures match the code exactly. For runnable recipes see the Use Cases. Exact parameter contracts live in the API reference under <code>docs/lib/</code> \u2014 this page is usage-level.</p>"},{"location":"02_components/#1-the-shared-model-portnode-and-methodspec","title":"1. The Shared Model \u2014 <code>PortNode</code> and <code>MethodSpec</code>","text":"<p>Every parser and generator serializes through one neutral tree. It is not tied to any sample \u2014 any port tree fits.</p>"},{"location":"02_components/#11-portnode","title":"1.1 <code>PortNode</code>","text":"<p>A node in a hexa port tree. Three kinds:</p> Kind Meaning Example <code>parent</code> The root of the tree <code>ExtractionPipeline</code> <code>port</code> Has nested port slots (children) <code>TransactionParserPort</code> <code>leaf</code> No children <code>NumberPort</code> <p>Fields:</p> Field Type Meaning <code>name</code> <code>str</code> Slot name, e.g. <code>\"parser\"</code>, <code>\"amount_balance\"</code> <code>port_cls</code> <code>str</code> ABC class name that gives this port its shape <code>kind</code> <code>str</code> <code>\"leaf\"</code> | <code>\"port\"</code> | <code>\"parent\"</code> <code>attributes</code> <code>dict[str, ConfigValue]</code> <code>name \u2192 type_expr</code> for scalars, or a nested dict of sub-attributes for grouped values <code>children</code> <code>list[PortNode]</code> Nested port slots <code>methods</code> <code>dict[str, MethodSpec]</code> Abstract methods keyed by method name <p>Tree helpers (used internally and handy for inspection):</p> <ul> <li><code>node.find(name) -> PortNode | None</code> \u2014 direct child by slot name.</li> <li><code>node.walk() -> list[PortNode]</code> \u2014 all nodes, depth-first, self first.</li> <li><code>node.diff(other, path=\"\") -> list[str]</code> \u2014 human-readable difference strings; empty list means equal.</li> </ul>"},{"location":"02_components/#12-methodspec","title":"1.2 <code>MethodSpec</code>","text":"<p>Specification of a single abstract method: <code>name</code>, positional <code>args: list[str]</code>, and keyword-only <code>kwargs: dict[str, str]</code> (name \u2192 type expression). Supports equality (<code>==</code>/<code>!=</code>).</p>"},{"location":"02_components/#13-configvalue","title":"1.3 <code>ConfigValue</code>","text":"<p><code>str | NestedConfig</code>, where <code>NestedConfig = dict[str, ConfigValue]</code>. An attribute is either a scalar type expression (e.g. <code>\"str\"</code>) or a nested structure of sub-attributes. Nesting is recursive.</p>"},{"location":"02_components/#2-converters-yaml-abc","title":"2. Converters \u2014 YAML \u21c4 ABC","text":"<p>All converters are pure (they operate on the model) and optional: you never have to touch them if you hand-write a single representation.</p>"},{"location":"02_components/#21-parse_yamlpath-portnode","title":"2.1 <code>parse_yaml(path) -> PortNode</code>","text":"<p>Parses a hexa YAML spec into a <code>PortNode</code> tree. The grammar uses three member kinds:</p> <ul> <li>attribute \u2014 <code>name: type</code> scalar (e.g. <code>source_type: str</code>)</li> <li>nested struct \u2014 <code>name:</code> mapping of sub-attributes (e.g. <code>source:</code>)</li> <li>port \u2014 <code>name:</code> mapping whose single key is a class whose value is a body (e.g. <code>ingest: IngestPort: ...</code>)</li> </ul> <p>Disambiguation rule: a mapping with exactly one key whose value is a non-scalar body is a port; any other mapping is a nested struct of attributes. Raises <code>ValueError</code> on structural errors (see Error Handling).</p>"},{"location":"02_components/#22-generate_abcnode-out_pathnone-str","title":"2.2 <code>generate_abc(node, out_path=None) -> str</code>","text":"<p>Generates <code>_abc.py</code> source from a <code>PortNode</code> tree. Emits <code>class X(ABC)</code> per node, synthesized <code>@dataclass</code>es for nested structs, port-slot annotations, and <code>@abstractmethod</code> stubs. Deterministic (walk order) so output is idempotent. When <code>out_path</code> is given the source is also written there; the source string is always returned.</p>"},{"location":"02_components/#23-parse_abcmodule_or_path-portnode","title":"2.3 <code>parse_abc(module_or_path) -> PortNode</code>","text":"<p>Reflects an ABC Python module into a <code>PortNode</code> tree. Accepts a <code>.py</code> file path, a module import string, or an already-loaded module. ABC classes become nodes; a <code>@dataclass</code> annotation on a port expands into a nested <code>attributes</code> entry. The root is auto-detected (the class not referenced as any other slot, preferring names containing <code>Pipeline</code>/<code>Root</code>/<code>Parent</code>).</p>"},{"location":"02_components/#24-generate_yamlnode-out_pathnone-str","title":"2.4 <code>generate_yaml(node, out_path=None) -> str</code>","text":"<p>Generates a YAML spec (nested mapping form) from a <code>PortNode</code> tree \u2014 the reverse of <code>generate_abc</code>. A deferred/empty slot renders as <code>slot: Class: ...</code>.</p>"},{"location":"02_components/#3-verification-check_matchesa-b","title":"3. Verification \u2014 <code>check_matches(a, b)</code>","text":"<p><code>check_matches(path_a, path_b) -> bool | list[str]</code> parses two representations (one <code>.yaml</code>/<code>.yml</code> and one <code>.py</code>, either direction) and compares the trees with <code>PortNode.diff</code>. Returns <code>True</code> when they describe the same tree, otherwise a list of human-readable difference strings. Inequality checks cover <code>port_cls</code>, <code>kind</code>, attributes (including nested structs), methods (args/kwargs), and children.</p>"},{"location":"02_components/#4-container-build","title":"4. Container \u2014 <code>build</code>","text":"<p><code>build(cls, *, instances=None, config=None) -> T</code> recursively instantiates the port tree implied by a root class's annotations.</p> <p>Algorithm:</p> <ol> <li>Merge <code>__annotations__</code> across the MRO (reverse order) so inherited slot annotations are visible.</li> <li>Instantiate the parent class (<code>cls()</code>; if that raises <code>TypeError</code>, retry passing <code>None</code> for every <code>__init__</code> parameter after <code>self</code>).</li> <li>For each annotation:</li> <li>A name in <code>instances</code> wins \u2014 the provided object is <code>setattr</code>ed verbatim, never reconstructed.</li> <li>Builtin / <code>typing</code> / PEP 604-union types are config fields \u2014 applied from <code>config</code> by name.</li> <li>Abstract classes are skipped (but take a <code>config</code> value when provided).</li> <li>Any other concrete type is a port slot \u2014 <code>build</code> recurses and the child is <code>setattr</code>ed onto the parent.</li> <li>Config defaults: any annotated attribute still unset is defaulted to its <code>config</code> value (or <code>None</code>) so methods can safely reference it.</li> </ol> <p>Runtime values (repos, handlers, clients) go in <code>instances=</code>; plain config values go in <code>config=</code>. Both propagate by name across the whole tree.</p>"},{"location":"02_components/#5-cli","title":"5. CLI","text":"Command Behavior <code>hexa parse-yaml PATH</code> Print the parsed <code>PortNode</code> tree. <code>hexa generate-abc PATH [--out FILE]</code> Emit ABC source from a YAML spec. <code>hexa parse-abc PATH</code> Print the tree reflected from an ABC module. <code>hexa generate-yaml PATH [--out FILE]</code> Emit a YAML spec from an ABC module. <code>hexa check PATH_A PATH_B</code> Print <code>Matches.</code>/<code>Differences found:</code> and exit 0/1. <p>Errors print <code>Error: <message></code> to stderr and exit 1.</p>"},{"location":"02_components/#related","title":"Related","text":"<ul> <li>01 \u2013 YAML first \u00b7 03 \u2013 Wiring & runtime \u00b7 Overview</li> </ul>"},{"location":"04_design/","title":"Design \u2014 Extraction Pipeline (Axis / Icici)","text":"<p>Note: The extraction pipeline discussed below is a sample/example illustrating the hexa composition pattern.</p> <p>This document is the concrete counterpart to Philosophy: where the files are, what the tree looks like for both banks, and how a future container instantiates it. Self-sufficient for a fresh agent.</p>"},{"location":"04_design/#file-map-samplesextraction_pipeline","title":"File map (<code>samples/extraction_pipeline/</code>)","text":"File Layer Role <code>extraction_pipeline_abc.py</code> ABC contracts All port ABCs + <code>ExtractionPipeline</code> root. Declares attributes, the <code>ExtractionSource</code> dataclass, port slots, <code>@abstractmethod</code>s. <code>extraction_pipeline_impl.py</code> Generic <code>Impl*</code> Concrete default implementations of every port; <code>ImplExtractionPipeline</code> root; <code>clean_num</code> helper. <code>extraction_pipeline_ambiguity.py</code> Shared logic Ported <code>ResolutionContext</code>, <code>AmbiguityHandler</code>, <code>AmountBalanceNotFound</code>, <code>validate_signs</code> \u2014 used by <code>ImplAmountBalancePort</code>. <code>extraction_pipeline_banks.py</code> Composition root <code>AxisExtractionPipeline(ImplExtractionPipeline)</code> + <code>IciciExtractionPipeline</code> (each re-pins <code>txn_dicts</code>). <code>banks/axis/pdf.py</code> Bank specialization <code>AxisNumberPort</code>, <code>AxisDescPort</code>, <code>AxisAmountBalancePort</code>, <code>AxisTransactionParserPort</code>, <code>AxisTxnDictsPort</code>. <code>banks/icici/pdf.py</code> Bank specialization <code>Icici*</code> mirror of the above + Icici-only <code>adjust_balance</code>/<code>missing_number_candidates</code>. <code>extraction_pipeline.yaml</code> Contract spec Declarative spec of the port tree; interchangeable with <code>_abc.py</code> via the optional utilities."},{"location":"04_design/#the-port-tree-abc-contract","title":"The port tree (ABC contract)","text":"Text Only<pre><code>ExtractionPipeline\n\u251c\u2500\u2500 source / pipeline / trust_fallback [attributes; source is a nested struct]\n\u251c\u2500\u2500 ingest \u2192 IngestPort (source \u2192 content)\n\u251c\u2500\u2500 raw_lines \u2192 RawLinesPort (content \u2192 raw_lines)\n\u251c\u2500\u2500 txn_blocks \u2192 TxnBlocksPort (raw_lines \u2192 txn_blocks)\n\u2502 \u251c\u2500\u2500 bucket_finder \u2192 BucketFinderPort\n\u2502 \u2514\u2500\u2500 splitter \u2192 SplitterPort\n\u251c\u2500\u2500 txn_dicts \u2192 TxnDictsPort (txn_blocks \u2192 txn_dicts)\n\u2502 \u2514\u2500\u2500 parser \u2192 TransactionParserPort\n\u2502 \u251c\u2500\u2500 number \u2192 NumberPort\n\u2502 \u251c\u2500\u2500 date \u2192 DatePort\n\u2502 \u251c\u2500\u2500 desc \u2192 DescPort\n\u2502 \u2514\u2500\u2500 amount_balance \u2192 AmountBalancePort\n\u2502 \u251c\u2500\u2500 number \u2192 NumberPort\n\u2502 \u2514\u2500\u2500 ambiguity \u2192 AmbiguityPort\n\u2514\u2500\u2500 raw_expense \u2192 RawExpensePort (txn_dicts \u2192 list[RawExpense], terminal)\n</code></pre>"},{"location":"04_design/#generic-default-impl","title":"Generic default (<code>Impl*</code>)","text":"<p><code>ImplExtractionPipeline</code> pins every stage slot to a concrete <code>Impl*</code> port:</p> Text Only<pre><code>ImplExtractionPipeline\n\u251c\u2500\u2500 ingest: ImplIngestPort\n\u251c\u2500\u2500 raw_lines: ImplRawLinesPort\n\u251c\u2500\u2500 txn_blocks: ImplTxnBlocksPort\n\u2502 \u251c\u2500\u2500 bucket_finder: ImplBucketFinderPort\n\u2502 \u2514\u2500\u2500 splitter: ImplSplitterPort\n\u251c\u2500\u2500 txn_dicts: ImplTxnDictsPort\n\u2502 \u2514\u2500\u2500 parser: ImplTransactionParserPort\n\u2502 \u251c\u2500\u2500 number: ImplNumberPort\n\u2502 \u251c\u2500\u2500 date: ImplDatePort\n\u2502 \u251c\u2500\u2500 desc: ImplDescPort\n\u2502 \u2514\u2500\u2500 amount_balance: ImplAmountBalancePort\n\u2502 \u251c\u2500\u2500 number: ImplNumberPort\n\u2502 \u2514\u2500\u2500 ambiguity: ImplAmbiguityPort\n\u2514\u2500\u2500 raw_expense: ImplRawExpensePort\n</code></pre>"},{"location":"04_design/#bank-variation-only-what-differs","title":"Bank variation (only what differs)","text":"<p>The root class re-pins only <code>txn_dicts</code>. Everything else inherits <code>Impl*</code>.</p>"},{"location":"04_design/#axis-banksaxispdfpy","title":"Axis (<code>banks/axis/pdf.py</code>)","text":"Python<pre><code>class AxisExtractionPipeline(ImplExtractionPipeline):\n txn_dicts: AxisTxnDictsPort\n</code></pre> Python<pre><code>AxisTxnDictsPort(ImplTxnDictsPort)\n \u2514\u2500 parser: AxisTransactionParserPort(ImplTransactionParserPort)\n \u251c\u2500 number: AxisNumberPort # NUMBER_RE, all clean numbers\n \u251c\u2500 desc: AxisDescPort # STARTTERS = [upi/, imps/, neft-, neft/, ach/, ach-, 2a/]\n \u2514\u2500 amount_balance: AxisAmountBalancePort(ImplAmountBalancePort)\n \u2514\u2500 number: AxisNumberPort # re-pin back to axis number port\n</code></pre> <p>Notes:</p> <ul> <li><code>date</code> is not redeclared on <code>AxisTransactionParserPort</code> \u2014 it inherits <code>ImplDatePort</code> (correct already, adds no behavior).</li> <li><code>amount_balance.ambiguity</code> is not redeclared \u2014 inherits <code>ImplAmbiguityPort</code>.</li> </ul>"},{"location":"04_design/#icici-banksicicipdfpy","title":"Icici (<code>banks/icici/pdf.py</code>)","text":"Python<pre><code>class IciciExtractionPipeline(ImplExtractionPipeline):\n txn_dicts: IciciTxnDictsPort\n</code></pre> Python<pre><code>IciciTxnDictsPort(ImplTxnDictsPort)\n \u2514\u2500 parser: IciciTransactionParserPort(ImplTransactionParserPort)\n \u251c\u2500 number: IciciNumberPort # NUMBER_RE, returns decimals[-2:] (last 2)\n \u251c\u2500 desc: IciciDescPort # STARTTERS + fix_ocr (UPl\u2192UPI, ..., ICIC]\u2192ICICI)\n \u2514\u2500 amount_balance: IciciAmountBalancePort(ImplAmountBalancePort)\n \u251c\u2500 number: IciciNumberPort # re-pin to icici number port\n \u251c\u2500 adjust_balance: # neg-markers [(-), (-}, {-}, {-) , ()] \u2192 -abs(balance)\n \u2514\u2500 missing_number_candidates: # Icici-only one-number hook\n</code></pre>"},{"location":"04_design/#redundancy-rule-applied-in-both-banks","title":"Redundancy rule (applied in both banks)","text":"<p>Only redeclare a slot you are actually changing from <code>Impl*</code>. If a slot's behavior is already correct from the parent <code>Impl*</code>, omit it. This keeps the diff between a bank and <code>Impl*</code> minimal and unambiguous.</p> <p><code>date: ImplDatePort</code> was previously redeclared redundantly and has been removed \u2014 it added mental load on the extender for zero behavior change. (Fidelity audit: it is still concrete <code>ImplDatePort</code> in <code>ImplTransactionParserPort</code>, so behavior is unchanged.)</p>"},{"location":"04_design/#container-buildcls-in-hexacontainerpy","title":"Container (<code>build(cls)</code> in <code>hexa/container.py</code>)","text":"<p>The annotations are metadata; a small reflection builder turns a root class into a wired instance:</p> Text Only<pre><code>build(AxisExtractionPipeline) \u2192 wired AxisExtractionPipeline\n</code></pre> <p>Algorithm (<code>build(cls)</code>):</p> <ol> <li>Walk <code>cls.__annotations__</code>, merged across the MRO so inherited slot annotations are visible (a bank subclass only adds/overrides a slot; the rest come from <code>Impl*</code>).</li> <li>For each port slot whose annotated type is a concrete <code>Impl*</code>/bank class (not an ABC, not a builtin), instantiate it.</li> <li>Recurse into that child \u2014 repeat until leaves (classes that declare no further port slots).</li> <li><code>setattr(parent, slot, child)</code> to wire each slot onto the parent instance.</li> <li>Apply config defaults from the class-level annotated attributes.</li> </ol> <p>Selection is fully static: the annotated type is the chosen implementation. The container holds no per-bank branch logic \u2014 <code>Axis</code> vs <code>Icici</code> is decided purely by which root class you call <code>build</code> on.</p>"},{"location":"04_design/#control-flow-at-runtime-target","title":"Control flow at runtime (target)","text":"<p>A leaf-stage port exposes one entry method; stages chain data through the shared <code>run(source)</code> signature on the root:</p> Text Only<pre><code>run(source)\n ingest.materialize(source) \u2192 content\n raw_lines.extract(content) \u2192 raw_lines\n txn_blocks.extract(raw_lines) \u2192 {opening_balance: [blocks]}\n txn_dicts.extract(txn_blocks) \u2192 [txn dicts]\n raw_expense.build(txn_dicts) \u2192 [RawExpense] (terminal)\n</code></pre> <p>The stage ports map naturally onto dagpipe <code>Node</code>s (<code>ingest</code>, <code>raw_lines</code>, <code>txn_blocks</code>, <code>txn_dicts</code>, <code>raw_expense</code>), each node's <code>resolve</code> delegating to its wired port subtree, connected as a dagpipe <code>Graph</code> and run via <code>Engine</code>. That merge is a separate, future step.</p>"},{"location":"04_design/#utilities-optional-hexa-agnostic","title":"Utilities (optional, hexa-agnostic)","text":"<p>The hexa utilities are implemented in <code>hexa/</code> (the package root) and they are agnostic of the extraction pipeline sample. It ships a set of optional code-gen / validation utilities so users can choose their own workflow \u2014 they are never required. A user is free to:</p> <ul> <li>hand-write an ABC file and never touch YAML, or</li> <li>start from YAML and generate the ABC file, or</li> <li>start from an ABC file and emit YAML for it, or</li> <li>use the ABC/YAML only as a spec (neither generated from the other).</li> </ul> <p>The utilities only ever translate between two interchangeable representations of the same contract \u2014 the <code>.yaml</code> spec and the <code>_abc.py</code> module. They never generate or manage the <code>Impl*</code>/bank files (those carry behavior and slot choice, which is a human decision).</p>"},{"location":"04_design/#shared-model","title":"Shared model","text":"<p>All utilities serialize through one neutral model, a <code>PortNode</code> tree. It is NOT tied to the extraction sample \u2014 any port tree fits.</p> Python<pre><code>@dataclass\nclass PortNode:\n name: str # slot name, e.g. \"parser\", \"amount_balance\"\n kind: str # \"leaf\" | \"port\" | \"parent\"\n port_cls: str # ABC class name, e.g. \"TransactionParserPort\"\n attributes: dict[str, ConfigValue] # annotated attributes: name -> type expr,\n # or a nested struct dict for grouped values\n children: list[\"PortNode\"]\n methods: dict[str, MethodSpec] # abstract methods\n</code></pre>"},{"location":"04_design/#1-parse_yamlpath-portnode","title":"1. <code>parse_yaml(path) -> PortNode</code>","text":"<p>Read a YAML spec into the <code>PortNode</code> tree (YAML \u2192 model).</p> <ul> <li>Parses completely (structural validation only, no type-checking).</li> <li>Supports the sample grammar (see <code>extraction_pipeline.yaml</code>): a root class whose inline attributes (scalars and nested structs), port slots, and reserved <code>methods:</code> recurse into child ports.</li> <li>Raises <code>ValueError</code> with file/line on structural errors.</li> </ul>"},{"location":"04_design/#2-generate_abcpath_yaml-out_pathnone-str","title":"2. <code>generate_abc(path_yaml, out_path=None) -> str</code>","text":"<p>Generate <code>_abc.py</code> source from a YAML spec (YAML \u2192 ABC).</p> <ul> <li><code>parse_yaml(...)</code> \u2192 write one <code>class <PortCls>(ABC)</code> per <code>PortNode</code> with attribute annotations (nested structs become synthesized <code>@dataclass</code>es), port-slot annotations, and <code>@abstractmethod</code> stubs, matching the style of the hand-written <code>extraction_pipeline_abc.py</code>.</li> <li>Deterministic (YAML-order) output so it is idempotent / diff-friendly.</li> <li><code>out_path</code> optional; always returns the generated source.</li> </ul>"},{"location":"04_design/#3-parse_abcmodule_or_path-portnode","title":"3. <code>parse_abc(module_or_path) -> PortNode</code>","text":"<p>Reflect over an ABC module into the <code>PortNode</code> tree (ABC \u2192 model).</p> <ul> <li>Reads <code>__annotations__</code> / <code>@abstractmethod</code> from generic ABC classes (no sample assumptions) to reconstruct the same <code>PortNode</code> tree.</li> </ul>"},{"location":"04_design/#4-generate_yamlmodule_or_path-out_pathnone-str","title":"4. <code>generate_yaml(module_or_path, out_path=None) -> str</code>","text":"<p>Generate a <code>.yaml</code> spec from an ABC module (ABC \u2192 YAML).</p> <ul> <li><code>parse_abc(...)</code> \u2192 write the reverse of <code>generate_abc</code>.</li> <li>Lets a user author the ABC file first and emit YAML for it.</li> </ul>"},{"location":"04_design/#5-check_matchespath_a-path_b-bool","title":"5. <code>check_matches(path_a, path_b) -> bool</code>","text":"<p>Verify two representations agree \u2014 YAML vs ABC, either direction.</p> <ul> <li>Builds both <code>PortNode</code> models (<code>parse_yaml</code> + <code>parse_abc</code>) and compares: classes exist and are <code>ABC</code>s, attributes match, port slots match, <code>@abstractmethod</code> signatures match (missing keyword-only params treated conservatively).</li> <li>Returns <code>True</code> only if the whole tree matches; otherwise <code>False</code> (or a diff).</li> </ul>"},{"location":"04_design/#workflows-users-choice","title":"Workflows (user's choice)","text":"Text Only<pre><code># YAML-first\nedit spec.yaml\n \u2192 generate_abc(spec.yaml, abc.py) # regenerate ABCs\n \u2192 hand-write/update Impl* + banks\n \u2192 check_matches(spec.yaml, abc.py) == True # guard\n\n# ABC-first\nedit abc.py\n \u2192 generate_yaml(abc.py, spec.yaml) # emit YAML spec\n \u2192 check_matches(spec.yaml, abc.py) == True\n\n# hand-written both (no utilities at all)\nedit abc.py + spec.yaml independently # optional check_matches as a guard\n</code></pre> <p><code>Impl*</code>/bank files are always hand-extended on top and are never generated.</p>"},{"location":"04_design/#related","title":"Related","text":"<ul> <li>05 \u2013 Philosophy \u00b7 03 \u2013 Wiring & runtime \u00b7 Overview</li> </ul>"},{"location":"05_philosophy/","title":"Philosophy \u2014 Why Type-Declared Dependencies","text":"<p>This document explains the why behind the type-declared dependency pattern used for the extraction pipeline sample \u2014 specifically the Axis/Icici bank variation. It is, together with Design, self-sufficient: a fresh agent should be able to pick up the codebase from here and reason about (and extend) the pattern without needing the original session context.</p>"},{"location":"05_philosophy/#one-idea","title":"One Idea","text":"<p>A concrete class's annotated slot type is the dependency decision.</p> <p>Bank variation is expressed entirely as class annotations \u2014 never as imperative wiring, never as <code>__init__</code> parameters, never as a composition-root that assembles objects by hand. The tree of ports a pipeline needs is declared once, and a bank differs from the generic pipeline only by which concrete types are pinned onto which slots.</p>"},{"location":"05_philosophy/#the-three-layers","title":"The Three Layers","text":"<p>The pattern is built from three fixed layers. Understanding which layer something belongs to is the whole mental model.</p>"},{"location":"05_philosophy/#layer-1-abc-contracts-samplesextraction_pipelineextraction_pipeline_abcpy","title":"Layer 1 \u2014 ABC contracts (<code>samples/extraction_pipeline/extraction_pipeline_abc.py</code>)","text":"<p>Authoritative declaration of need (\"WHAT a stage requires\").</p> <ul> <li>Each port is an <code>ABC</code>.</li> <li>It declares attributes as class-level annotated defaults (e.g. <code>TransactionParserPort.min_numbers: int = 3</code>), optionally grouped as a nested struct via a <code>@dataclass</code> (e.g. <code>source: ExtractionSource</code>).</li> <li>It declares port slots as annotated attributes whose type is another ABC (e.g. <code>TransactionParserPort.number: NumberPort</code>, <code>amount_balance: AmountBalancePort</code>).</li> <li>It declares <code>@abstractmethod</code> bodies \u2014 the method signatures each concrete implementation must provide.</li> </ul> <p>The ABC never runs, never holds an instance, and never says which implementation to use. It only says what the shape is.</p>"},{"location":"05_philosophy/#layer-2-generic-concurrent-implementations-samplesextraction_pipelineextraction_pipeline_implpy","title":"Layer 2 \u2014 Generic concurrent implementations (<code>samples/extraction_pipeline/extraction_pipeline_impl.py</code>)","text":"<p>The common default behavior (\"Impl* \u2014 the shared adaptor\").</p> <ul> <li><code>class ImplTransactionParserPort(TransactionParserPort)</code> subclasses the ABC, implements every <code>@abstractmethod</code>, and re-pins its port slots to concrete <code>Impl*</code> types: <code>number: ImplNumberPort</code>, <code>desc: ImplDescPort</code>, <code>amount_balance: ImplAmountBalancePort</code>.</li> <li><code>ImplAmountBalancePort</code> in turn pins <code>number: ImplNumberPort</code> and <code>ambiguity: ImplAmbiguityPort</code>.</li> <li>The root, <code>ImplExtractionPipeline</code>, pins its five stage slots to <code>Impl*</code> ports: <code>ingest</code>, <code>raw_lines</code>, <code>txn_blocks</code>, <code>txn_dicts</code>, <code>raw_expense</code>.</li> </ul> <p><code>Impl*</code> is the default that shared, bank-agnostic behavior lives in. Any slot a bank does not override falls back to <code>Impl*</code> at runtime.</p>"},{"location":"05_philosophy/#layer-3-bank-specializations-samplesextraction_pipelinebanksaxisicicipdfpy-samplesextraction_pipelineextraction_pipeline_bankspy","title":"Layer 3 \u2014 Bank specializations (<code>samples/extraction_pipeline/banks/{axis,icici}/pdf.py</code>, <code>samples/extraction_pipeline/extraction_pipeline_banks.py</code>)","text":"<p>The variation (\"what differs per bank\").</p> <ul> <li>Bank classes subclass the concrete <code>Impl*</code> classes, never the ABC \u2014 they extend, they never reinvent the contract.</li> <li>Each <code>Axis*</code>/<code>Icici*</code> class overrides only the members that differ from the generic <code>Impl*</code>; everything shared is inherited.</li> <li>A bank root is a thin annotated subclass of <code>ImplExtractionPipeline</code> that re-pins only the slots that differ:</li> </ul> Python<pre><code>class AxisExtractionPipeline(ImplExtractionPipeline):\n txn_dicts: AxisTxnDictsPort\n</code></pre>"},{"location":"05_philosophy/#the-re-pinning-cascade","title":"The Re-Pinning Cascade","text":"<p>Bank-specific wiring is hierarchical and cascading. Each level narrows exactly one slot, and its narrowed type drags the next level's narrowing along with it:</p> Text Only<pre><code>AxisExtractionPipeline\n \u2514\u2500 txn_dicts: AxisTxnDictsPort (only override on the root)\n \u2514\u2500 parser: AxisTransactionParserPort\n \u251c\u2500 number: AxisNumberPort (get_numbers \u2192 all clean nums)\n \u251c\u2500 desc: AxisDescPort (STARTTERS)\n \u2514\u2500 amount_balance: AxisAmountBalancePort\n \u2514\u2500 number: AxisNumberPort (re-pin: same bank's number port)\n</code></pre> <p>Siblings that are already correct in <code>Impl*</code> are inherited, not redeclared. E.g. the parser's <code>date</code> slot comes from <code>ImplTransactionParserPort</code>; the amount-balance port's <code>ambiguity</code> comes from <code>ImplAmountBalancePort</code>. Re-declaring them adds mental load on the extender with zero behavior change.</p> <p>Rule \u2014 only redeclare the slot you are actually changing. If a slot's behavior is already correct from <code>Impl*</code>, leave it out.</p>"},{"location":"05_philosophy/#the-two-fidelity-guarantees-this-pattern-preserves","title":"The Two Fidelity Guarantees This Pattern Preserves","text":"<ol> <li>Shared behavior stays shared. Slots like <code>ingest</code>, <code>raw_lines</code>, <code>txn_blocks</code>, <code>raw_expense</code>, parser <code>date</code>, and <code>ambiguity</code> are bank-agnostic. Leaving them at the <code>Impl*</code> default is deliberate, not an omission.</li> <li>Banks never reinvent. <code>Axis*</code>/<code>Icici*</code> only extend <code>Impl*</code>. The ABC contract in <code>samples/extraction_pipeline/extraction_pipeline_abc.py</code> is defined once and shared by every bank.</li> </ol>"},{"location":"05_philosophy/#what-this-pattern-is-not","title":"What This Pattern Is NOT","text":"<ul> <li>No imperative assembly. No <code>build_axis_pipeline()</code> wiring objects by hand.</li> <li>No constructor injection. <code>Impl*</code> classes have no <code>__init__</code>; wiring is done by assigning concrete port instances onto annotated slots.</li> <li>YAML and ABCs are interchangeable, and both optional. The <code>.yaml</code> spec and the <code>_abc.py</code> module are two representations of the same contract. hexa is agnostic of the extraction pipeline sample and never imposes a workflow: a user can hand-write the ABC file, start from YAML (ABC generated), start from an ABC file (YAML emitted), or use both as a spec. The optional utilities in Design \u00a7 Utilities translate between them and can verify they agree \u2014 but the bank <code>Impl*</code>/specialization files are always hand-written (their slots are a manual choice) and never generated.</li> </ul>"},{"location":"05_philosophy/#runtime-instantiation","title":"Runtime Instantiation","text":"<p>The annotations are the decision; <code>hexa.build(cls)</code> (see Design \u00a7 Container) turns a root class into a wired instance by reading <code>__annotations__</code> + MRO, recursively instantiating each pinned concrete type, and <code>setattr</code>ing the child onto the parent slot. This document is scoped to the notation \u2014 the rest of the tree can be expressed purely as class annotations and filled at runtime by the container.</p>"},{"location":"05_philosophy/#related","title":"Related","text":"<ul> <li>04 \u2013 Design \u00b7 02 \u2013 Core Components \u00b7 Overview</li> </ul>"},{"location":"06_best_practices/","title":"Best Practices","text":"<p>Rules of thumb for structuring hexa pipelines that stay easy to extend and verify. Grounded in the extraction pipeline sample and the test suite.</p>"},{"location":"06_best_practices/#layer-discipline","title":"\ud83c\udf33 Layer Discipline","text":"<ul> <li>Never subclass an ABC for a bank. Bank classes always subclass the concrete <code>Impl*</code> classes. The ABC defines the contract once; <code>Impl*</code> carries behavior; banks only vary.</li> <li>Pin concrete types at <code>Impl*</code>, not at the ABC. The ABC declares shape (<code>number: NumberPort</code>); <code>Impl*</code> decides implementation (<code>number: ImplNumberPort</code>).</li> <li>Keep the root thin. A bank root is one annotated subclass: Python<pre><code>class AxisExtractionPipeline(ImplExtractionPipeline):\n txn_dicts: AxisTxnDictsPort\n</code></pre></li> </ul>"},{"location":"06_best_practices/#only-redeclare-what-you-change","title":"\ud83d\udc63 Only Redeclare What You Change","text":"<p>The redundancy rule: if a slot's behavior is already correct from <code>Impl*</code>, leave it out.</p> <ul> <li><code>date</code> is not redeclared on <code>AxisTransactionParserPort</code> \u2014 it inherits <code>ImplDatePort</code>.</li> <li><code>ambiguity</code> is not redeclared on <code>AxisAmountBalancePort</code> \u2014 it inherits <code>ImplAmbiguityPort</code>.</li> <li>Re-declaring slots you don't change adds mental load on the extender with zero behavior change \u2014 and keeps the diff between a bank and <code>Impl*</code> minimal.</li> </ul> <p>If you do redeclare a slot, re-pin it back to your own bank's port (e.g. <code>amount_balance.number: AxisNumberPort</code>) so a single bank's number formatting is consistent down the subtree.</p>"},{"location":"06_best_practices/#representational-fidelity","title":"\ud83d\udcc4 Representational Fidelity","text":"<p>Keep YAML and ABC in agreement:</p> <ul> <li>Every converter (<code>parse_yaml</code>, <code>generate_abc</code>, <code>parse_abc</code>, <code>generate_yaml</code>) serializes through the same <code>PortNode</code> tree \u2014 any tree fits, nothing is tied to a particular sample.</li> <li>Use <code>check_matches(spec.yaml, abc.py)</code> (or <code>hexa check ...</code>) as a CI guard whenever both representations are maintained.</li> <li>Class-level attributes on ABCs are used as documentation of the config surface; pin defaults at the <code>Impl*</code> level so the ABC stays a pure contract.</li> <li>Nested grouped values are expressed once per representation: a <code>@dataclass</code> annotation (ABC) \u21c4 a nested struct mapping (YAML).</li> </ul>"},{"location":"06_best_practices/#wiring-discipline","title":"\ud83e\udde9 Wiring Discipline","text":"<ul> <li>Put plain runtime objects that must not be re-created (repos, handlers, clients) in <code>instances=</code> \u2014 wired verbatim by name, never reconstructed.</li> <li>Put plain config values in <code>config=</code> \u2014 propagated by name across the whole tree, including nested ports.</li> <li>Name a slot whatever the annotated field is called; <code>build</code> matches <code>instances</code>/<code>config</code> by that annotated name, so names must agree across the tree.</li> <li>Keep port slot annotations to concrete <code>Impl*</code>/bank classes (or ABCs when the leaf is a config-only option). Builtin/<code>typing</code>/union-annotated fields are treated as config, not ports \u2014 so don't annotate a port with <code># type: str</code>.</li> </ul>"},{"location":"06_best_practices/#test-layout","title":"\ud83e\uddea Test Layout","text":"<p>Mirror <code>tests/</code>:</p> <ul> <li>One test module per component (<code>test_parse_yaml.py</code>, <code>test_parse_abc.py</code>, <code>test_generate_abc.py</code>, ...).</li> <li>A dedicated <code>test_container.py</code> for <code>build</code> behavior (injection semantics, config propagation, defaults).</li> <li>Use <code>samples/minimal</code> as the smallest contrived base fixture: one root, one nested port, a few config vars, a single abstract method. It bikes the whole YAML \u21c4 ABC \u21c4 runtime loop in a few lines.</li> <li>Test the round-trip property directly \u2014 parsed models from both representations must be equal (<code>node1.diff(node3) == []</code>) and regeneration must be idempotent.</li> </ul>"},{"location":"06_best_practices/#related","title":"Related","text":"<ul> <li>03 \u2013 Wiring & runtime \u00b7 07 \u2013 Error Handling \u00b7 08 \u2013 Testing</li> </ul>"},{"location":"07_error_handling/","title":"Error Handling","text":"<p>Every failure mode in <code>hexa</code>, what triggers it, and how it surfaces \u2014 from the library (raises) and from the CLI (exits 1 with <code>Error: <message></code> on stderr).</p>"},{"location":"07_error_handling/#failure-modes","title":"\ud83d\udcda Failure Modes","text":"Where Trigger Outcome <code>parse_yaml</code> Structural error in the YAML spec (bad class body, wrong scalar types) <code>ValueError</code> raised <code>check_matches</code> Path whose suffix is neither <code>.yaml</code>/<code>.yml</code> nor <code>.py</code> <code>ValueError(\"Unknown file extension for <path>\")</code> <code>parse_abc</code> Module can't be located / root can't be detected Raises (import or reflection error) <code>build</code> Annotated type can't be instantiated Raises from the constructor path CLI (any subcommand) Any of the above Prints <code>Error: <message></code> to stderr, exits <code>1</code>"},{"location":"07_error_handling/#yaml-structural-errors","title":"\ud83d\udcdd YAML Structural Errors","text":"<p><code>parse_yaml</code> performs structural validation only \u2014 it checks the shape of the spec, not the types at runtime. Malformed specs raise <code>ValueError</code> (typically LRU-line-scoped in the source). Common causes:</p> <ul> <li>A port body that isn't a mapping.</li> <li>A nested struct that contains a value that's neither a scalar type expression nor a mapping body.</li> <li>A <code>methods:</code> entry with an invalid shape (missing <code>args</code>/<code>kwargs</code>, wrong collection types).</li> </ul> <p>The CLI wraps everything, so a bad spec shows as: Text Only<pre><code>Error: <reason>\n</code></pre> Do not confuse \"structural\" with \"runtime\": a spec that names a class that doesn't exist at runtime is not a parse error \u2014 YAML specs are contract-only and carry no runtime import.</p>"},{"location":"07_error_handling/#check_matches-mismatch-handling","title":"\ud83e\udd1d <code>check_matches</code> Mismatch Handling","text":"<p><code>check_matches</code> does not raise on disagreement \u2014 it returns a result:</p> Result Meaning <code>True</code> The two trees are equal. <code>list[str]</code> Human-readable difference strings (ported from <code>PortNode.diff</code>). <p>The CLI renders differences and exits <code>1</code>:</p> Text Only<pre><code>Differences found:\n <slot> port_cls mismatch: baz vs foo\n</code></pre> <p>A list result is truthy in Python, so always compare with <code>is True</code>:</p> Python<pre><code>res = check_matches(spec, abc)\nif res is True:\n print(\"clean\")\nelse:\n print(res) # iterable of diff strings\n</code></pre>"},{"location":"07_error_handling/#library-contracts","title":"\ud83e\uddf0 Library Contracts","text":"<ul> <li><code>check_matches(path_a, path_b)</code> \u2014 exactly one YAML file and exactly one <code>.py</code> file, in either order. Any other extension raises <code>ValueError</code>.</li> <li><code>parse_abc(x)</code> \u2014 accepts a <code>.py</code> path, an import string, or a loaded module. Give it a file path; other forms are for programmatic use.</li> <li><code>parse_yaml(path)</code> \u2014 accepts <code>str | Path</code>.</li> <li><code>build(cls, *, instances=None, config=None)</code> filters out non-dict config values and skips unclassified annotations (forward references, <code>Any</code>, etc.) \u2014 those are left unset rather than raised.</li> </ul>"},{"location":"07_error_handling/#tips","title":"\ud83d\udca1 Tips","text":"<ul> <li>Keep <code>str</code>/<code>int</code>/<code>bool</code> scalar annotations out of port slots \u2014 they're treated as config fields, and <code>build</code> won't instantiate them as ports.</li> <li><code>hexa check</code> is the friendliest failure reporter: never parse trees yourself when you can point two files at it.</li> <li>Guard CI with <code>hexa check spec.yaml abc.py</code> so representation drift never ships silently.</li> </ul>"},{"location":"07_error_handling/#related","title":"Related","text":"<ul> <li>02 \u2013 Core Components \u00b7 06 \u2013 Best Practices \u00b7 08 \u2013 Testing</li> </ul>"},{"location":"08_testing_example/","title":"Testing","text":"<p><code>hexa</code> ships a zero-mock pytest suite that exercises the real sample pipelines. The same fixtures make great templates for testing your own pipelines against the container.</p>"},{"location":"08_testing_example/#running-the-suite","title":"\ud83e\uddea Running the Suite","text":"Bash<pre><code>python -m pytest tests -q\n# expected: all pass (tests degrade gracefully when samples are absent)\n</code></pre> <p>The suite is fully deterministic: no network, no filesystem mocks, no dependency on external fixtures. A couple of tests guard on sample-file existence and skip cleanly otherwise, so the suite stays green in minimal checkouts.</p>"},{"location":"08_testing_example/#whats-covered","title":"\ud83d\udce6 What's Covered","text":"Module What it proves <code>test_model.py</code> <code>PortNode</code>, <code>MethodSpec</code>, nested <code>attributes</code> equality & helpers. <code>test_parse_yaml.py</code> YAML \u2192 tree: minimal spec and the extraction-pipeline grammar (attributes / nested structs / ports / <code>methods:</code>). <code>test_parse_abc.py</code> ABC module \u2192 tree: reflection from <code>__annotations__</code> + <code>@abstractmethod</code>. <code>test_check_matches.py</code> Two-representation agreement and <code>ValueError</code> on unknown extensions. <code>test_roundtrip.py</code> Full circle fidelity: <code>parse_yaml \u2192 generate_abc \u2192 parse_abc \u2192 generate_yaml \u2192 parse_yaml</code>. <code>test_container.py</code> <code>build</code> behavior: wiring, <code>instances=</code>/<code>config=</code> injection, defaults."},{"location":"08_testing_example/#the-round-trip-tests","title":"\ud83d\udd01 The Round-Trip Tests","text":"<p>The strongest guarantee lives in <code>test_roundtrip.py</code> \u2014 the same tree must survive a full circuit and stay equal, not just lossless:</p> <ul> <li><code>test_yaml_to_abc_to_yaml</code> \u2014 parse <code>samples/minimal/sample.yaml</code>, generate ABC source, parse it back, re-emit YAML, parse again; the roots and child counts match.</li> <li><code>test_nested_struct_roundtrip</code> \u2014 a nested struct attribute (<code>source</code>) survives YAML \u2192 ABC \u2192 YAML unchanged: Python<pre><code>node1 = parse_yaml(yaml_path)\nassert isinstance(node1.attributes.get(\"source\"), dict)\n...\nnode2 = parse_abc(tmp_abc)\nassert node1.attributes[\"source\"] == node2.attributes[\"source\"]\nassert node1.diff(node2) == []\n</code></pre></li> <li><code>test_abc_to_yaml_to_abc</code> \u2014 the mirror circuit starting from <code>samples/minimal/abc.py</code>.</li> </ul> <p>These are the tests to keep green when you add a new grammar feature or a new converter.</p>"},{"location":"08_testing_example/#container-tests","title":"\ud83c\udfd7\ufe0f Container Tests","text":"<p><code>test_container.py</code> pins the four injection contracts:</p> <ol> <li>Wiring with no overrides (<code>test_build_without_overrides_is_unchanged</code>) \u2014 <code>build(_Root)</code> constructs every annotated slot from its type and keeps class defaults.</li> <li>Runtime injection (<code>test_build_with_instances_and_config</code>) \u2014 Python<pre><code>root = build(\n _Root,\n instances={\"repo\": repo},\n config={\"retries\": 3, \"label\": \"fast\", \"threshold\": 2.5},\n)\nassert root.repo is repo # injected verbatim\nassert root.mid.threshold == 2.5 # config propagated by name into nested nodes\n</code></pre></li> <li>Typing-generic config fields (<code>test_config_applies_to_typing_generic_annotations</code>) \u2014 <code>str | None</code>/<code>list | None</code> fields take <code>config</code> values and leave unset ones at their defaults.</li> <li>Optional defaults (<code>test_config_optional_field_defaulted_to_none_when_unprovided</code>) \u2014 unprovided optional fields default to <code>None</code>.</li> </ol> <p>The <code>_Root</code>/<code>_Mid</code>/<code>_Leaf</code> fixture classes live right in the test file \u2014 a great minimal template for testing your own annotated pipelines.</p>"},{"location":"08_testing_example/#tips","title":"\ud83d\udca1 Tips","text":"<ul> <li>Keep fixture classes small and place them in the test module (like <code>_Root</code>, <code>_Repo</code>, <code>_OptionalConfig</code>). No fixtures framework needed for the container tests.</li> <li>Add <code>hexa check <spec.yaml> <abc.py></code> to CI as a live example of the guard, then assert on the return with <code>is True</code>.</li> <li>When extending the YAML grammar, add a case to both <code>test_parse_yaml.py</code> and a round-trip test \u2014 if the round-trip holds, the converter pair is consistent by construction.</li> </ul>"},{"location":"08_testing_example/#related","title":"Related","text":"<ul> <li>02 \u2013 Core Components \u00b7 06 \u2013 Best Practices \u00b7 07 \u2013 Error Handling</li> </ul>"},{"location":"03_use_cases/01_yaml_first/","title":"Use Case 1: YAML-First Contract Authoring","text":"<p>Scenario: you design the contract as a YAML spec, generate ABC Python source from it, hand-write concrete implementations, and prove the spec and code agree.</p>"},{"location":"03_use_cases/01_yaml_first/#whats-new","title":"\ud83d\udce6 What's New?","text":"Component Description <code>parse_yaml</code> Reads the spec into the neutral <code>PortNode</code> tree. <code>generate_abc</code> Emits <code>_abc.py</code> source (contracts only) from the tree. <code>check_matches</code> Verifies the spec and the ABC module describe the same tree."},{"location":"03_use_cases/01_yaml_first/#example","title":"\ud83d\ude80 Example","text":"<p>Start from the minimal sample spec (<code>samples/minimal/sample.yaml</code>):</p> YAML<pre><code>ParentClass:\n config_var1: str\n config_var2: int\n methods:\n run:\n args: [arg1, arg2]\n kwargs: {kwarg1: int, kwarg2: str}\n port1:\n NonHexaChildClass1:\n config_var11: str\n config_var12: int\n methods: {method1: {args: [arg1, arg2], kwargs: {kwarg1: int, kwarg2: str}}}\n port2:\n HexaChildClass2:\n config_var21: str\n config_var22: int\n methods: {method1: {args: [arg1, arg2], kwargs: {kwarg1: int, kwarg2: str}}}\n port1:\n NonHexaPort1:\n config_var11: str\n config_var12: int\n methods: {method1: {args: [arg1, arg2], kwargs: {kwarg1: int, kwarg2: str}}}\n</code></pre> <p>Generate the ABC module from the spec:</p> Bash<pre><code>hexa generate-abc samples/minimal/sample.yaml --out spec_abc.py\n</code></pre> <p>The generated file declares the contracts only \u2014 attributes, port slots, and abstract methods:</p> Python<pre><code>class ParentClass(ABC):\n config_var1: str\n config_var2: int\n\n port1: NonHexaChildClass1\n port2: HexaChildClass2\n port3: HexaChildClass3\n\n @abstractmethod\n def run(self, arg1, arg2, *, kwarg1: int, kwarg2: str): ...\n</code></pre> <p>Now write the concrete implementation (hand-written \u2014 behavior and slot pinning are human decisions):</p> Python<pre><code>class ImplParentClass(ParentClass):\n port1: NonHexaChildClass1\n port2: HexaChildClass2\n port3: HexaChildClass3\n\n def run(self, arg1, arg2, *, kwarg1: int = 0, kwarg2: str = \"\"):\n return {\n \"port1\": self.port1.method1(arg1, arg2, kwarg1=kwarg1, kwarg2=kwarg2),\n \"port2\": self.port2.method1(arg1, arg2, kwarg1=kwarg1, kwarg2=kwarg2),\n \"port3\": self.port3.method1(arg1, arg2, kwarg1=kwarg1, kwarg2=kwarg2),\n }\n</code></pre> <p>Finally, guard the two representations against drift:</p> Bash<pre><code>hexa check samples/minimal/sample.yaml samples/minimal/abc.py\n# Matches. (exit 0)\n</code></pre>"},{"location":"03_use_cases/01_yaml_first/#tips","title":"\ud83d\udca1 Tips","text":"<ul> <li><code>generate_abc</code> emits contracts only \u2014 it never generates <code>Impl*</code>/specialization files. Slot pinning and method bodies are your call.</li> <li>The YAML grammar distinguishes a port (a mapping whose single key has a non-scalar body) from a nested struct (any other mapping). Keep exactly one class key per port slot.</li> <li><code>check_matches</code> compares classes, attributes, slots, and <code>@abstractmethod</code> signatures; missing keyword-only params are treated conservatively so minor refactors don't false-negative.</li> <li>All generators are deterministic (walk order), so regenerating a spec from a module and diffing is stable.</li> </ul>"},{"location":"03_use_cases/01_yaml_first/#related","title":"Related","text":"<ul> <li>02 \u2013 ABC first \u00b7 03 \u2013 Wiring & runtime \u00b7 Overview</li> </ul>"},{"location":"03_use_cases/02_abc_first/","title":"Use Case 2: ABC-First Contract Authoring","text":"<p>Scenario: you prefer to author contracts as Python ABC modules, then emit \u2014 and verify \u2014 a YAML spec for them.</p>"},{"location":"03_use_cases/02_abc_first/#whats-new","title":"\ud83d\udce6 What's New?","text":"Component Description <code>parse_abc</code> Reflects an ABC module into the neutral <code>PortNode</code> tree. <code>generate_yaml</code> Emits a YAML spec from the tree. <code>check_matches</code> Verifies the two representations agree (reverse direction)."},{"location":"03_use_cases/02_abc_first/#example","title":"\ud83d\ude80 Example","text":"<p>Author the contract in Python (see <code>samples/minimal/abc.py</code>). A nested-struct attribute is expressed as a <code>@dataclass</code> annotation on an ABC port; the dataclass fields expand into a nested <code>attributes</code> entry when parsed:</p> Python<pre><code>from abc import ABC, abstractmethod\n\n\nclass NonHexaPort1(ABC):\n config_var11: str\n config_var12: int\n\n @abstractmethod\n def method1(self, arg1, arg2, *, kwarg1: int = 0, kwarg2: str = \"\") -> str: ...\n\n\nclass ParentClass(ABC):\n config_var1: str\n config_var2: int\n\n port1: NonHexaChildClass1\n port2: HexaChildClass2\n port3: HexaChildClass3\n\n @abstractmethod\n def run(self, arg1, arg2, *, kwarg1: int = 0, kwarg2: str = \"\") -> dict: ...\n</code></pre> <p>Emit the YAML spec from the module:</p> Bash<pre><code>hexa generate-yaml samples/minimal/abc.py --out spec.yaml\n</code></pre> <p>Inspect the parsed tree to make sure the root was detected correctly:</p> Bash<pre><code>hexa parse-abc samples/minimal/abc.py\n# PortNode(name='ParentClass', port_cls='ParentClass', kind='parent', ...)\n</code></pre> <p>And verify the spec against the module:</p> Bash<pre><code>hexa check spec.yaml samples/minimal/abc.py\n# Matches. (exit 0)\n</code></pre>"},{"location":"03_use_cases/02_abc_first/#tips","title":"\ud83d\udca1 Tips","text":"<ul> <li>Root detection: <code>parse_abc</code> picks the class that is not referenced as any other slot; on ties it prefers names containing <code>Pipeline</code>, <code>Root</code>, or <code>Parent</code>.</li> <li><code>parse_abc</code> accepts a <code>.py</code> path, an import string (e.g. <code>samples.minimal.abc</code>), or an already-loaded module, so it composes well in scripts.</li> <li>A <code>@dataclass</code> referenced from a port becomes a nested struct; a dataclass field that references an ABC becomes a nested port slot keyed under the struct.</li> <li>YAML generated this way is exactly the grammar <code>parse_yaml</code> understands \u2014 the two directions round-trip (<code>node1.diff(node3) == []</code> in the repo's roundtrip tests).</li> </ul>"},{"location":"03_use_cases/02_abc_first/#related","title":"Related","text":"<ul> <li>01 \u2013 YAML first \u00b7 03 \u2013 Wiring & runtime \u00b7 Core Components</li> </ul>"},{"location":"03_use_cases/03_wiring_runtime/","title":"Use Case 3: Wiring & Runtime Injection","text":"<p>Scenario: you have concrete classes with annotated slots and you want a fully wired instance \u2014 including runtime values (repos, clients) and config values (retries, labels, thresholds) \u2014 without writing any assembly code.</p>"},{"location":"03_use_cases/03_wiring_runtime/#whats-new","title":"\ud83d\udce6 What's New?","text":"Component Description <code>build</code> Recursively instantiates the port tree from annotations. <code>instances=</code> Inject objects verbatim (never reconstructed) by name. <code>config=</code> Propagate plain config values by name across the whole tree."},{"location":"03_use_cases/03_wiring_runtime/#example","title":"\ud83d\ude80 Example","text":"<p>This mirrors <code>tests/test_container.py</code> exactly. Define leaf, mid-level, and root classes purely with annotations:</p> Python<pre><code>from hexa import build\n\n\nclass _Repo:\n def __init__(self):\n self.name = \"injected-repo\"\n\n\nclass _Leaf:\n field: int = 0\n\n\nclass _Mid:\n leaf: _Leaf\n threshold: float = 0.0\n\n\nclass _Root:\n repo: _Repo\n mid: _Mid\n retries: int = 0\n label: str = \"\"\n\n\n# Provide a runtime instance for a slot, and config values by name.\nrepo = _Repo()\nroot = build(\n _Root,\n instances={\"repo\": repo},\n config={\"retries\": 3, \"label\": \"fast\", \"threshold\": 2.5},\n)\n\nassert root.repo is repo # injected verbatim, not reconstructed\nassert root.retries == 3 # config applied\nassert root.label == \"fast\"\nassert root.mid.leaf is not None # nested ports built recursively\nassert root.mid.threshold == 2.5 # config propagates by name into nested nodes\n</code></pre> <p>Without any overrides, the same annotations still produce a working tree \u2014 every slot is instantiated from its annotated type and config fields keep their class defaults:</p> Python<pre><code>root = build(_Root)\nassert isinstance(root.repo, _Repo)\nassert isinstance(root.mid, _Mid)\nassert isinstance(root.mid.leaf, _Leaf)\nassert root.retries == 0 and root.label == \"\"\n</code></pre> <p>Optional / typing-generic config fields are handled the same way \u2014 a <code>str | None</code> annotated attribute takes its <code>config</code> value when provided and defaults to the class default otherwise:</p> Python<pre><code>class _OptionalConfig:\n trust_fallback: str | None = None\n starters: list | None = None\n port: _Leaf\n\n\nbuilt = build(_OptionalConfig, config={\"trust_fallback\": \"amount\"})\nassert built.trust_fallback == \"amount\"\nassert built.starters is None # unrelated optional field keeps its default\n</code></pre>"},{"location":"03_use_cases/03_wiring_runtime/#tips","title":"\ud83d\udca1 Tips","text":"<ul> <li><code>instances=</code> wins over reconstruction. If a name is in <code>instances</code>, its object is <code>setattr</code>ed verbatim at every node that annotates it. Use it for things that must not be re-created (repos, handlers, clients).</li> <li><code>config=</code> targets non-port annotated fields. Builtin, <code>typing</code>, and PEP 604-union (<code>X | None</code>) annotated fields are treated as config, applied by name and propagated to every node.</li> <li>Abstract classes are never instantiated; they take a <code>config</code> value if one is provided (useful for defaults on CLI-style options).</li> <li>Only concrete annotated types become wired ports. String forward references are skipped (the container assumes <code>Impl</code> files pin actual types).</li> <li>Port instances are wired by <code>setattr(parent, slot, child)</code> \u2014 so methods can reference <code>self.portX</code> safely once the root is built.</li> <li>If <code>cls()</code> raises <code>TypeError</code>, <code>build</code> retries by passing <code>None</code> for each declared <code>__init__</code> parameter \u2014 convenient for classes with optional constructor args.</li> </ul>"},{"location":"03_use_cases/03_wiring_runtime/#related","title":"Related","text":"<ul> <li>01 \u2013 YAML first \u00b7 Best Practices \u00b7 Design</li> </ul>"}]} |