Skip to main content

EnvPit — Python SDK

Official Python SDK for EnvPit — configuration & secrets management without enterprise complexity.

Quickstart

The fastest way in: make your EXISTING os.environ-reading code work untouched — no client object, no new API to learn.

pip install envpit
export ENVPIT_API_KEY="epk_..."   # an environment-pinned key: Project → API Keys → New key
import envpit
import os

envpit.load().populate_environ()        # fetch once, merge into os.environ (opt-in, explicit)
print(os.environ["DATABASE_URL"])        # your existing os.environ-reading code, unmodified

populate_environ() never overwrites a variable your platform already set (same precedence as python-dotenv) — pass override=True if EnvPit values should win instead. It's a one-shot, boot-time snapshot: EnvPit's realtime refresh updates the client's own in-memory values immediately, but can't retroactively update os.environ (nothing can — this is true of os.environ/Spring @Value in every language, not an EnvPit limitation). Code that needs guaranteed-live values should read through the client instead — see "Client API" below.

Secrets are excluded by default. The server labels each config key is_secret=true/false (bd:envpit-durd); populate_environ() reads that flag and skips every secret-flagged key unless you pass include_secrets=True — the zero-argument call is the safe one. Env vars are inherited by every child process, are often serialized into crash dumps/APM//proc/<pid>/environ, and are logged by some startup scripts, so opting a whole environment's secrets into os.environ is a real exposure — naming include_secrets=True at the call site is the acknowledgment of that. only=/exclude= additionally narrow the merge by name (an allowlist/denylist, mirroring the Go SDK's WithOnly/WithExclude) — neither can pull a secret through without include_secrets=True also being set.

result = envpit.load().populate_environ(
    override=False,          # default: existing os.environ values always win
    include_secrets=False,   # default: server-flagged secret keys are never merged
    exclude={"LEGACY_KEY"},  # keep specific non-secret keys out too, by name
)
result.merged            # sorted tuple of key NAMES actually written
result.skipped_existing  # sorted tuple of key NAMES left alone (already present, no override)
result.skipped_secrets   # sorted tuple of key NAMES excluded because they're secret-flagged

Client API (in-memory reads, no os.environ)

import envpit

client = envpit.load()                  # fetches your environment's config once (blocking)
print(client.get("DATABASE_URL"))       # in-memory read — never a network call

Run it — you should see your value printed. That's it: client.get() reads from memory; the snapshot auto-refreshes in the background.

Using FastAPI or another asyncio app? load() only blocks once, at startup — call it in your lifespan/startup hook, or use await asyncio.to_thread(envpit.load) if you'd rather not block the loop even there. Every read after that is a plain in-memory lookup.

Framework integrations

Each framework gets the mechanism its own developers actually expect (bd:envpit-yvyr) — not one generic API forced onto all three.

FastAPI — Pydantic Settings (pip install envpit[fastapi]). FastAPI's real idiom is pydantic_settings.BaseSettings, not os.environEnvpitSettingsSource is a genuine PydanticBaseSettingsSource, wired via settings_customise_sources():

from pydantic_settings import BaseSettings
from envpit.integrations.fastapi import EnvpitSettingsSource

class Settings(BaseSettings):
    database_url: str
    port: int = 8080

    @classmethod
    def settings_customise_sources(cls, settings_cls, init_settings, env_settings,
                                    dotenv_settings, file_secret_settings):
        return (EnvpitSettingsSource(settings_cls), init_settings, env_settings,
                dotenv_settings, file_secret_settings)

settings = Settings()   # database_url/port resolved from EnvPit, case-insensitive, env_prefix honored

Flask — app.config (pip install envpit[flask]):

from flask import Flask
from envpit.integrations.flask import init_app

app = Flask(__name__)
init_app(app)   # merges the EnvPit snapshot into app.config

Django — settings.py, no extra install. Django has no plugin hook for external settings sources (verified — its settings loader just imports settings.py as a plain module); the accepted idiom (the one django-environ also uses) is populating values at the top of the file, before the rest of it reads them:

# settings.py
import envpit
from envpit.integrations.django import load_into_settings

load_into_settings(globals(), client=envpit.load())

DEBUG = DEBUG == "true"   # every EnvPit value is a string — typed post-processing is yours

