Repository navigation
Quick Start
From nothing to a working interpose in a few minutes. Everything else is in API Reference.
- For running from source: Python >= 3.13 and uv
- Access to GitHub: interpose is installed from a release tag, not from PyPI. It brings
pyyaml.
Pin a release tag, never main:
uv add "interpose @ git+https://github.com/metalayerlab/interpose@v0.1.0"To move to a newer release, change the tag in pyproject.toml and run uv lock. Dependabot can't do this for you yet (see FAQ).
A project's config.py declares its settings and turns the resolved values into its own Config:
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from interpose.secret import Secret
from interpose.settings import ConfigError, Setting, Settings, one_of, port
__all__ = ["CONFIG_ENV", "SETTINGS", "Config", "ConfigError", "load_config"]
SETTINGS = Settings(
"MYAPP_",
(
Setting(
"log_level", "INFO", "Log level.", check=one_of("DEBUG", "INFO", "WARNING", "ERROR")
),
Setting("listen.port", 8080, "Port the API listens on.", check=port),
Setting("upstream.token", "", "Token for the upstream API.", secret=True),
Setting("allow.hosts", "", "Hosts to allow.", is_list=True),
),
default_path=Path("/etc/myapp/config.yaml"),
)
CONFIG_ENV = SETTINGS.config_env # "MYAPP_CONFIG"
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class Config:
log_level: str
port: int
token: Secret
hosts: tuple[str, ...]
sources: Mapping[str, str] = field(default_factory=dict)
def load_config(path: Path | None = None, environ: Mapping[str, str] | None = None) -> Config:
r = SETTINGS.resolve(path, environ)
for warning in r.warnings: # unknown keys and variables: names only, never values
log.warning("%s", warning)
# Rules that span settings go here, raising ConfigError.
# Each accessor checks the setting's kind: r.str() on a secret setting is a TypeError.
return Config(
log_level=r.str("log_level"), # one_of gives the declared spelling: "debug" -> "DEBUG"
port=r.int("listen.port"),
token=r.secret("upstream.token"), # reveal() at the point of use
hosts=r.list("allow.hosts"),
sources=dict(r.sources),
)With this /etc/myapp/config.yaml:
listen:
port: 9090
allow:
hosts: [example.org, "*.example.org"]and MYAPP_UPSTREAM_TOKEN_FILE=/run/secrets/token in the environment, load_config() gives:
Config(log_level='INFO', port=9090, token=Secret('********'), hosts=('example.org', '*.example.org'),
sources={'log_level': 'default', 'listen.port': 'file',
'upstream.token': 'MYAPP_UPSTREAM_TOKEN_FILE', 'allow.hosts': 'file'})
config.token.reveal() gives the token at the point of use. A misspelt key, such as prot: under listen:, is ignored and logs unknown key 'listen.prot' in /etc/myapp/config.yaml: did you mean 'listen.port'?. A bad value stops it with one line, such as listen.port: expected int, got 0.5 (from /etc/myapp/config.yaml).
A project that passes Settings(..., expand=True) lets its config file use the environment: port: "${PORT:-9090}" takes $PORT, else 9090, and an unset ${HOST} with no default is an error. It is off by default.
Options that don't fit a fixed setting, such as each middleware's own, go in a section: Setting("middleware.config", "", "Per-middleware options.", is_section=True) takes the whole subtree under middleware: config: as a read-only Section, which the project validates in load_config. A secret inside it is written as a file, never inline:
middleware:
config:
ratelimit: {per_minute: 60}
auth: {token: {secret_file: /run/secrets/auth-token}}r.section("middleware.config")["auth"]["token"] is then a Secret. In Kubernetes, mount the section as a file and point MYAPP_MIDDLEWARE_CONFIG_FILE at it.
A list whose items have a fixed shape declares its fields, and interpose types each one:
Setting(
"schedule",
"",
"Cron schedule.",
is_section=True,
is_list=True,
fields=(
Setting("cron", "", "A 5-field cron expression.", required=True),
Setting("context", "", "The context to enqueue.", required=True),
),
)r.sections("schedule") is then a tuple of Sections, each holding cron and context. The list can also be set in the environment, item by item from 0: MYAPP_SCHEDULE_0_CRON="0 * * * *" and MYAPP_SCHEDULE_0_CONTEXT=hourly, then _1_.... Set this way, it replaces the file's list. A missing required field stops it with schedule[1].context: MYAPP_SCHEDULE_1_CONTEXT is not set, but MYAPP_SCHEDULE_1_CRON is.
For secrets that aren't settings (a middleware's API keys, say), read_secret_dir(Path("/run/secrets")) reads each file in a directory as a Secret.
- API Reference: the public API
- Troubleshooting if something doesn't work
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