{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"\ud83e\udde9 hexa \u2014 Type-Declared Dependency Injection for Python","text":"
hexa 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 __init__ parameters, never as a composition root that assembles objects by hand.
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 docs/mcp/.
Impl* classes hold the shared default, bank-like specializations override only what differsbuild container \u2014 recursively wires a full pipeline from __annotations__ across the MRO; instances= and config= inject runtime values by namePortNode tree; the model is not tied to any sampleparse_yaml / generate_abc / parse_abc / generate_yaml translate between YAML specs and ABC modulescheck_matches verifies two representations describe the same tree and prints human-readable diffshexa parse-yaml, parse-abc, generate-abc, generate-yaml, check for CLI workflowssamples/minimal and samples/extraction_pipeline double as live examples for the test suiteFrom your internal PyPI:
Bashpip install --extra-index-url https://$PYPI_USERNAME:$PYPI_PASSWORD@pip.aetoskia.com/simple hexa\n From local source:
Bashpip install -e .\n"},{"location":"#documentation-structure","title":"\ud83d\udcc1 Documentation Structure","text":"Section Description Overview The mental model: three layers, two representations, and the container Core Components PortNode, MethodSpec, converters, check_matches, build, 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 build, instances=, config= 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":"\u00a9 Aetoskia Internal \u2014 hexa 0.1.0
hexa 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.
\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 Two layers, deliberately decoupled:
PortNode tree. This layer is optional: you may hand-write only the ABC file, only the YAML, or both.build(cls) 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 setattrs each child onto its parent.Whatever you author, the pattern is built from three fixed layers:
Layer Example file Role 1. ABC contractssamples/minimal/abc.py Declaration of need \u2014 attributes, port slots, @abstractmethod bodies. Never runs. 2. Generic Impl* / concrete defaults samples/minimal/impl.py Shared behavior \u2014 subclasses the ABC, implements every @abstractmethod, re-pins slots to concrete types. 3. Specialization (optional) samples/extraction_pipeline/banks/{axis,icici}/pdf.py Variation \u2014 overrides only the members that differ from Impl*. The root class you hand to build is always a concrete type (layer 2 or 3). The ABC (layer 1) is only a contract \u2014 it is never instantiated.
.yaml) or ABC (_abc.py) \u2014 or both, and let check_matches prove they agree. 2 Generate one from the other (optional) generate_abc(spec.yaml) when you want ABCs from YAML; generate_yaml(abc.py) for the reverse. 3 Write concrete implementations (Impl*) Subclass the ABC, implement abstracts, pin each slot to a concrete port type. 4 Wire build(ImplRoot, instances={\"repo\": repo}, config={\"retries\": 3}). 5 Verify (optional) check_matches(spec.yaml, abc.py) returns True or a list of difference strings. Steps 1\u20133 choose what the pipeline needs; step 4 is automatic. Variation between pipelines happens purely by which root class you build.
"},{"location":"01_overview/#which-entry-point-should-you-use","title":"\ud83e\udded Which Entry Point Should You Use?","text":"Entry point Use when Addsparse_yaml(path) You author specs in YAML YAML \u2192 PortNode tree (structural validation only) generate_abc(node) You want ABC source from a spec YAML model \u2192 Python ABC module source parse_abc(mod) You author contracts in Python ABC module \u2192 PortNode tree (reflection) generate_yaml(node) You want a spec for your ABCs Python ABC module \u2192 YAML source check_matches(a, b) You keep both representations Verifies the two trees agree; prints diffs build(cls, ...) You want a wired runtime instance Recursively instantiates the port tree from annotations"},{"location":"01_overview/#read-next","title":"\u27a1\ufe0f Read Next","text":"A validated reference to the public API of hexa (package root exports from hexa/__init__.py). Signatures match the code exactly. For runnable recipes see the Use Cases. Exact parameter contracts live in the API reference under docs/lib/ \u2014 this page is usage-level.
PortNode and MethodSpec","text":"Every parser and generator serializes through one neutral tree. It is not tied to any sample \u2014 any port tree fits.
"},{"location":"02_components/#11-portnode","title":"1.1PortNode","text":"A node in a hexa port tree. Three kinds:
Kind Meaning Exampleparent The root of the tree ExtractionPipeline port Has nested port slots (children) TransactionParserPort leaf No children NumberPort Fields:
Field Type Meaningname str Slot name, e.g. \"parser\", \"amount_balance\" port_cls str ABC class name that gives this port its shape kind str \"leaf\" | \"port\" | \"parent\" attributes dict[str, ConfigValue] name \u2192 type_expr for scalars, or a nested dict of sub-attributes for grouped values children list[PortNode] Nested port slots methods dict[str, MethodSpec] Abstract methods keyed by method name Tree helpers (used internally and handy for inspection):
node.find(name) -> PortNode | None \u2014 direct child by slot name.node.walk() -> list[PortNode] \u2014 all nodes, depth-first, self first.node.diff(other, path=\"\") -> list[str] \u2014 human-readable difference strings; empty list means equal.MethodSpec","text":"Specification of a single abstract method: name, positional args: list[str], and keyword-only kwargs: dict[str, str] (name \u2192 type expression). Supports equality (==/!=).
ConfigValue","text":"str | NestedConfig, where NestedConfig = dict[str, ConfigValue]. An attribute is either a scalar type expression (e.g. \"str\") or a nested structure of sub-attributes. Nesting is recursive.
All converters are pure (they operate on the model) and optional: you never have to touch them if you hand-write a single representation.
"},{"location":"02_components/#21-parse_yamlpath-portnode","title":"2.1parse_yaml(path) -> PortNode","text":"Parses a hexa YAML spec into a PortNode tree. The grammar uses three member kinds:
name: type scalar (e.g. source_type: str)name: mapping of sub-attributes (e.g. source:)name: mapping whose single key is a class whose value is a body (e.g. ingest: IngestPort: ...)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 ValueError on structural errors (see Error Handling).
generate_abc(node, out_path=None) -> str","text":"Generates _abc.py source from a PortNode tree. Emits class X(ABC) per node, synthesized @dataclasses for nested structs, port-slot annotations, and @abstractmethod stubs. Deterministic (walk order) so output is idempotent. When out_path is given the source is also written there; the source string is always returned.
parse_abc(module_or_path) -> PortNode","text":"Reflects an ABC Python module into a PortNode tree. Accepts a .py file path, a module import string, or an already-loaded module. ABC classes become nodes; a @dataclass annotation on a port expands into a nested attributes entry. The root is auto-detected (the class not referenced as any other slot, preferring names containing Pipeline/Root/Parent).
generate_yaml(node, out_path=None) -> str","text":"Generates a YAML spec (nested mapping form) from a PortNode tree \u2014 the reverse of generate_abc. A deferred/empty slot renders as slot: Class: ....
check_matches(a, b)","text":"check_matches(path_a, path_b) -> bool | list[str] parses two representations (one .yaml/.yml and one .py, either direction) and compares the trees with PortNode.diff. Returns True when they describe the same tree, otherwise a list of human-readable difference strings. Inequality checks cover port_cls, kind, attributes (including nested structs), methods (args/kwargs), and children.
build","text":"build(cls, *, instances=None, config=None) -> T recursively instantiates the port tree implied by a root class's annotations.
Algorithm:
__annotations__ across the MRO (reverse order) so inherited slot annotations are visible.cls(); if that raises TypeError, retry passing None for every __init__ parameter after self).instances wins \u2014 the provided object is setattred verbatim, never reconstructed.typing / PEP 604-union types are config fields \u2014 applied from config by name.config value when provided).build recurses and the child is setattred onto the parent.config value (or None) so methods can safely reference it.Runtime values (repos, handlers, clients) go in instances=; plain config values go in config=. Both propagate by name across the whole tree.
hexa parse-yaml PATH Print the parsed PortNode tree. hexa generate-abc PATH [--out FILE] Emit ABC source from a YAML spec. hexa parse-abc PATH Print the tree reflected from an ABC module. hexa generate-yaml PATH [--out FILE] Emit a YAML spec from an ABC module. hexa check PATH_A PATH_B Print Matches./Differences found: and exit 0/1. Errors print Error: <message> to stderr and exit 1.
Note: The extraction pipeline discussed below is a sample/example illustrating the hexa composition pattern.
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.
"},{"location":"04_design/#file-map-samplesextraction_pipeline","title":"File map (samples/extraction_pipeline/)","text":"File Layer Role extraction_pipeline_abc.py ABC contracts All port ABCs + ExtractionPipeline root. Declares attributes, the ExtractionSource dataclass, port slots, @abstractmethods. extraction_pipeline_impl.py Generic Impl* Concrete default implementations of every port; ImplExtractionPipeline root; clean_num helper. extraction_pipeline_ambiguity.py Shared logic Ported ResolutionContext, AmbiguityHandler, AmountBalanceNotFound, validate_signs \u2014 used by ImplAmountBalancePort. extraction_pipeline_banks.py Composition root AxisExtractionPipeline(ImplExtractionPipeline) + IciciExtractionPipeline (each re-pins txn_dicts). banks/axis/pdf.py Bank specialization AxisNumberPort, AxisDescPort, AxisAmountBalancePort, AxisTransactionParserPort, AxisTxnDictsPort. banks/icici/pdf.py Bank specialization Icici* mirror of the above + Icici-only adjust_balance/missing_number_candidates. extraction_pipeline.yaml Contract spec Declarative spec of the port tree; interchangeable with _abc.py via the optional utilities."},{"location":"04_design/#the-port-tree-abc-contract","title":"The port tree (ABC contract)","text":"Text OnlyExtractionPipeline\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"},{"location":"04_design/#generic-default-impl","title":"Generic default (Impl*)","text":"ImplExtractionPipeline pins every stage slot to a concrete Impl* port:
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"},{"location":"04_design/#bank-variation-only-what-differs","title":"Bank variation (only what differs)","text":"The root class re-pins only txn_dicts. Everything else inherits Impl*.
banks/axis/pdf.py)","text":"Pythonclass AxisExtractionPipeline(ImplExtractionPipeline):\n txn_dicts: AxisTxnDictsPort\n PythonAxisTxnDictsPort(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 Notes:
date is not redeclared on AxisTransactionParserPort \u2014 it inherits ImplDatePort (correct already, adds no behavior).amount_balance.ambiguity is not redeclared \u2014 inherits ImplAmbiguityPort.banks/icici/pdf.py)","text":"Pythonclass IciciExtractionPipeline(ImplExtractionPipeline):\n txn_dicts: IciciTxnDictsPort\n PythonIciciTxnDictsPort(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"},{"location":"04_design/#redundancy-rule-applied-in-both-banks","title":"Redundancy rule (applied in both banks)","text":"Only redeclare a slot you are actually changing from Impl*. If a slot's behavior is already correct from the parent Impl*, omit it. This keeps the diff between a bank and Impl* minimal and unambiguous.
date: ImplDatePort 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 ImplDatePort in ImplTransactionParserPort, so behavior is unchanged.)
build(cls) in hexa/container.py)","text":"The annotations are metadata; a small reflection builder turns a root class into a wired instance:
Text Onlybuild(AxisExtractionPipeline) \u2192 wired AxisExtractionPipeline\n Algorithm (build(cls)):
cls.__annotations__, merged across the MRO so inherited slot annotations are visible (a bank subclass only adds/overrides a slot; the rest come from Impl*).Impl*/bank class (not an ABC, not a builtin), instantiate it.setattr(parent, slot, child) to wire each slot onto the parent instance.Selection is fully static: the annotated type is the chosen implementation. The container holds no per-bank branch logic \u2014 Axis vs Icici is decided purely by which root class you call build on.
A leaf-stage port exposes one entry method; stages chain data through the shared run(source) signature on the root:
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 The stage ports map naturally onto dagpipe Nodes (ingest, raw_lines, txn_blocks, txn_dicts, raw_expense), each node's resolve delegating to its wired port subtree, connected as a dagpipe Graph and run via Engine. That merge is a separate, future step.
The hexa utilities are implemented in hexa/ (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:
The utilities only ever translate between two interchangeable representations of the same contract \u2014 the .yaml spec and the _abc.py module. They never generate or manage the Impl*/bank files (those carry behavior and slot choice, which is a human decision).
All utilities serialize through one neutral model, a PortNode tree. It is NOT tied to the extraction sample \u2014 any port tree fits.
@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"},{"location":"04_design/#1-parse_yamlpath-portnode","title":"1. parse_yaml(path) -> PortNode","text":"Read a YAML spec into the PortNode tree (YAML \u2192 model).
extraction_pipeline.yaml): a root class whose inline attributes (scalars and nested structs), port slots, and reserved methods: recurse into child ports.ValueError with file/line on structural errors.generate_abc(path_yaml, out_path=None) -> str","text":"Generate _abc.py source from a YAML spec (YAML \u2192 ABC).
parse_yaml(...) \u2192 write one class <PortCls>(ABC) per PortNode with attribute annotations (nested structs become synthesized @dataclasses), port-slot annotations, and @abstractmethod stubs, matching the style of the hand-written extraction_pipeline_abc.py.out_path optional; always returns the generated source.parse_abc(module_or_path) -> PortNode","text":"Reflect over an ABC module into the PortNode tree (ABC \u2192 model).
__annotations__ / @abstractmethod from generic ABC classes (no sample assumptions) to reconstruct the same PortNode tree.generate_yaml(module_or_path, out_path=None) -> str","text":"Generate a .yaml spec from an ABC module (ABC \u2192 YAML).
parse_abc(...) \u2192 write the reverse of generate_abc.check_matches(path_a, path_b) -> bool","text":"Verify two representations agree \u2014 YAML vs ABC, either direction.
PortNode models (parse_yaml + parse_abc) and compares: classes exist and are ABCs, attributes match, port slots match, @abstractmethod signatures match (missing keyword-only params treated conservatively).True only if the whole tree matches; otherwise False (or a diff).# 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 Impl*/bank files are always hand-extended on top and are never generated.
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.
"},{"location":"05_philosophy/#one-idea","title":"One Idea","text":"A concrete class's annotated slot type is the dependency decision.
Bank variation is expressed entirely as class annotations \u2014 never as imperative wiring, never as __init__ 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.
The pattern is built from three fixed layers. Understanding which layer something belongs to is the whole mental model.
"},{"location":"05_philosophy/#layer-1-abc-contracts-samplesextraction_pipelineextraction_pipeline_abcpy","title":"Layer 1 \u2014 ABC contracts (samples/extraction_pipeline/extraction_pipeline_abc.py)","text":"Authoritative declaration of need (\"WHAT a stage requires\").
ABC.TransactionParserPort.min_numbers: int = 3), optionally grouped as a nested struct via a @dataclass (e.g. source: ExtractionSource).TransactionParserPort.number: NumberPort, amount_balance: AmountBalancePort).@abstractmethod bodies \u2014 the method signatures each concrete implementation must provide.The ABC never runs, never holds an instance, and never says which implementation to use. It only says what the shape is.
"},{"location":"05_philosophy/#layer-2-generic-concurrent-implementations-samplesextraction_pipelineextraction_pipeline_implpy","title":"Layer 2 \u2014 Generic concurrent implementations (samples/extraction_pipeline/extraction_pipeline_impl.py)","text":"The common default behavior (\"Impl* \u2014 the shared adaptor\").
class ImplTransactionParserPort(TransactionParserPort) subclasses the ABC, implements every @abstractmethod, and re-pins its port slots to concrete Impl* types: number: ImplNumberPort, desc: ImplDescPort, amount_balance: ImplAmountBalancePort.ImplAmountBalancePort in turn pins number: ImplNumberPort and ambiguity: ImplAmbiguityPort.ImplExtractionPipeline, pins its five stage slots to Impl* ports: ingest, raw_lines, txn_blocks, txn_dicts, raw_expense.Impl* is the default that shared, bank-agnostic behavior lives in. Any slot a bank does not override falls back to Impl* at runtime.
samples/extraction_pipeline/banks/{axis,icici}/pdf.py, samples/extraction_pipeline/extraction_pipeline_banks.py)","text":"The variation (\"what differs per bank\").
Impl* classes, never the ABC \u2014 they extend, they never reinvent the contract.Axis*/Icici* class overrides only the members that differ from the generic Impl*; everything shared is inherited.ImplExtractionPipeline that re-pins only the slots that differ:class AxisExtractionPipeline(ImplExtractionPipeline):\n txn_dicts: AxisTxnDictsPort\n"},{"location":"05_philosophy/#the-re-pinning-cascade","title":"The Re-Pinning Cascade","text":"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:
Text OnlyAxisExtractionPipeline\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 Siblings that are already correct in Impl* are inherited, not redeclared. E.g. the parser's date slot comes from ImplTransactionParserPort; the amount-balance port's ambiguity comes from ImplAmountBalancePort. Re-declaring them adds mental load on the extender with zero behavior change.
Rule \u2014 only redeclare the slot you are actually changing. If a slot's behavior is already correct from Impl*, leave it out.
ingest, raw_lines, txn_blocks, raw_expense, parser date, and ambiguity are bank-agnostic. Leaving them at the Impl* default is deliberate, not an omission.Axis*/Icici* only extend Impl*. The ABC contract in samples/extraction_pipeline/extraction_pipeline_abc.py is defined once and shared by every bank.build_axis_pipeline() wiring objects by hand.Impl* classes have no __init__; wiring is done by assigning concrete port instances onto annotated slots..yaml spec and the _abc.py 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 Impl*/specialization files are always hand-written (their slots are a manual choice) and never generated.The annotations are the decision; hexa.build(cls) (see Design \u00a7 Container) turns a root class into a wired instance by reading __annotations__ + MRO, recursively instantiating each pinned concrete type, and setattring 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.
Rules of thumb for structuring hexa pipelines that stay easy to extend and verify. Grounded in the extraction pipeline sample and the test suite.
"},{"location":"06_best_practices/#layer-discipline","title":"\ud83c\udf33 Layer Discipline","text":"Impl* classes. The ABC defines the contract once; Impl* carries behavior; banks only vary.Impl*, not at the ABC. The ABC declares shape (number: NumberPort); Impl* decides implementation (number: ImplNumberPort).class AxisExtractionPipeline(ImplExtractionPipeline):\n txn_dicts: AxisTxnDictsPort\nThe redundancy rule: if a slot's behavior is already correct from Impl*, leave it out.
date is not redeclared on AxisTransactionParserPort \u2014 it inherits ImplDatePort.ambiguity is not redeclared on AxisAmountBalancePort \u2014 it inherits ImplAmbiguityPort.Impl* minimal.If you do redeclare a slot, re-pin it back to your own bank's port (e.g. amount_balance.number: AxisNumberPort) so a single bank's number formatting is consistent down the subtree.
Keep YAML and ABC in agreement:
parse_yaml, generate_abc, parse_abc, generate_yaml) serializes through the same PortNode tree \u2014 any tree fits, nothing is tied to a particular sample.check_matches(spec.yaml, abc.py) (or hexa check ...) as a CI guard whenever both representations are maintained.Impl* level so the ABC stays a pure contract.@dataclass annotation (ABC) \u21c4 a nested struct mapping (YAML).instances= \u2014 wired verbatim by name, never reconstructed.config= \u2014 propagated by name across the whole tree, including nested ports.build matches instances/config by that annotated name, so names must agree across the tree.Impl*/bank classes (or ABCs when the leaf is a config-only option). Builtin/typing/union-annotated fields are treated as config, not ports \u2014 so don't annotate a port with # type: str.Mirror tests/:
test_parse_yaml.py, test_parse_abc.py, test_generate_abc.py, ...).test_container.py for build behavior (injection semantics, config propagation, defaults).samples/minimal 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.node1.diff(node3) == []) and regeneration must be idempotent.Every failure mode in hexa, what triggers it, and how it surfaces \u2014 from the library (raises) and from the CLI (exits 1 with Error: <message> on stderr).
parse_yaml Structural error in the YAML spec (bad class body, wrong scalar types) ValueError raised check_matches Path whose suffix is neither .yaml/.yml nor .py ValueError(\"Unknown file extension for <path>\") parse_abc Module can't be located / root can't be detected Raises (import or reflection error) build Annotated type can't be instantiated Raises from the constructor path CLI (any subcommand) Any of the above Prints Error: <message> to stderr, exits 1"},{"location":"07_error_handling/#yaml-structural-errors","title":"\ud83d\udcdd YAML Structural Errors","text":"parse_yaml performs structural validation only \u2014 it checks the shape of the spec, not the types at runtime. Malformed specs raise ValueError (typically LRU-line-scoped in the source). Common causes:
methods: entry with an invalid shape (missing args/kwargs, wrong collection types).The CLI wraps everything, so a bad spec shows as: Text Only
Error: <reason>\n 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."},{"location":"07_error_handling/#check_matches-mismatch-handling","title":"\ud83e\udd1d check_matches Mismatch Handling","text":"check_matches does not raise on disagreement \u2014 it returns a result:
True The two trees are equal. list[str] Human-readable difference strings (ported from PortNode.diff). The CLI renders differences and exits 1:
Differences found:\n <slot> port_cls mismatch: baz vs foo\n A list result is truthy in Python, so always compare with is True:
res = check_matches(spec, abc)\nif res is True:\n print(\"clean\")\nelse:\n print(res) # iterable of diff strings\n"},{"location":"07_error_handling/#library-contracts","title":"\ud83e\uddf0 Library Contracts","text":"check_matches(path_a, path_b) \u2014 exactly one YAML file and exactly one .py file, in either order. Any other extension raises ValueError.parse_abc(x) \u2014 accepts a .py path, an import string, or a loaded module. Give it a file path; other forms are for programmatic use.parse_yaml(path) \u2014 accepts str | Path.build(cls, *, instances=None, config=None) filters out non-dict config values and skips unclassified annotations (forward references, Any, etc.) \u2014 those are left unset rather than raised.str/int/bool scalar annotations out of port slots \u2014 they're treated as config fields, and build won't instantiate them as ports.hexa check is the friendliest failure reporter: never parse trees yourself when you can point two files at it.hexa check spec.yaml abc.py so representation drift never ships silently.hexa 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.
python -m pytest tests -q\n# expected: all pass (tests degrade gracefully when samples are absent)\n 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.
"},{"location":"08_testing_example/#whats-covered","title":"\ud83d\udce6 What's Covered","text":"Module What it provestest_model.py PortNode, MethodSpec, nested attributes equality & helpers. test_parse_yaml.py YAML \u2192 tree: minimal spec and the extraction-pipeline grammar (attributes / nested structs / ports / methods:). test_parse_abc.py ABC module \u2192 tree: reflection from __annotations__ + @abstractmethod. test_check_matches.py Two-representation agreement and ValueError on unknown extensions. test_roundtrip.py Full circle fidelity: parse_yaml \u2192 generate_abc \u2192 parse_abc \u2192 generate_yaml \u2192 parse_yaml. test_container.py build behavior: wiring, instances=/config= injection, defaults."},{"location":"08_testing_example/#the-round-trip-tests","title":"\ud83d\udd01 The Round-Trip Tests","text":"The strongest guarantee lives in test_roundtrip.py \u2014 the same tree must survive a full circuit and stay equal, not just lossless:
test_yaml_to_abc_to_yaml \u2014 parse samples/minimal/sample.yaml, generate ABC source, parse it back, re-emit YAML, parse again; the roots and child counts match.test_nested_struct_roundtrip \u2014 a nested struct attribute (source) survives YAML \u2192 ABC \u2192 YAML unchanged: Pythonnode1 = 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) == []\ntest_abc_to_yaml_to_abc \u2014 the mirror circuit starting from samples/minimal/abc.py.These are the tests to keep green when you add a new grammar feature or a new converter.
"},{"location":"08_testing_example/#container-tests","title":"\ud83c\udfd7\ufe0f Container Tests","text":"test_container.py pins the four injection contracts:
test_build_without_overrides_is_unchanged) \u2014 build(_Root) constructs every annotated slot from its type and keeps class defaults.test_build_with_instances_and_config) \u2014 Pythonroot = 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\ntest_config_applies_to_typing_generic_annotations) \u2014 str | None/list | None fields take config values and leave unset ones at their defaults.test_config_optional_field_defaulted_to_none_when_unprovided) \u2014 unprovided optional fields default to None.The _Root/_Mid/_Leaf fixture classes live right in the test file \u2014 a great minimal template for testing your own annotated pipelines.
_Root, _Repo, _OptionalConfig). No fixtures framework needed for the container tests.hexa check <spec.yaml> <abc.py> to CI as a live example of the guard, then assert on the return with is True.test_parse_yaml.py and a round-trip test \u2014 if the round-trip holds, the converter pair is consistent by construction.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.
"},{"location":"03_use_cases/01_yaml_first/#whats-new","title":"\ud83d\udce6 What's New?","text":"Component Descriptionparse_yaml Reads the spec into the neutral PortNode tree. generate_abc Emits _abc.py source (contracts only) from the tree. check_matches Verifies the spec and the ABC module describe the same tree."},{"location":"03_use_cases/01_yaml_first/#example","title":"\ud83d\ude80 Example","text":"Start from the minimal sample spec (samples/minimal/sample.yaml):
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 Generate the ABC module from the spec:
Bashhexa generate-abc samples/minimal/sample.yaml --out spec_abc.py\n The generated file declares the contracts only \u2014 attributes, port slots, and abstract methods:
Pythonclass 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 Now write the concrete implementation (hand-written \u2014 behavior and slot pinning are human decisions):
Pythonclass 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 Finally, guard the two representations against drift:
Bashhexa check samples/minimal/sample.yaml samples/minimal/abc.py\n# Matches. (exit 0)\n"},{"location":"03_use_cases/01_yaml_first/#tips","title":"\ud83d\udca1 Tips","text":"generate_abc emits contracts only \u2014 it never generates Impl*/specialization files. Slot pinning and method bodies are your call.check_matches compares classes, attributes, slots, and @abstractmethod signatures; missing keyword-only params are treated conservatively so minor refactors don't false-negative.Scenario: you prefer to author contracts as Python ABC modules, then emit \u2014 and verify \u2014 a YAML spec for them.
"},{"location":"03_use_cases/02_abc_first/#whats-new","title":"\ud83d\udce6 What's New?","text":"Component Descriptionparse_abc Reflects an ABC module into the neutral PortNode tree. generate_yaml Emits a YAML spec from the tree. check_matches Verifies the two representations agree (reverse direction)."},{"location":"03_use_cases/02_abc_first/#example","title":"\ud83d\ude80 Example","text":"Author the contract in Python (see samples/minimal/abc.py). A nested-struct attribute is expressed as a @dataclass annotation on an ABC port; the dataclass fields expand into a nested attributes entry when parsed:
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 Emit the YAML spec from the module:
Bashhexa generate-yaml samples/minimal/abc.py --out spec.yaml\n Inspect the parsed tree to make sure the root was detected correctly:
Bashhexa parse-abc samples/minimal/abc.py\n# PortNode(name='ParentClass', port_cls='ParentClass', kind='parent', ...)\n And verify the spec against the module:
Bashhexa check spec.yaml samples/minimal/abc.py\n# Matches. (exit 0)\n"},{"location":"03_use_cases/02_abc_first/#tips","title":"\ud83d\udca1 Tips","text":"parse_abc picks the class that is not referenced as any other slot; on ties it prefers names containing Pipeline, Root, or Parent.parse_abc accepts a .py path, an import string (e.g. samples.minimal.abc), or an already-loaded module, so it composes well in scripts.@dataclass referenced from a port becomes a nested struct; a dataclass field that references an ABC becomes a nested port slot keyed under the struct.parse_yaml understands \u2014 the two directions round-trip (node1.diff(node3) == [] in the repo's roundtrip tests).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.
"},{"location":"03_use_cases/03_wiring_runtime/#whats-new","title":"\ud83d\udce6 What's New?","text":"Component Descriptionbuild Recursively instantiates the port tree from annotations. instances= Inject objects verbatim (never reconstructed) by name. config= Propagate plain config values by name across the whole tree."},{"location":"03_use_cases/03_wiring_runtime/#example","title":"\ud83d\ude80 Example","text":"This mirrors tests/test_container.py exactly. Define leaf, mid-level, and root classes purely with annotations:
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 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:
Pythonroot = 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 Optional / typing-generic config fields are handled the same way \u2014 a str | None annotated attribute takes its config value when provided and defaults to the class default otherwise:
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"},{"location":"03_use_cases/03_wiring_runtime/#tips","title":"\ud83d\udca1 Tips","text":"instances= wins over reconstruction. If a name is in instances, its object is setattred verbatim at every node that annotates it. Use it for things that must not be re-created (repos, handlers, clients).config= targets non-port annotated fields. Builtin, typing, and PEP 604-union (X | None) annotated fields are treated as config, applied by name and propagated to every node.config value if one is provided (useful for defaults on CLI-style options).Impl files pin actual types).setattr(parent, slot, child) \u2014 so methods can reference self.portX safely once the root is built.cls() raises TypeError, build retries by passing None for each declared __init__ parameter \u2014 convenient for classes with optional constructor args.