All three integrations share populate_environ()'s precedence rules (existing value wins unless override=True) and its secret-exclusion default: server-flagged secrets are excluded unless include_secrets=True is passed to init_app()/load_into_settings(). FastAPI's EnvpitSettingsSource follows the same default via its own include_secrets=True constructor argument (an excluded secret field simply falls back to that field's own default, same as an absent key).

API

import envpit
from envpit import EnvpitClient

client = EnvpitClient.load(
    api_key=None,        # falls back to the ENVPIT_API_KEY environment variable
    host=None,            # default: https://envpit.com
    poll_interval=60.0,   # seconds; 0 disables ALL background refresh, including realtime
    timeout=5.0,           # seconds, per request
    logger=None,           # a stdlib logging.Logger, or any object exposing a subset of
                            # debug/info/warn/error — absent = silent
)

client.get("DATABASE_URL")                      # str; raises MissingKeyError if absent
client.get("DATABASE_URL", "postgres://local")  # str with a default — never raises
client.get_int("PORT", 8080)                     # int; raises TypeMismatchError if unparsable
client.get_bool("MAINTENANCE_MODE", False)       # bool: true/1/yes/on, false/0/no/off (ci)
client.get_optional("PORT")                      # str | None — the only getter that never raises

client.snapshot()                                # dict[str, str | None] — defensive copy, in-memory only (values only)
client.known_secret_keys()                       # frozenset[str] — server-flagged secret key NAMES only
client.populate_environ(                         # see Quickstart above; returns a MergeResult
    override=False, include_secrets=False, only=None, exclude=None, environ=None,
)

unsubscribe = client.on_change(lambda event: ...)   # event: ChangeEvent (key names only)
client.on_connection(lambda event: ...)             # event: ConnectionEvent
client.on_error(lambda event: ...)                  # event: an EnvpitError subclass instance

client.cache_info   # CacheInfo — fetched_at / age_ms / last_error / etag / refresh_mode / ...
client.close()       # stops background refresh; also a context manager:
with EnvpitClient.load() as client:
    ...

The module-level envpit.load() sets a default instance so envpit.get(...) / envpit.get_int(...) / envpit.on_change(...) etc. delegate to it — convenience sugar over the same client class shown above.

Listener registration only accepts plain (sync) callables — an async def callback raises a TypeError immediately at registration, with a message telling you how to bridge it (asyncio.run_coroutine_threadsafe). This is deliberate: a sync dispatcher silently never awaiting your coroutine would otherwise fail invisibly.

Errors

Every SDK error is a subclass of envpit.EnvpitError:

Class When
AuthenticationError No API key found, or the server rejected it (HTTP 401/403)
NetworkError DNS/connect/timeout, a non-2xx response, or an invalid/oversized response
MissingKeyError get*() called for a key that isn't set and no default was given (.key)
TypeMismatchError get_int/get_bool couldn't parse the stored value (.key, .expected_type)
try:
    port = client.get_int("PORT")
except envpit.MissingKeyError as e:
    ...
except envpit.EnvpitError as e:   # catch-all
    ...

No config value or API key ever appears in an error message or log line (one narrow, documented exception: TypeMismatchError echoes the offending raw value itself, e.g. got "abc" — that value is what you passed to a typed getter, never a secret by construction of the typed-getter path).

Caching & resilience

  • Memory-only. Nothing is ever written to disk — no cache file, no temp file, no pickle/shelve.
  • Stale-while-revalidate: a background refresh failure never raises from get*() — the last good snapshot keeps serving reads, and the failure is recorded on client.cache_info.
  • Realtime push (SSE) is an optimization; the poll_interval timer is always the correctness backstop, independent of the realtime channel's health.
  • One daemon poll thread + one daemon realtime thread — neither keeps your process alive.

Security notes

  • Auth is sent as X-Api-Key, never Authorization — a separate trust boundary from any session auth your app has.
  • No SDK option exists to skip TLS verification. Python's urllib verifies certificates by default; this SDK never disables that.
  • repr(client)/str(client)/print(client) never include the API key or any config value — both are always shown redacted.
  • Response bodies and realtime stream lines are size-capped (5 MiB / 64 KiB respectively) so a misbehaving or compromised server can't exhaust client memory.
  • The config-resolve response must be the post-bd:envpit-durd {values, secretKeys} envelope — a pre-durd bare {key: value} map is rejected as a NetworkError, not silently accepted (it would otherwise read as "no secrets here" and merge production secrets while reporting none excluded). There were zero published SDK releases before this shape shipped, so this only matters if you're pointed at a very old self-hosted EnvPit server.

Requirements

Python ≥ 3.10. Zero runtime dependencies — stdlib urllib/json/threading/hashlib only. envpit.integrations.* is opt-in and never imported by the core package: envpit.integrations. fastapi needs pip install envpit[fastapi] (pydantic-settings), envpit.integrations.flask needs pip install envpit[flask] (flask); envpit.integrations.django needs no extra install.

Release files for envpit 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for envpit 0.1.0
File Size Uploaded
envpit-0.1.0.tar.gz 66.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for envpit 0.1.0
File Interpreter ABI Platform
envpit-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 109.6 kB

Release files / envpit-0.1.0.tar.gz

Download URL envpit-0.1.0.tar.gz
Size 66.4 kB
Tags Source
SHA-256 checksum
How to use checksums
9a549fd74b402cc7c257d23b213507543aa332f9ebb35d7e0e75997e8c01169f
BLAKE2b-256 checksum
How to use checksums
9dad91043f268d69c263b5b0b18ad8d37f04596b9436cbfb7864426603bd21d3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.4

Release files / envpit-0.1.0-py3-none-any.whl

Download URL envpit-0.1.0-py3-none-any.whl
Size 43.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ba3ec99510eb81d612961d0d0f298c0984bc054bccf7aad36bc610fb7890174a
BLAKE2b-256 checksum
How to use checksums
fa20d1d6da5f3c311170040c393cfde0cafce2b057d71894d2987d9c3c6dd356
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.4

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page