Skip to main content

Pydantic-style environment parsing with typed descriptors.

Project description

EnvBind

EnvBind logo

EnvBind gives you typed environment parsing with explicit descriptors. Define a class, run it, and use attributes as normal values.

Acknowledgement

EnvBind follows a class-based configuration style inspired by Pydantic.
The design goal is simpler: parse and validate environment values at load time with a small, focused API.

Install

uv sync --group dev

The package requires Python 3.10 or newer.

Quick start

from envbind import B64DecodedStringEnv, ParameterSource, StringEnv


class DatabaseSettings(ParameterSource):
    user: str = StringEnv(envar="USER")
    password: str = StringEnv(envar="PWD")
    certificate: str = B64DecodedStringEnv(envar="CERTIFICATE")
export USER=admin
export PWD=secret
export CERTIFICATE=c29tZSB4NTA5IGNlcnQgaGVyZSBlbmNvZGVkIGluIGJhc2U2NA==
params = DatabaseSettings()
print(params.user)
print(params.password)
print(params.certificate)

StringEnv validates non-empty values. optional=True plus a default value loads the default when the variable is absent.

class DatabaseSettings(ParameterSource):
    user: str = StringEnv(envar="USER", optional=True, default="admin")

Design goals from a code perspective

EnvBind addresses common design debt in environment-driven code.

Many projects read environment variables in each service class. This spreads parsing logic across many files. It also hides failure points until runtime.

This library keeps parsing and validation in one place. Each class defines one schema and one responsibility. That avoids duplicate reads and repeated conversion code.

The next issue is test setup. Business classes often hard-code real clients during construction. That creates fragile tests and slow test runs.

ParameterSource makes parameter objects explicit. The object can be passed to business code as a dependency. The object can then be swapped for a mock without changing call sites.

The last issue is design drift. When environment details and business logic mix, changes become risky. With parameter objects, both logic and external settings stay separate. That supports the Open-Closed principle and safe subclassing in tests.

Exception handling

EnvBind raises two runtime exceptions for parsing contracts.

  • MissingEnvironmentVariableError when a required variable is not present.
  • InvalidEnvironmentValueError when the value cannot be parsed.

EnvParseError is the base exception for both types.

from envbind import (
    ParameterSource,
    EnvParseError,
    InvalidEnvironmentValueError,
    MissingEnvironmentVariableError,
    IntEnv,
    StringEnv,
)


class Settings(ParameterSource):
    user: str = StringEnv(envar="USER")
    port: int = IntEnv(envar="PORT")


try:
    settings = Settings()
except MissingEnvironmentVariableError as error:
    raise RuntimeError(f"Missing configuration: {error}") from error
except InvalidEnvironmentValueError as error:
    raise RuntimeError(f"Invalid configuration format: {error}") from error
except EnvParseError as error:
    raise RuntimeError(f"General parser error: {error}") from error

When optional=True, EnvField does not raise on missing values. If a default exists, the default returns first. If no default exists, the resolved value becomes None.

class OptionalSettings(ParameterSource):
    user: str | None = StringEnv(
        envar="USER",
        optional=True,
    )

Custom parsing with your own EnvField

ParameterSource ships with typed fields for common data and custom logic needs.

Built in:

  • BooleanEnv
  • FloatEnv
  • ListEnv
  • JSONEnv
  • EnumEnv
from enum import Enum

from envbind import BooleanEnv, ParameterSource, EnumEnv, FloatEnv, JSONEnv, ListEnv


class DeploymentMode(Enum):
    BLUE = "blue"
    GREEN = "green"


class ServiceSettings(ParameterSource):
    enabled: bool = BooleanEnv(envar="SERVICE_ENABLED")
    timeout: float = FloatEnv(envar="SERVICE_TIMEOUT_SECONDS")
    hosts: list[str] = ListEnv(envar="SERVICE_HOSTS", delimiter=",")
    profile: dict[str, object] = JSONEnv(envar="SERVICE_PROFILE")
    mode: DeploymentMode = EnumEnv(envar="SERVICE_MODE", enum_type=DeploymentMode)

You can still create your own field classes for special rules.

from envbind import EnvField, ParameterSource, InvalidEnvironmentValueError


class SecretValue:
    """Container that hides secret text."""

    def __init__(self, value: str) -> None:
        """Keep raw value stored privately."""
        self._value = value

    def reveal(self) -> str:
        """Expose the real value only when called."""
        return self._value

    def __str__(self) -> str:
        """Hide value in string output."""
        return "<secret>"

    def __repr__(self) -> str:
        """Hide value in object repr."""
        return "<secret>"


class SecretEnv(EnvField):
    """Read a secret and keep it masked in logs."""

    def _coerce(self, raw: str) -> SecretValue:
        """Reject empty secrets and return a safe wrapper."""
        if raw == "":
            raise InvalidEnvironmentValueError("SECRET value must not be empty")
        return SecretValue(raw)


class SecretSettings(ParameterSource):
    api_key: SecretValue = SecretEnv(envar="API_KEY")

SecretValue exposes the token only through reveal(). That avoids accidental text logging.

Dependency injection via parameter objects

Use subclassing for dependency injection in tests while keeping business code stable.

from typing import Any, Protocol
from pymongo import MongoClient
from envbind import ParameterSource, B64DecodedStringEnv, StringEnv, IntEnv


class MongoConnector(Protocol):
    @property
    def db(self) -> Any:
        ...


