{"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/.

"},{"location":"#key-features","title":"\ud83d\ude80 Key Features","text":""},{"location":"#installation","title":"\ud83d\udce6 Installation","text":"

From your internal PyPI:

Bash
pip install --extra-index-url https://$PYPI_USERNAME:$PYPI_PASSWORD@pip.aetoskia.com/simple hexa\n

From local source:

Bash
pip 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

"},{"location":"01_overview/","title":"Library Overview","text":"

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.

"},{"location":"01_overview/#architecture","title":"\ud83c\udfd7\ufe0f Architecture","text":"Text Only
\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:

  1. 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 PortNode tree. This layer is optional: you may hand-write only the ABC file, only the YAML, or both.
  2. Runtime \u2014 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.
"},{"location":"01_overview/#the-three-layers-the-mental-model","title":"\ud83d\udd04 The Three Layers (the mental model)","text":"

Whatever you author, the pattern is built from three fixed layers:

Layer Example file Role 1. ABC contracts samples/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.

"},{"location":"01_overview/#lifecycle-rules","title":"\ud83d\udd04 Lifecycle Rules","text":"Step Call Why 1 Author the contract As YAML (.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 Adds parse_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":""},{"location":"02_components/","title":"Core Components","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.

"},{"location":"02_components/#1-the-shared-model-portnode-and-methodspec","title":"1. The Shared Model \u2014 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.1 PortNode","text":"

A node in a hexa port tree. Three kinds:

Kind Meaning Example parent The root of the tree ExtractionPipeline port Has nested port slots (children) TransactionParserPort leaf No children NumberPort

Fields:

Field Type Meaning name 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):

"},{"location":"02_components/#12-methodspec","title":"1.2 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 (==/!=).

"},{"location":"02_components/#13-configvalue","title":"1.3 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.

"},{"location":"02_components/#2-converters-yaml-abc","title":"2. Converters \u2014 YAML \u21c4 ABC","text":"

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.1 parse_yaml(path) -> PortNode","text":"

Parses a hexa YAML spec into a PortNode tree. The grammar uses three member kinds:

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).

"},{"location":"02_components/#22-generate_abcnode-out_pathnone-str","title":"2.2 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.

"},{"location":"02_components/#23-parse_abcmodule_or_path-portnode","title":"2.3 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).

"},{"location":"02_components/#24-generate_yamlnode-out_pathnone-str","title":"2.4 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: ....

"},{"location":"02_components/#3-verification-check_matchesa-b","title":"3. Verification \u2014 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.

"},{"location":"02_components/#4-container-build","title":"4. Container \u2014 build","text":"

build(cls, *, instances=None, config=None) -> T recursively instantiates the port tree implied by a root class's annotations.

Algorithm:

  1. Merge __annotations__ across the MRO (reverse order) so inherited slot annotations are visible.
  2. Instantiate the parent class (cls(); if that raises TypeError, retry passing None for every __init__ parameter after self).
  3. For each annotation:
  4. A name in instances wins \u2014 the provided object is setattred verbatim, never reconstructed.
  5. Builtin / typing / PEP 604-union types are config fields \u2014 applied from config by name.
  6. Abstract classes are skipped (but take a config value when provided).
  7. Any other concrete type is a port slot \u2014 build recurses and the child is setattred onto the parent.
  8. Config defaults: any annotated attribute still unset is defaulted to its 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.

"},{"location":"02_components/#5-cli","title":"5. CLI","text":"Command Behavior 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.

"},{"location":"02_components/#related","title":"Related","text":""},{"location":"04_design/","title":"Design \u2014 Extraction Pipeline (Axis / Icici)","text":"

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 Only
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
"},{"location":"04_design/#generic-default-impl","title":"Generic default (Impl*)","text":"

ImplExtractionPipeline pins every stage slot to a concrete Impl* port:

Text Only
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*.

"},{"location":"04_design/#axis-banksaxispdfpy","title":"Axis (banks/axis/pdf.py)","text":"Python
class AxisExtractionPipeline(ImplExtractionPipeline):\n    txn_dicts: AxisTxnDictsPort\n
Python
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

