Skip to main content

castenv

castenv is a smart environment loader with robust casting/normalization, that uses python-decouple (if installed), python-dotenv with a Fallback to os.environ

current_version = "v0.3.0"

  1. python-decouple (if installed)
  2. python-dotenv (if installed; auto-discovers .env, .env.local, .env.{env}, .env.{env}.local across cwd+parents)
  3. Fallback to os.environ

No per-call config — just castenv.get_env("KEY"), like os.environ.get but smarter.

Quick start

Install

pip install castenv
# Optional extras:
pip install "castenv[dotenv]"
pip install "castenv[decouple]"

Example

import castenv as env

# Optional one-time setup (else: auto cwd+parents):
# env.configure(search_dirs=[BASE_DIR], env_name="prod")

DATABASE_URL = env.get_env("DATABASE_URL", None)
DEBUG = env.env_bool("DEBUG", False)
DEBUG2 = env.get_env("DEBUG2", True)
TIMEOUT_SECS = env.get_env("TIMEOUT", "1m30s")  # -> seconds float (90.0)
TIMEOUT_HOURS = env.get_env("HOURS", "2d1h")  # -> seconds float (176400.0)
CACHE_BYTES = env.get_env("CACHE", "256MB")     # -> bytes int (256000000)
ALLOWED = env.get_env(
    "ALLOWED_HOSTS",
    "localhost,127.0.0.1",
    normalize_kwargs={"parse_lists": True}
)
PORT   = env.get_env("PORT", 8080)     # -> 8080 (int)
OPTS   = env.get_env("OPTS", {"x": 1}) # -> {"x": 1} (dict)
SCALE = env.get_env("SCALE", "50%", normalize_kwargs={"percent_mode": "fraction"})
TAX   = env.get_env("PORT", 15.5)     # -> 15.5 (float)

print("DATABASE_URL:", DATABASE_URL)
print("DEBUG (bool) [env_bool]:", DEBUG)
print("DEBUG2 (bool) [get_env]:", DEBUG2)
print("TIMEOUT (secs):", TIMEOUT_SECS)
print("TIMEOUT_HOURS (secs):", TIMEOUT_HOURS)
print("CACHE (bytes):", CACHE_BYTES)
print("ALLOWED_HOSTS (list):", ALLOWED)
print("PORT (int):", PORT)
print("OPTS (dict):", OPTS)
print("SCALE (float) [0-1]:", SCALE)
print("TAX (float):", TAX)

API Reference

Main Functions

get_env(key, default=None, *, normalize_kwargs=None)

Retrieves and normalizes an environment variable with automatic type casting.

Parameters:

  • key (str): Environment variable name
  • default (Any): Default value if key not found (default: None)
  • normalize_kwargs (dict, optional): Additional normalization options (see below)

Returns: Normalized value based on content and settings

env_bool(key, default=None, **kwargs)

Retrieves environment variable as boolean.

Boolean values recognized:

  • True: "true", "yes", "y", "on", "1"
  • False: "false", "no", "n", "off", "0"

env_int(key, default=None, **kwargs)

Retrieves environment variable as integer.

env_float(key, default=None, **kwargs)

Retrieves environment variable as float.

env_str(key, default=None, **kwargs)

Retrieves environment variable as string.

env_list(key, default=None, *, separators=(",",), **kwargs)

Retrieves environment variable as list with custom separators.

Parameters:

  • separators: Tuple of separator strings (default: (",",))

get_all(keys, defaults=None, *, normalize_kwargs=None)

Retrieves multiple environment variables at once.

Parameters:

  • keys: Iterable of key names
  • defaults: Dict of default values per key
  • normalize_kwargs: Normalization options for all keys

normalize_config(obj, **normalize_kwargs)

Recursively normalizes config structures (dicts, lists, strings) with environment variable expansion and automatic type casting.

This is useful for loading structured config from environment variables, where you want:

  1. Full support for ${VAR}, ${VAR:-default}, and $VAR environment variable references
  2. Layered source precedence (decouple → dotenv → os.environ) for all variable lookups
  3. Automatic type casting of expanded strings (booleans, numbers, JSON, durations, etc.)
  4. Recursive processing of nested dicts and lists

Parameters:

  • obj (Any): The object to process (dict, list, string, or other type)
  • **kwargs: All normalization options from normalize() (see Normalization Parameters below)

