Skip to main content

Typed, dependency-free environment variable loading: read env vars as int/bool/list/json/Path with defaults, required-checks, and clear errors.

Project description

envcaster ⚙️

Read environment variables as the type you actually wantint, bool, list, json, Path — with defaults, required-checks, and errors that name the offending variable. Zero dependencies, pure standard library.

CI PyPI Python License: MIT

os.environ only ever gives you strings. So every project grows the same little pile of int(os.environ.get("PORT", "8000")) and hand-rolled truthy checks that quietly treat "False" as True. envcaster is that pile, done once and done right.

  • 🪶 Zero required dependencies — pure standard library.
  • 🎯 Typed gettersstr · int · float · bool · list · json · path · decimal · duration · datetime · date · bytes · url (+ custom cast).
  • Built-in validationchoices, min/max bounds, and a ValidationError that's distinct from parse failures.
  • 📋 Batch config checksenv.collect() reports every bad/missing variable at once, not one per restart.
  • 🧯 Loud, precise errors — missing or malformed values tell you which variable and why.
  • 🧪 Fully tested across Python 3.9–3.12.
  • 🧩 Drop-in .env loader with optional ${VAR} interpolation — never clobbers real environment config.

Install

pip install envcaster

Quick start

from envcaster import env

PORT    = env.int("PORT", default=8000)
DEBUG   = env.bool("DEBUG", default=False)
HOSTS   = env.list("ALLOWED_HOSTS", sep=",")          # ["a", "b"] from "a,b"
TIMEOUT = env.float("TIMEOUT", default=1.5)
SECRET  = env.str("SECRET_KEY", required=True)         # raises if not set
DATA    = env.path("DATA_DIR", default="/var/data")   # -> pathlib.Path
FLAGS   = env.json("FEATURE_FLAGS", default={})        # parsed JSON

A variable is required unless you give it a default. Missing required variables raise MissingEnvError; bad values raise CastError. Both subclass EnvError — catch broadly or narrowly.


Usage

Booleans that actually behave

env.bool("DEBUG")     # 1 true t yes y on  -> True   (case-insensitive)
                      # 0 false f no n off -> False
                      # anything else      -> CastError

No more bool("False") == True bugs.

Lists (and lists of other types)

env.list("ALLOWED_HOSTS")                 # "a, b ,, c" -> ["a", "b", "c"]  (trims, drops empties)
env.list("PORTS", sep=":", cast=int)      # "80:443"    -> [80, 443]
env.list("TAGS", default=[])              # missing     -> []

JSON and paths

env.json("LIMITS")          # '{"rpm": 60}' -> {"rpm": 60}
env.path("LOG_DIR")         # "/var/log"    -> PosixPath("/var/log")

Richer types — money, durations, dates, bytes, URLs

from decimal import Decimal

env.decimal("PRICE")                       # "9.99"      -> Decimal("9.99")  (exact)
env.duration("TIMEOUT")                    # "1h30m"     -> timedelta(seconds=5400)
env.duration("RETRY", default=None)        # "500ms"     -> timedelta(microseconds=500000)
env.datetime("STARTS_AT")                  # "2026-06-22T12:00:00Z" -> aware datetime (UTC)
env.date("RELEASE_DAY")                    # "2026-06-22" -> date(2026, 6, 22)
env.bytes("API_KEY", encoding="base64")    # "aGVsbG8="  -> b"hello"
env.url("WEBHOOK_URL")                     # requires scheme+host; http/https by default
env.url("REDIS_URL", schemes=["redis"])    # restrict allowed schemes

duration accepts plain seconds or ms s m h d w tokens ("30", "5m", "1h30m", "2d"). decimal/duration/datetime/date all take min/max.

Anything else — bring your own cast

from decimal import Decimal

env.cast("PRICE", Decimal)                 # "9.99" -> Decimal("9.99")
env.cast("COLOR", lambda v: int(v, 16))    # "ff0000" -> 16711680
# exceptions from your function are wrapped in CastError, naming the variable

Validate values — choices, min, max

env.str("STAGE", choices=["dev", "staging", "prod"])   # else ValidationError
env.int("PORT", min=1, max=65535)                       # range-checked
env.float("SAMPLE_RATE", min=0.0, max=1.0)
env.cast("LEVEL", str.upper, choices=["INFO", "DEBUG"]) # choices check the converted value

