Metadata-Version: 2.4
Name: envbind
Version: 1.1.0
Summary: Pydantic-style environment parsing with typed descriptors.
Author: Pedro Guzmán
License-Expression: MIT
Project-URL: Homepage, https://github.com/OneTesseractInMultiverse/envbind
Project-URL: Bug-Tracker, https://github.com/OneTesseractInMultiverse/envbind/issues
Keywords: environment,configuration,env,parsing,descriptor,pydantic
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# EnvBind

![EnvBind logo](/Users/0xfffeee/Documents/Codex/envite/assets/envbind-logo.svg)

`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](https://docs.pydantic.dev/).  
The design goal is simpler: parse and validate environment values at load time with a small, focused API.

## Install

```bash
uv sync --group dev
```

The package requires Python 3.10 or newer.

## Quick start

```python
from envbind import B64DecodedStringEnv, ParameterSource, StringEnv


class DatabaseSettings(ParameterSource):
    user: str = StringEnv(envar="USER")
    password: str = StringEnv(envar="PWD")
    certificate: str = B64DecodedStringEnv(envar="CERTIFICATE")
```

```bash
export USER=admin
export PWD=secret
export CERTIFICATE=c29tZSB4NTA5IGNlcnQgaGVyZSBlbmNvZGVkIGluIGJhc2U2NA==
```

```python
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.

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

By default, empty strings are handled as missing values.
Use `allow_empty=True` to parse an empty string as a real value.

```python
class TextSettings(ParameterSource):
    label: str = StringEnv(envar="LABEL", allow_empty=True)
```

## 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.

```python
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`.

```python
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`

```python
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)
```

`ListEnv` can parse each item into another type.

```python
class NetworkSettings(ParameterSource):
    ports: list[int] = ListEnv(
        envar="SERVICE_PORTS",
        element_type=int,
        sensitive=False,
    )
```

## Validation helpers

Attach a validator to any field.
The validator receives the parsed value and raises `ValueError` on invalid input.

```python
from envbind import IntEnv, ParameterSource, StringEnv, validators


class ApiSettings(ParameterSource):
    port: int = IntEnv(
        envar="PORT",
        validator=validators.in_range(1, 65535),
        sensitive=False,
    )
    environment: str = StringEnv(
        envar="ENVIRONMENT",
        validator=validators.one_of("dev", "staging", "prod"),
        sensitive=False,
    )
```

Built-in validators:
- `in_range(min_val, max_val)`
- `min_value(min_val)`
- `max_value(max_val)`
- `one_of(*allowed)`
- `min_length(length)`
- `max_length(length)`
- `matches_pattern(pattern)`
- `is_url()`
- `is_email()`
- `all_of(*validators)`

## Sensitive values

Fields are sensitive by default.
Sensitive fields are hidden from `repr()`, observer logs, and `as_dict()`.

```python
class SecretSettings(ParameterSource):
    token: str = StringEnv(envar="API_TOKEN")
    host: str = StringEnv(envar="API_HOST", sensitive=False)


settings = SecretSettings()
print(settings)
print(settings.as_dict())
print(settings.as_dict(include_sensitive=True))
```

Use `sensitive=False` for values that are safe to show, such as ports,
hostnames, modes, and feature flags.

## Observability

Register an observer to receive configuration loading events.

```python
import logging

from envbind import LoggingObserver, ParameterSource


logging.basicConfig(level=logging.INFO)
ParameterSource.set_observer(LoggingObserver())
```

Use `StructuredObserver` for log processors that read structured fields.
For custom telemetry, implement `ConfigurationObserver`.

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

```python
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.

```python
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]
```

```python
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.

```python
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]
```

```python
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.

```python
from __future__ import annotations

import pytest

from envbind import IntEnv, MissingEnvironmentVariableError, ParameterSource, StringEnv


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.

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

Run format, lint, and type checks:

```bash
pre-commit run --all-files
```

Pre-push hook runs tests with coverage using:

```bash
pytest --cov=envbind --cov-branch --cov-fail-under=100
```

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

## Testing

```bash
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](LICENSE).
