Repository navigation
API Reference
Import from the submodules: interpose.pipeline, interpose.settings, interpose.secret and interpose.metrics. The package itself exports only __version__ and __description__. Anything starting with _ is internal.
The middleware loop and the guard around every hook. interpose defines no hooks and no unit of work: the project keeps its own Middleware class and unit (a flow, a call), and passes invoke callables that call its hooks.
Policy(on_error, after_order, after_local, observe_failed, *, timeout=None, answers=(), skip_inherited=None): the project's answers, a frozen dataclass. The first four have no default.
-
on_error:"skip"(a failing middleware is out for the rest of the unit, which carries on) or"fail"(HookFailedis raised), unless the middleware says. -
after_order:"chain"(as registered) or"reverse"(the onion). -
after_local: whether after hooks run when a before hook stopped the unit (answered locally). -
observe_failed: whether observer hooks still reach a middleware that has failed. -
timeout: seconds per hook call, unless the middleware says;Noneis no limit. -
answers: exception types a hook raises as its answer (a deny), re-raised unchanged whateveron_errorsays. -
skip_inherited: the project's base class, whose hooks must do nothing; a hook a middleware doesn't override, in its class or on the instance (mw.pre = gatecounts as an override), isn't called (no timing). Ineachandbeforeit counts as returningNone, sountilis still asked (a stop that reads the unit's state still happens there); elsewhere it givesSKIPPED. Overrides are resolved once, when thePipelineis built (0008): a hook assigned after that, on the instance or its class, isn't seen, so set instance hooks before building it. - Raises
ValueErrorfor a value outside these.
Entry(middleware, name, on_error=None, timeout=None): one middleware in a chain with its own policy (None: the project's; a timeout of NO_LIMIT opts it out of the project's limit). Compared by identity. Entry.of(middleware, name=None) reads the middleware's name, on_error and timeout attributes; name= wins. name_of(middleware): its name attribute if a non-empty str, else its class name.
Pipeline(middleware, policy): a frozen chain (middleware or Entrys) and its policy. entries, names, policy.
-
start(*, select=None, select_hook="select", on_failure=None, timings=None, state=None) -> Run: one unit's run.select(middleware) -> boolpicks what applies, once, now; one that raises fails like a hook namedselect_hook.on_failure(Failure)is called for every failure (log and count it there).timingsandstate, if given, are filled in place.
Run: one unit's pass. entries (selected), active (minus failed), timings (seconds per middleware name), state, ran ((entry, context) pairs), stopped, policy, has_failed(entry).
| Method | Calls | Over (default) | Returns |
|---|---|---|---|
call(entry, hook, invoke, *, valid=None) |
invoke() |
one entry | the result, or SKIPPED
|
each(hook, invoke, *, entries, until, valid) |
invoke(mw) until until(result)
|
active |
Stopped(entry, result) or None
|
fold(hook, value, invoke, *, entries, valid, keep_none=False) |
invoke(mw, value); the result replaces value
|
active |
the value |
observe(hook, invoke, *, entries) |
invoke(mw); always fails open |
entries |
None |
before(hook, context, invoke, *, entries, until, valid) |
invoke(mw, context), recording (entry, context) in ran first; a stop is kept in stopped
|
active |
Stopped or None
|
after_pairs() |
ran in after_order, minus failed; [] after a stop unless after_local
|
the pairs | |
after(hook, value, invoke, *, valid, keep_none=False) |
invoke(mw, context, value) |
after_pairs() |
the value |
observe_ran(hook, invoke, *, pairs) |
invoke(mw, context); always fails open |
ran in after_order
|
None |
scoped(entry) |
a ScopedState
|
-
The guard, around every call: skips a failed entry (observers: unless
observe_failed) or an inherited hook; times the call intotimings[name]; bounds it with the entry's or the policy's timeout; re-raisesanswers(not in observers); anything else it raises, or a resultvalidrefuses (or avalidthat raises), is a failure: the entry leavesactive,on_failureis called, and under"fail"HookFailedis raised (under"skip",SKIPPEDis returned).CancelledErroris never caught. A hook that returns after its limit (it ran past the deadline in synchronous code,late="overran", or caught its cancellation,late="swallowed") has its result discarded: it is a timeout failure (timed_out=True, aTimeoutError), handled byon_error. -
Hooks and timeouts: a timeout cancels the hook at its current
await. Hooks must be cancel-safe (cleanup intry/finallyorasync with) and must never swallowCancelledErrororBaseException. Blocking or CPU-bound work goes off the event loop: a timeout can't interrupt synchronous code. A thread started withrun_in_executororasyncio.to_threadkeeps running after itsawaitis cancelled. -
keep_none=Truemakes aNoneresult keep the value (a chunk hook that passes a chunk on). -
untilis the project's predicate and must not raise. Under"fail",start()raisesHookFailedwhen aselectfails.
Failure(middleware, hook, error, timed_out, action, timeout, late=None): what failed, with what, and whether the guard's timeout did it; action is "skip" or "fail"; timeout is the limit that applied (seconds, or None); late (Late) is "overran" or "swallowed" for a result that came back after the limit, else None. Holds no unit data.
HookFailed: raised under "fail". failure is the Failure; __cause__ is what the hook raised (a TimeoutError for a timeout). The message names the middleware and the hook, never a value.
InvalidResult: the Failure.error for a result valid refused; names the result's type only.
Stopped(entry, result): where a phase stopped. SKIPPED: a call that gave no result (falsy). ScopedState(state, name): a MutableMapping view storing keys as "<name>.<key>".
require_async(middleware, hooks, *, name=None): TypeError if any of hooks the middleware has isn't async def, for refusing sync hooks at registration.
from interpose.pipeline import HookFailed, Pipeline, Policy
POLICY = Policy(on_error="fail", after_order="reverse", after_local=True, observe_failed=True)
PIPELINE = Pipeline(chain, POLICY) # built once
async def handle(ctx):
run = PIPELINE.start(select=lambda mw: mw.applies(ctx), state=ctx.state)
try:
await run.before("pre", ctx, lambda mw, c: mw.pre(c), until=lambda _: ctx.responded)
result = ctx.response if run.stopped else await operation(ctx)
return await run.after("post", result, lambda mw, c, r: mw.post(c, r))
except HookFailed as e:
await run.observe_ran("failed", lambda mw, c: mw.failed(c, e))
raise MyDeny(e.failure.middleware) from eSetting(key, default, help, *, secret=False, is_list=False, check=None, aliases=()): one setting, a frozen dataclass.
-
key: dotted lower case,[a-z0-9_]+joined by dots (listen.port). -
default: astr,intorbool. Its type is the setting's type. A list setting's default is"". -
help: Markdown, as users read it in the generated settings table. -
secret: the value resolves to aSecret. Only for str settings. -
is_list: the value is atuple[str, ...], from a YAML list or a comma-separated string. -
check: a callable that gets the coerced value and raisesValueError(reason)if it's invalid. It may return a replacement of the setting's kind (astr, anintthat isn't abool, abool, atupleofstr);Nonekeeps the value. Returning the value unchanged keeps it. A replacement of another kind is aConfigErrornaming the key and the type, never the value. A check on a secret setting must returnNone(or the value unchanged). -
aliases: a tuple of old dotted keys for a renamed setting (aliases=("port",)onlisten.port). Each is read wherever the key is: in every layer, as<PREFIX><ALIAS>and as<PREFIX><ALIAS>_FILE. At each level of the lookup order the key and its aliases are looked up together: if one is set it is used; if several are, their coerced values must be equal (the key's is the one read), or it is aConfigErrornaming both, never the values. A higher level beats a lower one whatever the name. Each alias that is set gives analiaswarning, used or not. Public, so aconfigcommand or wiki table can show "formerlyport" (0009). -
env(property): the environment variable, the prefix plus the upper-cased key with dots as underscores (MYAPP_LISTEN_PORT).prefixis set bySettings, never by hand.
Settings(prefix, settings, *, default_path, extra_env=(), expand=False, expand_unset="error"): a Sequence[Setting], each one bound to prefix.
-
config_env:prefix + "CONFIG", the variable that names the YAML file. Alsoprefix,default_path,extra_env,expandandexpand_unset. -
extra_env: names of prefixed variables the project reads itself (("MYAPP_DEBUG_DUMP",)), so they give no unknown-variable warning. Each must start with the prefix, appear once, and not be a variable a setting already reads (or<PREFIX>CONFIG). -
expand:Trueturns on${VAR}expansion in the config file:read_file()gives its layerexpand=True. Off by default, so an existing${in a file keeps its meaning. In a value a setting reads from that layer,${NAME}is the variable's value and${NAME:-default}the default when the variable is unset or empty, as in the shell.NAMEis[A-Za-z_][A-Za-z0-9_]*; the default is literal text up to the first}(nothing in it expands).$${is a literal${, and a$not followed by{is literal. Any other${(unclosed,${},${vars.x}) is aConfigErrornaming the key: write$${for a literal. The variables areresolve()'senviron, read on every resolve. Every string in the value expands, inside longer strings and in list items, never mapping keys; non-strings are untouched, and typing comes after, soport: "${PORT:-5000}"is the int 5000. Only what a setting reads expands: an unknown key's${never fails a load. Variables,_FILEcontents, defaults and aLayerthe project builds withoutexpand=Truenever expand.sourcesstill says"file"0009. -
expand_unset: what a${NAME}with no default gives whenNAMEis unset."error"(the default) is aConfigError:upstream.url: ${HOST} is not set and has no default (from /etc/myapp/config.yaml)."empty"gives the empty string, for a project whose files relied on that. Anything else is aValueError. - Raises
ValueErrorfor a bad or duplicate key, a key part YAML doesn't read as a string (on,no,null,1), two settings that would read the same variable (a.banda_b;x_fileandx's_FILE; a keyconfig), an alias that is malformed, isn't a tuple, repeats (in its setting or another), equals a key, is a parent of a key or alias or below one, or reads a variable another setting or alias reads, a list setting whose default isn't"", a secret setting that isn't a str setting, anextra_envname that breaks its rules, or anexpand_unsetother than"error"or"empty". -
resolve(path=None, environ=None, *, layers=None) -> Resolved: readspath(else$<PREFIX>CONFIG, elsedefault_path; missing means defaults) andenviron(elseos.environ), and resolves every setting. RaisesConfigError.-
layers, a sequence ofLayer, replaces the file: no file is read, and each setting takes the first of its_FILEvariable, its variable,layers[0],layers[1], ..., its default.layers=[]means the environment and defaults only. Passingpathwithlayersis aValueError. Withoutlayers,resolve(path, environ)isresolve(environ=environ, layers=[read_file(path, environ)])(0009).
-
-
read_file(path=None, environ=None) -> Layer: the config file as aLayerwith source"file",whereits path andexpandthe project'sexpand, found asresolve()finds it. Nothing expands here:resolve()expands what each setting reads. A missing file is an empty layer; an unreadable or invalid one is the sameConfigErrorresolve()gives.
r = SETTINGS.resolve(Path("config.yaml"), {"MYAPP_LISTEN_PORT": "9090"})
r.values["listen.port"], r.sources["listen.port"] # (9090, "MYAPP_LISTEN_PORT")
# Managed values first, then the local file (layers replace the file, so it's passed too).
payload = Layer(
{"listen": {"port": 9443}}, source="control plane", where="control plane (config v12)"
)
r = SETTINGS.resolve(layers=[payload, SETTINGS.read_file()])
r.sources["listen.port"] # "control plane"Layer(data, source, where, origins={}, expand=False): values from one place, a frozen dataclass.
-
data: a YAML-shaped tree, whatyaml.safe_loadgives for a config file.resolve()reads it, and never changes or keeps it. -
source: whatResolved.sourcessays for a value from this layer ("file","control plane"). -
where: what an error names for a value from this layer ("/etc/myapp/config.yaml","control plane (config v12)"). -
origins: dotted key to a finerwherefor that key's value ({"listen.port": "payload.listen.port"}); an error uses it beforewhere. -
expand: whether${VAR}in the values settings read from this layer expands, as described underSettings(expand=...).read_file()sets it to the project'sexpand, and is the only place interpose sets it. Never set it on a layer built from a remote payload, such as a control plane's: it would let the sender read the process's environment (Security).
Resolved(values, sources, warnings=()): frozen, read-only mappings keyed by setting key, in declaration order, and the warnings.
-
values[key]: the typed value, aValue(str | int | bool | tuple[str, ...] | Secret). -
sources[key]:"<PREFIX><KEY>_FILE","<PREFIX><KEY>","file"(or, withlayers, the layer'ssource) or"default". For a value read under an alias, the alias's variable ("MYAPP_PORT","MYAPP_PORT_FILE"), or the layer'ssource. -
warnings: a tuple ofConfigWarning, in the order found: each layer's unknown keys and set aliases in document order, then unknown prefixed variables and set alias variables by name. Empty for a clean configuration. interpose never logs, prints or raises them: log each at WARNING on startup and on every reload, or raise if you want strictness (0009). -
Typed accessors
str(key),int(key)(never abool),bool(key),list(key)(atuple[str, ...]) andsecret(key)(aSecret) give a value with its type instead of acast(). Each raisesTypeErrornaming the key when the setting is another kind; on a secret setting, every one butsecret()does, so a masked"********"can't reach a credential. A key that isn't declared is aKeyError.
r.str("log_level") # "INFO"
r.int("listen.port") # 9090
r.secret("upstream.token").reveal()
# TypeError: 'upstream.token' is a secret setting, not str: use secret() and reveal()
r.str("upstream.token")ConfigWarning(kind, name, message): something resolution ignored, a frozen dataclass; str() gives message.
-
kind:"unknown"for a key in a layer that is neither a setting's key nor a parent of one, or a variable that starts with the prefix but that no setting reads (its variable, its_FILE,<PREFIX>CONFIG,extra_env, an alias's variables)."alias"for an alias that is set, in a layer or as a variable, whether its value was used or shadowed by a higher level."ignored"is reserved for a later release. -
name: the key (dotted, at its first unknown level:listne, not each key below it; a non-string YAML key by its YAML text,trueforon:) or the variable; for an alias, the alias or its variable. -
message: one line naming the key or variable and where, with the closest current name when one is close enough (difflib, cutoff 0.75), never a value:unknown key 'listen.prot' in /etc/myapp/config.yaml: did you mean 'listen.port'?,unknown variable 'MYAPP_LISTEN_PROT' in the environment: did you mean 'MYAPP_LISTEN_PORT'?. For a layer, where isorigins[key]when present, else itswhere. An alias warning names the old name and the current one:'port' (in /etc/myapp/config.yaml) is the old name of 'listen.port': rename it,'MYAPP_PORT' (in the environment) is the old name of 'MYAPP_LISTEN_PORT': rename it. Suggestions never offer an alias.
ConfigError: the configuration can't be read, or a value in it is invalid. The message names the key, the expected type and where the value came from (the variable, default, or for a value from the file or a layer its path or where: listen.port: expected int, got 'x' (from /etc/myapp/config.yaml)), and never holds a secret setting's value.
Checks, for check=. Each raises ValueError, which resolve() turns into ConfigError("<key>: <reason>"), followed by (from <where>), after a space, when the value came from the file or a layer; one_of also replaces the value:
| Check | Passes |
|---|---|
port |
an int from 1 to 65535 |
positive |
an int above 0 |
non_negative |
an int, 0 or more |
one_of(*choices) |
a str equal to one of choices, in any case; the value becomes the declared spelling (info → INFO) |
Secret(value: str): a secret value.
-
str(),repr(),format()and f-strings give********(MASK);reveal()gives the value. - Equal to another
Secretwith the same value (compared in constant time), never to a plainstr. Hashable. Falsy when the value is empty. - Raises
TypeErrorfor anything but astr.
read_secret_dir(directory: Path) -> Mapping[str, Secret]: each regular file in directory (symlinks followed) as a Secret, keyed by file name, minus one trailing newline. Dot-entries are skipped, which covers a Kubernetes secret volume's ..data. A missing directory gives an empty mapping. Raises ConfigError if the directory or a file can't be read.
MASK: "********".
Prometheus text exposition (format 0.0.4). Every label takes its values from a declared set, so the series count is fixed by code and configuration, never by traffic.
Label(name, values=()): a label name and the only values it may take.
-
name: matches[a-zA-Z_][a-zA-Z0-9_]*, not starting with__. -
values: an iterable ofstr(a list, not a bare string:Label("kind", "ca")is aTypeError); read back as afrozenset[str].value in labeltests one. -
declare(*values): allow more values, from configuration (the middleware names a reload brings), never from measured data. Values are never removed. OneLabelcan be shared by several metrics, so onedeclarecovers all of them.
Counter(name, help, labels=()): inc(*values, amount=1.0). amount must be finite and zero or more.
Gauge(name, help, labels=(), read=None): set(value, *values), add(amount, *values). With read (a callable returning {label values: number}), the samples come from it when the gauge renders.
Histogram(name, help, buckets, labels=()): observe(value, *values). Classic cumulative buckets (inclusive), plus _sum and _count; +Inf is added for you. Buckets are finite, distinct and sorted for you. NaN is refused. samples() gives each label set's count.
-
labelsis a sequence ofLabel. Each call passes one value per label, in order. A value outside its label's set, or the wrong number of values, raisesValueError, which names the metric and the label but never the value. - Declaration errors are
ValueError(orTypeErrorfor a label that isn't aLabel): a bad metric or label name, a repeated label name, a histogram label calledle, bad buckets.
Registry(metrics=()): the metrics one endpoint exports.
-
register(metric) -> metric: adds it and returns it. A second metric with the same name, or one whose samples clash with another's (a counterfoo_countnext to a histogramfoo), raisesValueError. - Iterates in registration order;
len()counts the metrics. -
render() -> str: every metric's# HELP,# TYPEand samples (sorted by label values), joined by newlines and ending in one;""when empty.
CONTENT_TYPE: "text/plain; version=0.0.4; charset=utf-8", for an HTTP response's Content-Type.
from interpose.metrics import CONTENT_TYPE, Counter, Label, Registry
OUTCOME = Label("outcome", ["ok", "denied", "error"])
REGISTRY = Registry()
CALLS = REGISTRY.register(Counter("myapp_calls_total", "Calls, by outcome.", (OUTCOME,)))
CALLS.inc("ok")
CALLS.inc("example.org") # ValueError: label 'outcome' got a value outside its declared set
body = REGISTRY.render() # serve with CONTENT_TYPE, or as a fileinterpose doesn't serve the text: an HTTP endpoint or a virtual file is the project's.
The public API follows semantic versioning: breaking changes only in a major release (a minor one before 1.0), and they're announced in the CHANGELOG.
Everything above is stable within 0.1.x. The message text of ConfigError and ValueError is for people: match on the exception type, not the words.
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