A value that parses but breaks a constraint raises ValidationError (a ValueError), kept distinct from CastError (which means it couldn't be parsed at all). A default you supply is trusted and never constraint-checked.

Validate a whole config at once — collect()

Stop debugging your config one missing variable per restart. collect() gathers every error in the block and raises a single report:

from envcaster import env

with env.collect() as cfg:
    PORT   = cfg.int("PORT", default=8000)
    SECRET = cfg.str("SECRET_KEY", required=True)
    DB_URL = cfg.str("DATABASE_URL", required=True)
    REGION = cfg.str("REGION", choices=["us", "eu"])

# If SECRET_KEY and DATABASE_URL are both missing, you get ONE error:
#   EnvValidationError: 2 environment variable errors found:
#     - Required environment variable 'SECRET_KEY' is not set.
#     - Required environment variable 'DATABASE_URL' is not set.

Scoped readers with a prefix

from envcaster import Env

app = Env(prefix="APP_")
app.int("PORT")        # reads APP_PORT
app.bool("DEBUG")      # reads APP_DEBUG

db = app.prefixed("DB_")   # chain prefixes
db.str("HOST")             # reads APP_DB_HOST

Read from somewhere other than os.environ

cfg = Env(source={"PORT": "9000"})   # great for tests — no global state
cfg.int("PORT")                       # 9000

Load a .env file (no dependency)

from envcaster import load_dotenv, env

load_dotenv()                  # reads ./.env into os.environ (won't override real env vars)
load_dotenv(".env.local", override=True)

PORT = env.int("PORT")

# Or parse without touching the environment:
from envcaster import read_dotenv
values = read_dotenv(".env")   # -> {"PORT": "8000", ...}

# Opt in to ${VAR} / $VAR expansion (from earlier keys, then os.environ):
read_dotenv(".env", interpolate=True)        # HOST=localhost / URL=http://${HOST} -> http://localhost

Handles KEY=value, export KEY=value, # comments, and quoted values. Single-quoted values stay literal and \$ is an escaped dollar. For multiline values, use python-dotenv.


API reference

Call Returns Notes
env.str(name, default=…, required=False, choices=None) str The raw value, unchanged
env.int(name, …, min=None, max=None, choices=None) int Base-10, whitespace stripped
env.float(name, …, min=None, max=None, choices=None) float
env.bool(name, …) bool 1/true/t/yes/y/on0/false/f/no/n/off
env.list(name, …, sep=",", cast=str) list Trims items, drops empties, per-item cast
env.json(name, …) Any json.loads of the value
env.path(name, …) pathlib.Path Not resolved/validated
env.decimal(name, …, min=None, max=None, choices=None) Decimal Exact precision
env.duration(name, …, min=None, max=None) timedelta Seconds or 500ms/5m/1h30m/2d/1w
env.datetime(name, …, min=None, max=None) datetime ISO 8601; trailing Z = UTC
env.date(name, …, min=None, max=None) date ISO 8601 YYYY-MM-DD
env.bytes(name, …, encoding="utf-8") bytes Codec, or base64/hex
env.url(name, …, schemes=("http","https")) str Validated; requires scheme + host
env.cast(name, func, …, choices=None) Any Apply any callable; errors wrapped in CastError
env.collect() context manager Batch-validate; raises one EnvValidationError
env.prefixed(prefix) Env New reader with a combined prefix
Env(source=None, prefix="") Env Custom mapping and/or name prefix
read_dotenv(path=".env", interpolate=False) dict Parse a .env file; {} if absent
load_dotenv(path=".env", override=False, interpolate=False) dict Inject into os.environ

Errors: EnvError (base) · MissingEnvError (also KeyError) · CastError (also ValueError) · ValidationError (also ValueError, for choices/min/max) · EnvValidationError (aggregate from collect()).


Why not just os.environ?

# Before
import os
PORT  = int(os.environ.get("PORT", "8000"))
DEBUG = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
HOSTS = [h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()]

# After
from envcaster import env
PORT  = env.int("PORT", default=8000)
DEBUG = env.bool("DEBUG", default=False)
HOSTS = env.list("ALLOWED_HOSTS", default=[])

Development

git clone https://github.com/YoungAlpaccino/envcast
cd envcast
pip install -e ".[dev]"
pytest          # run tests
ruff check .    # lint

License

MIT — see LICENSE. Use it anywhere, including commercially.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

envcaster-0.4.0.tar.gz (21.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

envcaster-0.4.0-py3-none-any.whl (15.0 kB view details)

Uploaded Python 3

File details

Details for the file envcaster-0.4.0.tar.gz.

File metadata

  • Download URL: envcaster-0.4.0.tar.gz
  • Upload date:
  • Size: 21.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.0

File hashes

Hashes for envcaster-0.4.0.tar.gz
Algorithm Hash digest
SHA256 7ff4a6e2cd4a5f81ed8c297d19b348de8a3faf7d08834bae2ac11d61a9580b28
MD5 22c498c965cd44ea9942b2d335223305
BLAKE2b-256 f95f22a9e0012006c3c48351da943b17435e121421868975daecbf3c9675e809

See more details on using hashes here.

File details

Details for the file envcaster-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: envcaster-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 15.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.0

File hashes

Hashes for envcaster-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1f6d0c50b309d50595a6565c218cda0872a6378022dcd367e9688641dd1f5c86
MD5 2d4b8301a9503a3c2d418ea328e09eb3
BLAKE2b-256 2432a5d73c0f27cb707ea6f75fa06c6b638ed5545eed2032dbc4ce3c402d721a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page