Returns: The same structure with env vars expanded and types automatically cast

Key Differences from os.path.expandvars():

  • Respects castenv's configured dotenv search and precedence rules
  • Supports python-decouple if installed
  • Handles missing variables gracefully (returns empty string by default, or uses ${VAR:-default} syntax for defaults)

Example:

import os
import castenv as env

# Set environment variables
os.environ['DB_HOST'] = 'localhost'
os.environ['DB_PORT'] = '5432'
os.environ['DB_DEBUG'] = 'true'
os.environ['ALLOWED_HOSTS'] = 'localhost,127.0.0.1,192.168.1.1'
os.environ['TIMEOUTS'] = '30s,60s,120s'

config = {
    "database": {
        "host": "${DB_HOST}",
        "port": "${DB_PORT}",
        "debug": "${DB_DEBUG}",
        "max_connections": "${MAX_CONNECTIONS:-100}",  # Uses default if not set
    },
    "hosts": "${ALLOWED_HOSTS}",
    "timeouts": "${TIMEOUTS}",
}

result = env.normalize_config(config)
# Returns:
# {
#     "database": {
#         "host": "localhost",
#         "port": 5432,              # int (not string)
#         "debug": True,             # bool (not string)
#         "max_connections": 100,    # int (from default)
#     },
#     "hosts": ["localhost", "127.0.0.1", "192.168.1.1"],  # list
#     "timeouts": [30.0, 60.0, 120.0],  # list of floats (seconds)
# }

Normalization & Casting

Automatic Type Detection

castenv automatically detects and converts values based on their content:

1. None/Null Values

Converts to Python None:

  • Empty string: ""
  • Null literals: "null", "none", "nil", "undefined"
env.get_env("MISSING", "")  # -> None (if coerce_empty_to_none=True)
env.get_env("NULL_VAL", "null")  # -> None

2. Boolean Conversion

Automatically detects boolean strings (case-insensitive):

True values: "true", "yes", "y", "on", "1"
False values: "false", "no", "n", "off", "0"

env.get_env("DEBUG", "true")   # -> True
env.env_bool("ENABLED", "no")  # -> False

3. Number Parsing

Integers:

  • Decimal: "42" → 42
  • Hexadecimal: "0xFF" → 255
  • Binary: "0b1010" → 10
  • Octal: "0o10" → 8

Floats:

  • Standard: "3.14" → 3.14
  • Scientific notation: "1e-3" → 0.001
env.get_env("HEX_COLOR", "0xFF00FF")  # -> 16711935
env.get_env("RATIO", "1.5e-2")        # -> 0.015

4. Duration Parsing

Converts human-readable durations to seconds (float):

Supported units:

  • ns - nanoseconds
  • us, µs - microseconds
  • ms - milliseconds
  • s - seconds
  • m - minutes
  • h - hours
  • d - days
  • w - weeks

Examples:

env.get_env("TIMEOUT", "1m30s")     # -> 90.0
env.get_env("CACHE_TTL", "2d1h")    # -> 176400.0
env.get_env("DELAY", "500ms")       # -> 0.5
env.get_env("WEEK", "1w")           # -> 604800.0

5. Byte Size Parsing

Converts size strings to bytes (int):

IEC Units (powers of 1024):

  • b, B - bytes
  • k, kb, kib, KiB - kibibytes
  • m, mb, mib, MiB - mebibytes
  • g, gb, gib, GiB - gibibytes
  • t, tb, tib, TiB - tebibytes

SI Units (powers of 1000, uppercase trigger):

  • KB - kilobytes (1000)
  • MB - megabytes (1000²)
  • GB - gigabytes (1000³)
  • TB - terabytes (1000⁴)
env.get_env("CACHE", "256MB")       # -> 256000000 (SI: 256 * 1000²)
env.get_env("BUFFER", "1MiB")       # -> 1048576 (IEC: 1024²)
env.get_env("DISK", "500gb")        # -> 536870912000 (IEC by default)

6. Percentage Conversion

Handles percentage values with configurable output:

Modes (via percent_mode):

  • "none" - Returns as string (default): "50%" → "50%"
  • "number" - Returns numeric value: "50%" → 50.0
  • "fraction" - Returns decimal fraction: "50%" → 0.5