class MongoConnectionSettings(ParameterSource):
    user: str = StringEnv(envar="USER")
    password: str = StringEnv(envar="PWD")
    certificate: str = B64DecodedStringEnv(envar="CERTIFICATE")
    server: str = StringEnv(envar="SERVER", optional=True, default="localhost")
    port: int = IntEnv(envar="PORT", optional=True, default=27017)
    database: str = StringEnv(envar="DB", optional=True, default="test_db")

    @property
    def connection_str(self) -> str:
        return f"mongodb://{self.user}:{self.password}@{self.server}:{self.port}/{self.database}"

    @property
    def db(self) -> Any:
        client = MongoClient(self.connection_str)
        return client[self.database]
class UserDocumentRepository:
    def __init__(self, connection: MongoConnector):
        self._connection = connection

    def add(self, user: dict[str, object]) -> None:
        self._connection.db["users"].insert_one(user)

Swap MongoConnectionSettings with a test subclass to inject a mock database.

from typing import Any

import mongomock


class MockMongoConnectionSettings(MongoConnectionSettings):
    def __init__(self) -> None:
        super().__init__()
        self._mock_client = mongomock.MongoClient()

    @property
    def db(self) -> Any:
        return self._mock_client[self.database]
from typing import Any
from unittest.mock import patch

import pytest


def _load_test_user() -> dict[str, object] | None:
    with patch.dict(
        "os.environ",
        {
            "USER": "admin",
            "PWD": "secret",
            "CERTIFICATE": "c29tZSB4NTA5IGNlcnQgaGVyZSBlbmNvZGVkIGluIGJhc2U2NA==",
            "SERVER": "localhost",
            "PORT": "27017",
            "DB": "users_db",
        },
    ):
        connection = MockMongoConnectionSettings()
        repository = UserDocumentRepository(connection=connection)
        repository.add({"email": "alice@example.com"})

        return repository._connection.db["users"].find_one({"email": "alice@example.com"})


def test_user_document_repository_add_inserts_user_record() -> None:
    record = _load_test_user()
    assert record is not None


def test_user_document_repository_add_records_email_field() -> None:
    record = _load_test_user()
    assert record["email"] == "alice@example.com"

mongomock is optional and only needed for this test path.

Testing ParameterSource subclasses with pytest

The package is easiest to test with in-memory environment values. monkeypatch makes each test fully isolated.

from __future__ import annotations

import pytest

    from envbind import ParameterSource, IntEnv, StringEnv
from envbind import MissingEnvironmentVariableError


class DatabaseParameters(ParameterSource):
    user: str = StringEnv(envar="TEST_USER")
    port: int = IntEnv(envar="TEST_PORT", optional=True, default=5432)


def test_database_parameters_reads_values(monkeypatch: pytest.MonkeyPatch) -> None:
    """Build values from environment settings."""

    monkeypatch.setenv("TEST_USER", "admin")
    monkeypatch.setenv("TEST_PORT", "27017")
    params = DatabaseParameters()
    assert params.user == "admin"


def test_database_parameters_uses_default(monkeypatch: pytest.MonkeyPatch) -> None:
    """Use defaults for optional fields."""

    monkeypatch.delenv("TEST_PORT", raising=False)
    monkeypatch.setenv("TEST_USER", "admin")
    params = DatabaseParameters()
    assert params.port == 5432


def test_database_parameters_raises_on_missing_user(monkeypatch: pytest.MonkeyPatch) -> None:
    """Raise an explicit parse error for missing required fields."""

    monkeypatch.delenv("TEST_USER", raising=False)
    with pytest.raises(MissingEnvironmentVariableError):
        DatabaseParameters()

If your repository uses dependency injection, pass the parsed object to your business code and test it with fake data adapters.

Development

This repo uses uv for dependencies.

uv sync --group dev
pre-commit install --hook-type pre-commit --hook-type pre-push

Run format, lint, and type checks:

pre-commit run --all-files

Pre-push hook runs tests with coverage using:

pytest --cov=envbind --cov-branch --cov-fail-under=90

This project includes pre-commit and pre-push hooks. Commits run Ruff and Mypy. Pushes run unit tests with coverage gate.

Testing

uv run pytest

All tests live under tests/envbind. Each test method uses one assertion to keep scope tight.

License

EnvBind is released under the MIT License.

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

envbind-1.0.5.tar.gz (14.6 kB view details)

Uploaded Source

Built Distribution

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

envbind-1.0.5-py3-none-any.whl (22.2 kB view details)

Uploaded Python 3

File details

Details for the file envbind-1.0.5.tar.gz.

File metadata

  • Download URL: envbind-1.0.5.tar.gz
  • Upload date:
  • Size: 14.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for envbind-1.0.5.tar.gz
Algorithm Hash digest
SHA256 9648a61aa69947b309b9edad930d9d12cfe5640332f9b714ab8b8c6a583d5aeb
MD5 5f1692eae184fbdf3e3a53dc2c53f608
BLAKE2b-256 e4b97630608b8ef8a36b5add3ae401a8c0c6285a5165ec8aeafdcd3e0ae6115a

See more details on using hashes here.

Provenance

The following attestation bundles were made for envbind-1.0.5.tar.gz:

Publisher: ci-cd.yml on OneTesseractInMultiverse/envbind

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file envbind-1.0.5-py3-none-any.whl.

File metadata

  • Download URL: envbind-1.0.5-py3-none-any.whl
  • Upload date:
  • Size: 22.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for envbind-1.0.5-py3-none-any.whl
Algorithm Hash digest
SHA256 47191a76e173e52ba9e0da68c0e77ecaa84a728c4a1dbb1b5336165685d35c63
MD5 b36feeb13bbb12c01b42d076189879fa
BLAKE2b-256 87dbe6d5f1b18879cdb32557cfeb206f066c979f1efd576bdf51ebeb7e2d1112

See more details on using hashes here.

Provenance

The following attestation bundles were made for envbind-1.0.5-py3-none-any.whl:

Publisher: ci-cd.yml on OneTesseractInMultiverse/envbind

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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