Notes:

"},{"location":"04_design/#icici-banksicicipdfpy","title":"Icici (banks/icici/pdf.py)","text":"Python
class IciciExtractionPipeline(ImplExtractionPipeline):\n    txn_dicts: IciciTxnDictsPort\n
Python
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
"},{"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.)

"},{"location":"04_design/#container-buildcls-in-hexacontainerpy","title":"Container (build(cls) in hexa/container.py)","text":"

The annotations are metadata; a small reflection builder turns a root class into a wired instance:

Text Only
build(AxisExtractionPipeline)  \u2192  wired AxisExtractionPipeline\n

Algorithm (build(cls)):

  1. Walk 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*).
  2. For each port slot whose annotated type is a concrete Impl*/bank class (not an ABC, not a builtin), instantiate it.
  3. Recurse into that child \u2014 repeat until leaves (classes that declare no further port slots).
  4. setattr(parent, slot, child) to wire each slot onto the parent instance.
  5. Apply config defaults from the class-level annotated attributes.

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.

"},{"location":"04_design/#control-flow-at-runtime-target","title":"Control flow at runtime (target)","text":"

A leaf-stage port exposes one entry method; stages chain data through the shared run(source) signature on the root:

Text Only
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.

"},{"location":"04_design/#utilities-optional-hexa-agnostic","title":"Utilities (optional, hexa-agnostic)","text":"

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).

"},{"location":"04_design/#shared-model","title":"Shared model","text":"

All utilities serialize through one neutral model, a PortNode tree. It is NOT tied to the extraction sample \u2014 any port tree fits.

Python
@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).

"},{"location":"04_design/#2-generate_abcpath_yaml-out_pathnone-str","title":"2. generate_abc(path_yaml, out_path=None) -> str","text":"

Generate _abc.py source from a YAML spec (YAML \u2192 ABC).

"},{"location":"04_design/#3-parse_abcmodule_or_path-portnode","title":"3. parse_abc(module_or_path) -> PortNode","text":"

Reflect over an ABC module into the PortNode tree (ABC \u2192 model).

"},{"location":"04_design/#4-generate_yamlmodule_or_path-out_pathnone-str","title":"4. generate_yaml(module_or_path, out_path=None) -> str","text":"

Generate a .yaml spec from an ABC module (ABC \u2192 YAML).

"},{"location":"04_design/#5-check_matchespath_a-path_b-bool","title":"5. check_matches(path_a, path_b) -> bool","text":"

Verify two representations agree \u2014 YAML vs ABC, either direction.

"},{"location":"04_design/#workflows-users-choice","title":"Workflows (user's choice)","text":"Text Only
# 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.

"},{"location":"04_design/#related","title":"Related","text":""},{"location":"05_philosophy/","title":"Philosophy \u2014 Why Type-Declared Dependencies","text":"

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.

"},{"location":"05_philosophy/#the-three-layers","title":"The Three Layers","text":"

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\").

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\").

Impl* is the default that shared, bank-agnostic behavior lives in. Any slot a bank does not override falls back to Impl* at runtime.

"},{"location":"05_philosophy/#layer-3-bank-specializations-samplesextraction_pipelinebanksaxisicicipdfpy-samplesextraction_pipelineextraction_pipeline_bankspy","title":"Layer 3 \u2014 Bank specializations (samples/extraction_pipeline/banks/{axis,icici}/pdf.py, samples/extraction_pipeline/extraction_pipeline_banks.py)","text":"