env.get_env("TAX", "15%", normalize_kwargs={"percent_mode": "number"})     # -> 15.0
env.get_env("SCALE", "50%", normalize_kwargs={"percent_mode": "fraction"}) # -> 0.5
env.get_env("RAW", "25%")  # -> "25%" (default)

7. JSON Parsing

Automatically parses JSON objects and arrays:

env.get_env("CONFIG", '{"key": "value", "count": 10}')  # -> {'key': 'value', 'count': 10}
env.get_env("ITEMS", '["a", "b", "c"]')                 # -> ['a', 'b', 'c']
env.get_env("QUOTED", '"{\\"k\\": \\"v\\"}"')           # -> {'k': 'v'}

8. List Parsing

Splits comma-separated values (or custom separators) into lists:

Default separator: ,

env.get_env("HOSTS", "localhost,127.0.0.1", normalize_kwargs={"parse_lists": True})
# -> ['localhost', '127.0.0.1']

env.env_list("PORTS", "8000;8001;8002", separators=(";",))
# -> ['8000', '8001', '8002']

# Recursive normalization on list items
env.get_env("MIXED", "true,42,3.14", normalize_kwargs={"parse_lists": True})
# -> [True, 42, 3.14]

9. Environment Variable Interpolation

Expands ${VAR} and $VAR references:

# With BASE_URL="https://api.example.com"
env.get_env("API_URL", "${BASE_URL}/v1")        # -> "https://api.example.com/v1"
env.get_env("FULL_URL", "${BASE:-http://localhost}/api")  # -> Uses default if BASE not set
env.get_env("PATH", "$HOME/config")             # -> Expands $HOME

10. Path Expansion

Expands ~ to user's home directory:

env.get_env("LOG_FILE", "~/app/logs/app.log")  # -> "/home/user/app/logs/app.log"

11. Quote Handling

Strips matching quotes and unescapes common sequences:

Escape sequences supported:

  • \\ → \
  • \" → "
  • \' → '
  • \n → newline
  • \r → carriage return
  • \t → tab
  • \b → backspace
  • \f → form feed
  • \0 → null byte
env.get_env("MSG", '"Hello\\nWorld"')  # -> "Hello\nWorld" (with actual newline)
env.get_env("PATH", "'C:\\\\Users\\\\App'")  # -> "C:\Users\App"

Normalization Parameters

The normalize_kwargs dictionary accepts the following parameters:

Parameter Type Default Description
coerce_empty_to_none bool True Convert empty strings to None
coerce_null_strings bool True Convert "null"/"none" to None
parse_booleans bool True Parse boolean strings
parse_numbers bool True Parse numeric strings
parse_json bool True Parse JSON objects/arrays
parse_lists bool True Parse comma-separated lists
list_separators tuple (",",) Separators for list parsing
strip_quotes bool True Remove matching quotes
unescape_in_quotes bool True Unescape sequences in quotes
interpolate_env bool True Expand ${VAR} references
expand_user bool True Expand ~ to home directory
parse_duration bool True Parse duration strings
parse_bytesize bool True Parse byte size strings
percent_mode str "none" Percentage mode: "none", "number", or "fraction"
lowercase_strings bool False Convert final strings to lowercase
enum iterable None Validate value is in allowed set

Examples with Custom Parameters

# Disable list parsing
env.get_env("CSV", "a,b,c", normalize_kwargs={"parse_lists": False})  # -> "a,b,c"

# Custom list separator
env.get_env("ITEMS", "x|y|z", normalize_kwargs={"parse_lists": True, "list_separators": ("|",)})  # -> ['x', 'y', 'z']

# Enum validation
env.get_env("ENV", "dev", normalize_kwargs={"enum": ["dev", "staging", "prod"]})  # -> "dev"
# Raises ValueError if value not in enum

# Lowercase strings
env.get_env("NAME", "MyApp", normalize_kwargs={"lowercase_strings": True})  # -> "myapp"

# Disable specific parsers
env.get_env("RAW", "true", normalize_kwargs={"parse_booleans": False})  # -> "true" (string)

Configuration

Global Configuration

Configure castenv once at startup:

from pathlib import Path
import castenv as env

