Repository navigation
Architecture Internals
How interpose works inside: for contributors, reviewers and AI assistants. User-facing behaviour belongs on the other pages.
Four modules and no global state. A consumer's config.py builds one Settings at import, which only validates the declarations and binds the prefix. All I/O happens when resolve() is called.
flowchart LR
C[consumer config.py] -->|declares| S[Settings]
S -->|resolve| Y[YAML file, or the project's layers]
S -->|resolve| E[environment and _FILE]
S --> R[Resolved: values + sources]
R -->|secret=True| SEC[Secret]
C -->|read_secret_dir| SEC
| Module | Responsibility |
|---|---|
interpose/__init__.py |
Package version and description |
interpose/pipeline.py |
Policy, Entry, Pipeline, Run, Failure, HookFailed: the middleware loop and the guard around every hook |
interpose/settings.py |
Setting, Settings, Layer, Resolved, Section, ConfigError, ConfigWarning, coercion, sections and secret leaves, the single-value checks |
interpose/secret.py |
Secret, MASK, read_secret_dir
|
interpose/metrics.py |
Label, Counter, Gauge, Histogram, Registry, CONTENT_TYPE: Prometheus text exposition |
A project builds a Pipeline once (its chain and its Policy) and calls start() per unit. The Run holds what applies, what has failed, what ran (with its context) and whether a before phase stopped. Every hook call goes through one guard:
- Skip if the entry has failed (observers: unless
observe_failed) orskip_inheritedsays the hook isn't overridden: neither the middleware's class nor the instance itself defines it (0004). That is worked out once per entry when thePipelineis built, so the guard asks a dict and a set (0008); a hook assigned after the build isn't seen. Ineachandbefore, such a hook counts as the base's no-op returningNone, sountilis asked as if it had run. - Time it into
timings[name]withtime.perf_counter, failed calls included. - Bound it with
asyncio.timeout(the entry's timeout, else the policy's;NO_LIMITmeans none, and with no limit the hook is awaited directly). Only the guard's own limit is a timeout (expired()); aTimeoutErrorthe hook raises is an ordinary failure. A hook that returns after its limit is a timeout too, its result discarded (0005):late="swallowed"if the deadline fired (expired()), so the hook caught its cancellation;late="overran"if it never fired but the loop clock is pastwhen(), so the hook ran synchronous code past the deadline. Both use publicasyncioAPI, andasyncio.timeout's exit withdraws its cancel request, so nothing is left pending for the caller. - Catch
Exception.answersare re-raised (not by observers). Anything else, or a resultvalidrefuses (or avalidthat raises), is a failure: the entry leavesactive,on_failuregets aFailure, and"fail"raisesHookFailedfrom the hook's exception;"skip"returnsSKIPPED.
A timeout cancels the hook at its current await, so hooks must be cancel-safe (try/finally, async with) and must never swallow CancelledError or BaseException; blocking or CPU-bound work belongs off the event loop, since a timeout can't interrupt synchronous code, and a thread from run_in_executor or asyncio.to_thread keeps running after its await is cancelled.
Every hook is awaited in turn on the caller's task. Nothing is scheduled with create_task, which is what carries backpressure from a slow hook back to the reader. Nothing is logged: on_failure is where the project logs with its unit ID. after_pairs() applies the after-phase policy: what ran, minus what failed, in after_order, and nothing after a stop unless after_local.
Each phase awaits the guard directly with the hook's arguments as a tuple (one coroutine frame per call, no partial), the guard enters asyncio.timeout only when a limit applies, and Run.active is cached until an entry fails or entries changes (0006). uv run benchmarks/bench-pipeline.py measures it; run it again with PYTHONPATH=<other checkout>/src to compare. Reference numbers (one workstation, Python 3.13, a no-op hook):
| Case | v0.1.4 | v0.1.5 |
|---|---|---|
bare await
|
0.09 µs | 0.09 µs |
call, no timeout |
1.5–1.6 µs | 0.7 µs |
call, 5 s timeout |
3.3–3.5 µs | 3.2–3.5 µs |
each over five middleware, no timeout (bare loop 0.5 µs) |
9.1–9.5 µs | 2.9–3.0 µs |
each over five middleware, 5 s timeout |
20.4–20.8 µs | 17.5–18.2 µs |
start + before + after, five middleware, no timeout |
20.4–20.5 µs | 7.6–8.3 µs |
With a limit, asyncio.timeout (a timer scheduled and cancelled per call) is most of the cost. With skip_inherited, a hook a middleware leaves alone isn't awaited at all, and since 0008 the check costs less than the guarded call it saves. A unit (start + before + after) through sluice's two five-middleware chains, no timeout:
| Chain | every hook awaited |
skip_inherited, v0.2.0 (per call) |
skip_inherited, resolved once (0008) |
|---|---|---|---|
| typical (2 of 10 hooks inherited) | 8.0–8.5 µs | 9.6–10.3 µs | 7.8–8.2 µs |
five pre-only gates (5 of 10 inherited) |
7.9–8.3 µs | 8.9 µs | 6.7 µs |
Settings.resolve(path, environ, *, layers):
-
The layers. Without
layers, the one layer is the file (read_file):path, elseenviron[<PREFIX>CONFIG], elsedefault_path, with source"file"andwherethe path. Missing or empty means{}. A top level that isn't a mapping, unreadable, not UTF-8 or invalid YAML is aConfigError. A YAML error gives the line and column only, because pyyaml's own message quotes the line, which may hold a secret. Withlayers, no file is read: the project's layers take its place, first one first (a control-plane payload, then the local file, say: 0009). -
Each setting, in declaration order, takes its raw value from the first of
<ENV>_FILE(the file's text, trailing whitespace stripped),<ENV>, each layer's value at the dotted key in order, or the default. A YAMLnull, or a scalar where a mapping is expected, counts as absent, so the next layer is tried. A setting withaliasesis looked up under its key and every alias together at each of those levels: the_FILEvariables, then the variables, then each layer. The first level where any is set decides, so an alias's variable beats the key in the file. When several are set there, each is coerced and they must be equal, elseConfigError("<key>: set as both '<key>' and its old name '<alias>' (from <where>), to different values"), never with the values (for variables, the names are the variables' and<where>isthe environment); the key's value, else the first alias's, is the one read and checked (0009). The source is recorded: the variable (an alias's, if that was read), the layer'ssource("file"for the file), or"default". For a value from a layer, so is where it came from:origins[key]if the layer has it, else itswhere. A layer'sdatais only read. -
Expansion, only for a value from a layer with
expand=True(the file's, when the project passesSettings(expand=True)) 0009. It comes once the level is chosen, so an expanded empty string doesn't fall through to the next layer, and before coercion and the alias comparison, so"${PORT:-5000}"coerces to 5000 and a key and its alias are compared expanded and typed. Every string in the raw value is passed through one regular expression:$${,${NAME},${NAME:-default}(default up to the first}), or any other${, which is aConfigErrornaming the key and its position, never the text. Mapping keys and non-strings are untouched. A variable is looked up inresolve()'senviron; unset with no default is aConfigErrornaming the key, the variable and where, or the empty string withexpand_unset="empty". No message holds a variable's value. Variables,_FILEcontents and defaults are never expanded, and nothing an unknown key holds is looked at. The value as written is kept for errors: a coercion error shows it in place of the expanded one, and a check's reason has the expanded value replaced by it (item by item for a list; an item a variable's comma split off is masked), so a non-secret value embedding a secret variable shows${PASS}, not the password. -
Coercion to the setting's type. Anything else is a
ConfigErrornaming the key, the type and where the value came from (the variable,default, or the layer'swhere, which for the file is its path):Type Accepts Refuses (the drifted cases) bool a bool; 1/true/yes/on,0/false/no/offin any case, as a string or a YAML 1/02,1.0, listsint an int; a string of ASCII digits with an optional sign a float ( 0.5was a silent0), a bool (truewas1),"1_000"str a scalar: string, int, float, bool, date a list or mapping (was its repr), a set, binary list a YAML list of scalars, or a comma-separated string; items stripped, empties dropped a mapping, nested lists, a bare YAML number or bool Strings from the environment and
_FILEgo through the same rules, so"0.5"failsinteverywhere. pyyaml types YAML 1.1 values first:yesis alreadyTrue,1_000is already1000. -
check, on every value, the default included. ItsValueErrorbecomesConfigError("<key>: <reason>"), then(from <where>)for a value from a layer, with a secret value masked out of the reason. A value it returns replaces the coerced one if it is the setting's kind, else aConfigError; a secret setting's value is never replaced (0007). -
secret=Truewraps the value inSecret, after the check.A section (
is_section=True) takes the same order and alias rule with three differences, and a shape check and secret leaves in place of coercion (0009). Its_FILEnames a YAML file, parsed as the config file is (errors name the variable and path, never the text; empty is an empty section), and it is local configuration: its value expands with the project'sexpandand may hold secret leaves. Its plain variable (or an alias's) is never read: if set, even beside a_FILE, it is aConfigErrorsaying to use_FILE. Its default is an emptySection, or()for a list section. The chosen value is expanded (when its layer expands, keys never), then frozen: it must be a mapping (a list of mappings for a list section), every key at every depth a string (else aConfigErrornaming the path, the key by its YAML text:middleware.config.true), and every value a mapping, list or YAML scalar (else aConfigErrornaming the path and the type). Mappings becomeSections and lists tuples. A mapping that is exactly{secret_file: <path>}is a secret leaf: if the value came from a layer withoutsecret_files(onlyread_file()and a section's_FILEset it), it is aConfigErrornaming the section path and the layer, before anything is opened (decision E); otherwise its path (expanded first, where the layer expands) is checked: one starting with~is aConfigError, a relative one resolves against the value'sbase_dir(carried in_Foundwithsecret_files: the layer's, whichread_file()sets to the config file's directory, or a section's_FILE's own directory, never resolved through symlinks), and with nobase_dirit is aConfigError, unopened (0010). The file is then read into aSecret, trailing whitespace stripped, every resolve, and an unreadable file is aConfigErrornaming the path, the file as written and, for a relative one, the resolved path, never its contents. A section or list item that is itself a leaf is an error. A key and an alias set at one level are both frozen and compared withSectionequality, which compares types as well as values. The check gets the frozen value; its reason has expanded strings replaced by the text as written and every secret leaf's value masked, and a replacement must be aSection(a tuple of them for a list section).Resolvedrecords each section's kind, so an empty list section isn't taken for an empty list setting.A list section with fields (
fields=(...)) differs in two places. Its value's items are not frozen whole: each must be a mapping, and each declared field is looked up in it (nullis unset) and resolved as a setting of its kind, with the item path (schedule[2].cron) as its key in every message and the value's layer,where,expandandsecret_filescarried down. So a field expands only where its layer does, a section field's secret leaves follow decision E, and a{secret_file: ...}leaf as a secret field's value is read the same way (refused, unopened, from a layer withoutsecret_files). A missing optional field takes its default (checked, as a default is); a missing required one is aConfigError. Unknown fields are never expanded or typed. And it has a variable level: after its_FILE, its indexed variables<VAR>_<N>_<FIELD>and_FILEfor the key's and each alias's<VAR>, counted from 0 until anNwith no field variable set; each name with items is one candidate at that level, compared as a key and alias are.sourcesgives<VAR>_<N>_*.Settingsrejects any other variable of the form<VAR>_<digits>_<anything>, so no name is both. The list's check gets the typed items; its reason has expanded field strings replaced by the text as written. -
Unknown keys and variables (0009), once every setting has resolved. Each layer's tree is walked in document order: a dotted path is known if it is a setting's key or alias or a parent of one, and nothing below a setting's key or alias, or below a scalar, is looked at. An alias that is set (not
null) gives analiaswarning there, in document order, whether or not its value was the one read. Each unknown path gives oneConfigWarning, at its first unknown level; a non-string key (on:loads asTrue) is never known and is named by its YAML text, and neither is a key with a dot in it (listen.port:at the top level), which resolution never reads. Then each environment variable that starts with the prefix, by name, unless a setting reads it (its variable or_FILE, or an alias's), it is<PREFIX>CONFIGor it is inextra_env; with an empty prefix no variable is reported unknown. An alias's variable that is set gives analiaswarning in that order, with or without a prefix. Only names are read, never values. The suggestion isdifflib.get_close_matcheswith cutoff 0.75 over current names, never an alias: for a key, the keys and parents at the same depth (solistnesuggestslisten, andlisten.1never its own parent); for a variable, the variables settings read. The known names are sets built once inSettings, with aliases, their parents and their variables in them but not in the sets suggestions come from. A section's key and aliases are leaves, so nothing below one is checked: its content is the project's, except a list section's declared fields: each item that is a mapping has every key that is no field reported as anunknown field, suggesting the closest field. Items in a list section's_FILEare checked as it is read, and those warnings follow the layers'. A variable of a list section's indexed form is known when itsNis written plainly and its rest is a field's (CRON,CRON_FILE): it gives analiaswarning if it is an alias's, and anignoredwarning if itsNis past the end of its list, with or without a prefix. Any other variable of that form is unknown, suggesting that item's field variables under the current name.
Nothing is cached, and nothing is logged. The first error stops the resolve; warnings never do.
A project builds its metrics at startup, each with Label objects that hold the allowed values, and registers them in a Registry. Each inc/set/add/observe checks its label values against those sets before it touches a series, so a refused value leaves no trace. A gauge's read callback is checked when it renders. Registry.render() walks the metrics in registration order:
-
# HELP(with\and newline escaped) and# TYPE, then each series sorted by its label values; - label values escape
\,"and newline; integral numbers have no decimal point;+Inf,-Inf,NaN; - a histogram writes cumulative
_bucketlines withleas Python'sreprof the bound ("1.0"), thenle="+Inf",_sumand_count.
That is byte-for-byte mitm-proxy's output before it moved here (0002), which keeps its dashboards working.
Why things are the way they are is recorded in docs/design/. Rejected ideas and the reasons are in TODO.md under "Decided against".
- A library, pinned by GitHub tag, not a template file or PyPI (0001): fixes travel as version bumps.
-
Policies are required, not defaulted (0003): fail open or closed, after-hook order, after hooks on local answers and observers on failed middleware are each project's, so none inherits another's by omission. Per-middleware
on_errorand timeout cover the middleware whose role differs from the project default. 0003 also reconciles both consumers' "Decided against" lists; 0004 records Stephen's answers to its decisions and makesskip_inheritedinstance-aware. -
Settingsbinds the prefix (0001): a library can't hold a module-levelENV_PREFIXfor its caller, andsetting.envkeeps working for consumers' CLIs and wiki generators. - Strict coercion (0001): refuse what isn't the declared type, never convert silently.
-
Secretisn't astr(0001): it can't reach a log or an f-string as its value by accident. - Mechanism only (0001): rules that span settings, and policies, stay in each project.
-
Labels from declared sets (0002): an undeclared label value is a
ValueError, so "no unbounded labels" is enforced, not a comment. Transport (HTTP, a virtual file) stays in the project.
interpose | Shared mechanism for metalayerlab interceptors — a policy-configurable async middleware pipeline, the layered settings loader, Secret values, and Prometheus text exposition.
GitHub • Releases • Issues • Changelog • Contributing
This wiki is generated from docs/wiki/ on every push to main: edits made here are overwritten. Change it with a pull request.
- 🧅 One middleware loop; fail open or closed is policy, per project and per middleware
- 🥞 One lookup order:
_FILE> environment > YAML > default - 🧱 Strict types: no silent
0for a YAML0.5 - 🧭 Where each value came from, for every setting
- 🔒
Secret: masked in logs, reprs and errors - 📈 Prometheus text with labels from declared value sets