The variation (\"what differs per bank\").

Python
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 Only
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

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.

"},{"location":"05_philosophy/#the-two-fidelity-guarantees-this-pattern-preserves","title":"The Two Fidelity Guarantees This Pattern Preserves","text":"
  1. Shared behavior stays shared. Slots like 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.
  2. Banks never reinvent. Axis*/Icici* only extend Impl*. The ABC contract in samples/extraction_pipeline/extraction_pipeline_abc.py is defined once and shared by every bank.
"},{"location":"05_philosophy/#what-this-pattern-is-not","title":"What This Pattern Is NOT","text":""},{"location":"05_philosophy/#runtime-instantiation","title":"Runtime Instantiation","text":"

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.

"},{"location":"05_philosophy/#related","title":"Related","text":""},{"location":"06_best_practices/","title":"Best Practices","text":"

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":""},{"location":"06_best_practices/#only-redeclare-what-you-change","title":"\ud83d\udc63 Only Redeclare What You Change","text":"

The redundancy rule: if a slot's behavior is already correct from Impl*, leave it out.

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.

"},{"location":"06_best_practices/#representational-fidelity","title":"\ud83d\udcc4 Representational Fidelity","text":"

Keep YAML and ABC in agreement:

"},{"location":"06_best_practices/#wiring-discipline","title":"\ud83e\udde9 Wiring Discipline","text":""},{"location":"06_best_practices/#test-layout","title":"\ud83e\uddea Test Layout","text":"

Mirror tests/:

"},{"location":"06_best_practices/#related","title":"Related","text":""},{"location":"07_error_handling/","title":"Error Handling","text":"

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).

"},{"location":"07_error_handling/#failure-modes","title":"\ud83d\udcda Failure Modes","text":"Where Trigger Outcome 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:

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:

Result Meaning True The two trees are equal. list[str] Human-readable difference strings (ported from PortNode.diff).

The CLI renders differences and exits 1:

Text Only
Differences found:\n  <slot> port_cls mismatch: baz vs foo\n

A list result is truthy in Python, so always compare with is True:

Python
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":""},{"location":"07_error_handling/#tips","title":"\ud83d\udca1 Tips","text":""},{"location":"07_error_handling/#related","title":"Related","text":""},{"location":"08_testing_example/","title":"Testing","text":"

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.

"},{"location":"08_testing_example/#running-the-suite","title":"\ud83e\uddea Running the Suite","text":"Bash
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 proves test_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:

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:

  1. Wiring with no overrides (test_build_without_overrides_is_unchanged) \u2014 build(_Root) constructs every annotated slot from its type and keeps class defaults.
  2. Runtime injection (test_build_with_instances_and_config) \u2014 Python
    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
  3. Typing-generic config fields (test_config_applies_to_typing_generic_annotations) \u2014 str | None/list | None fields take config values and leave unset ones at their defaults.
  4. Optional 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.

"},{"location":"08_testing_example/#tips","title":"\ud83d\udca1 Tips","text":""},{"location":"08_testing_example/#related","title":"Related","text":""},{"location":"03_use_cases/01_yaml_first/","title":"Use Case 1: YAML-First Contract Authoring","text":"

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 Description parse_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):

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:

Bash
hexa 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:

Python
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

Now write the concrete implementation (hand-written \u2014 behavior and slot pinning are human decisions):

Python
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

Finally, guard the two representations against drift:

Bash
hexa 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":""},{"location":"03_use_cases/01_yaml_first/#related","title":"Related","text":""},{"location":"03_use_cases/02_abc_first/","title":"Use Case 2: ABC-First Contract Authoring","text":"

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 Description parse_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:

Python
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:

Bash
hexa generate-yaml samples/minimal/abc.py --out spec.yaml\n

Inspect the parsed tree to make sure the root was detected correctly:

Bash
hexa parse-abc samples/minimal/abc.py\n# PortNode(name='ParentClass', port_cls='ParentClass', kind='parent', ...)\n

And verify the spec against the module:

Bash
hexa 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":""},{"location":"03_use_cases/02_abc_first/#related","title":"Related","text":""},{"location":"03_use_cases/03_wiring_runtime/","title":"Use Case 3: Wiring & Runtime Injection","text":"

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 Description build 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:

Python
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:

Python
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

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:

Python
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":""},{"location":"03_use_cases/03_wiring_runtime/#related","title":"Related","text":""}]}