env.configure(
    search_dirs=[Path("/app/config")],     # Where to look for .env files
    env_name="production",                  # Use .env.production files
    filenames=[".env", ".env.local"],       # Custom file names
    stop_at_first_found_dir=True,          # Stop at first dir with .env
    prefer_os_over_dotenv=True,            # OS env vars take precedence
    use_decouple_if_available=True         # Use python-decouple if installed
)

Temporary Configuration (Testing)

Use context manager for temporary overrides:

with env.using(search_dirs=[Path("tests/fixtures")], env_name="test"):
    # Temporary configuration active here
    value = env.get_env("TEST_VAR")
# Original configuration restored

Testing overrides

from pathlib import Path
import castenv as env

with env.using(search_dirs=[Path("tests/envs")], env_name="test"):
    assert env.get_env("SOME_KEY") == "value"

Development

Virtual Environments

python -m venv venv

Mac/Linux

source venv/bin/activate

Windows

venv/scripts/activate

Install Requirements

pip install poetry
poetry install

Install Optional Requirements

poetry install --extras "decouple"
poetry install --extras "dotenv"
poetry install --extras "decouple dotenv"

Test

pytest
coverage run -m pytest
coverage report
coverage html
mypy --html-report mypy_report .
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --format=html --htmldir="flake8_report/basic" --exclude=venv
flake8 . --count --exit-zero --max-complexity=11 --max-line-length=127 --statistics --format=html --htmldir="flake8_report/complexity" --exclude=venv

BumpVer

With the CLI command bumpver, you can search for and update version strings in your project files. It has a flexible pattern syntax to support many version schemes (SemVer, CalVer or otherwise). Run BumbVer with:

bumpver update --major --dry
bumpver update --major

bumpver update --minor --dry
bumpver update --minor

bumpver update --patch --dry
bumpver update --patch

Build

poetry build

Publish

poetry publish

Automated PyPI Publishing

This project uses GitHub Actions to automatically publish to PyPI when a new version tag is pushed.

Setup (One-time configuration)

  1. Register a Trusted Publisher on PyPI:
    • Go to https://pypi.org/manage/account/publishing/
    • Click "Add a new pending publisher"
    • Fill in the following details:
      • PyPI Project Name: castenv
      • Owner: systemizing-solutions (your GitHub username)
      • Repository name: castenv
      • Workflow name: publish.yml
      • Environment name: pypi
    • Click "Add pending publisher"

How it works

When you use bumpver to update the version:

bumpver update --patch  # or --minor, --major

This will:

  1. Update the version in pyproject.toml, src/castenv/__init__.py, and README.md
  2. Create a git commit with the version bump
  3. Create a git tag (e.g., 4.0.1)
  4. Push the tag to GitHub

GitHub Actions will automatically detect the new tag and:

  1. Build the distribution packages (wheel and source)
  2. Publish to PyPI using the trusted publisher authentication

Security

This approach uses OpenID Connect (OIDC) Trusted Publishers, which is more secure than API tokens because:

  • ✅ No credentials are stored in GitHub secrets
  • ✅ Only this specific workflow can publish
  • ✅ Only from this specific repository
  • ✅ PyPI automatically verifies the request is legitimate

License

MIT

Metadata

Release files for castenv 0.3.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 castenv 0.3.0
File Size Uploaded Provenance Actions
castenv-0.3.0.tar.gz 25.3 kB With provenance Details for castenv-0.3.0.tar.gz

Built distribution (wheel)

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

Total release size: 47.1 kB

Release files / castenv-0.3.0.tar.gz

Download URL castenv-0.3.0.tar.gz
Size 25.3 kB
Tags Source
SHA-256 checksum
How to use checksums
9e51c993d5e34a56af2092695209e92b1fa48a4e65ee7a787f8337365a556c9c
BLAKE2b-256 checksum
How to use checksums
6310026f54fc6fe44105fde9855ef58b17a938f657a46c392743e49b9708ad0c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Dec 27, 2025.

Transparency log

Release files / castenv-0.3.0-py3-none-any.whl

Download URL castenv-0.3.0-py3-none-any.whl
Size 21.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
653fe7b515193308106c02ad66fbe189b19e2ca25073db213e72dcb311c3a102
BLAKE2b-256 checksum
How to use checksums
a176a72fcb853733ba34952893a409aaf34475d6646f17edb671eb48bb5a19fe
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Dec 27, 2025.

Transparency log

Security for castenv 0.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for castenv-0.3.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Dec 27, 2025.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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