# python-env-resolver
Type-safe environment configuration for Python services, powered by Pydantic. Model the shape of your settings once, enforce policies centrally, and keep secrets fresh without burning latency.
```bash
pip install python-env-resolver
# or
uv add python-env-resolver
```
## Why teams reach for it
- Strong typing and validation with your Pydantic models.
- Resolver pipeline that merges `os.environ`, `.env`, cloud stores, or any async source.
- Built-in policies to lock down where secrets may come from, especially in production.
- Audit trail for compliance or debugging misconfigured environments.
- Smart TTL caching with stale-while-revalidate so background refreshes never block callers.
## Quickstart
### Async
```python
from pydantic import BaseModel, HttpUrl
from python_env_resolver import resolve
class AppConfig(BaseModel):
port: int = 3000
database_url: HttpUrl
debug: bool = False
api_key: str | None = None
async def main():
config = await resolve(AppConfig)
print(config.database_url)
```
### Sync
```python
from pydantic import BaseModel, HttpUrl
from python_env_resolver import resolve_sync
class AppConfig(BaseModel):
port: int = 3000
database_url: HttpUrl
debug: bool = False
api_key: str | None = None
config = resolve_sync(AppConfig)
print(config.database_url)
```
Both APIs share identical behavior. By default they read from `process_env()` (plain `os.environ`). Pass additional resolvers when you need more.
## Resolvers and merge strategy
```python
from python_env_resolver import dotenv, process_env, resolve, resolve_sync, ResolveOptions
async def load_config():
return await resolve(
AppConfig,
resolvers=[dotenv(".env"), process_env()],
options=ResolveOptions(priority="last") # later resolvers override earlier ones
)
# Or synchronously:
config = resolve_sync(
AppConfig,
resolvers=[dotenv(".env"), process_env()],
options=ResolveOptions(priority="last"),
)
```
Resolvers are async callables that return `dict[str, str]`. Custom sources only need to implement a `.load()` coroutine and a `name`.
## Custom resolvers
Build your own resolver to load from any source—databases, HTTP APIs, vault systems, or custom file formats. The interface is minimal and composable:
```python
from python_env_resolver import BaseResolver
class ConsulResolver(BaseResolver):
def __init__(self, host: str, prefix: str):
super().__init__(name="consul")
self.host = host
self.prefix = prefix
async def load(self) -> dict[str, str]:
# Your logic to fetch key-value pairs from Consul
async with httpx.AsyncClient() as client:
response = await client.get(f"{self.host}/v1/kv/{self.prefix}?recurse=true")
data = response.json()
return {item["Key"]: item["Value"] for item in data}
# Use it like any built-in resolver
config = await resolve(
AppConfig,
resolvers=[ConsulResolver("http://localhost:8500", "app/config"), process_env()],
)
```
You can also create simple function-based resolvers for one-off needs:
```python
from python_env_resolver import create_resolver
async def load_from_api():
# Fetch environment variables from your API
return {"API_KEY": "secret123", "REGION": "us-west-2"}
api_resolver = create_resolver("my-api", load_from_api)
config = await resolve(AppConfig, resolvers=[api_resolver])
```
## Custom validators
Compose Pydantic validators with built-in utilities or create your own for domain-specific constraints:
```python
from pydantic import BaseModel, field_validator
from python_env_resolver import resolve, validate_url, validate_port
class AppConfig(BaseModel):
api_url: str
port: int
redis_host: str
@field_validator("api_url")
@classmethod
def check_api_url(cls, v: str) -> str:
# Use built-in validator
return validate_url(v, require_https=True)
@field_validator("port")
@classmethod
def check_port(cls, v: int) -> int:
# Compose with built-in validator
return validate_port(v, min_port=1024, max_port=65535)
@field_validator("redis_host")
@classmethod
def check_redis_host(cls, v: str) -> str:
# Custom validation logic
if not v.endswith(".cache.amazonaws.com") and v != "localhost":
raise ValueError("Redis must be ElastiCache or localhost")
return v
config = await resolve(AppConfig)
```
Mix and match validators for ultimate flexibility:
```python
from python_env_resolver import validate_email, validate_number_range
class ServiceConfig(BaseModel):
admin_email: str
max_connections: int
timeout_seconds: float
@field_validator("admin_email")
@classmethod
def check_email(cls, v: str) -> str:
return validate_email(v)
@field_validator("max_connections")
@classmethod
def check_max_connections(cls, v: int) -> int:
return validate_number_range(v, min_val=1, max_val=1000)
@field_validator("timeout_seconds")
@classmethod
def check_timeout(cls, v: float) -> float:
if v <= 0 or v > 300:
raise ValueError("Timeout must be between 0 and 300 seconds")
return v
```
## Keeping secrets fast with caching
Wrap any resolver with `cached()` to enable TTL caching, including stale-while-revalidate. Fresh data is served instantly while a background refresh updates the cache, even when multiple callers arrive simultaneously.
```python
from python_env_resolver import CacheOptions, TTL, cached
from python_env_resolver_aws import aws_secrets # example integration
secrets_resolver = cached(
aws_secrets(secret_id="prod/db"),
CacheOptions(
ttl=TTL.minutes5,
max_age=TTL.hour,
stale_while_revalidate=True,
),
)
async def load_config():
return await resolve(AppConfig, resolvers=[secrets_resolver])
```
Prefer the helper when you like opinionated defaults tuned for AWS Secrets Manager:
```python
from python_env_resolver import aws_cache
secrets_resolver = cached(aws_secrets(secret_id="prod/db"), aws_cache())
```
## Security policies
Keep risky sources out of production or require critical settings to originate from a specific resolver.
```python
from python_env_resolver import PolicyOptions, ResolveOptions
options = ResolveOptions(
policies=PolicyOptions(
allow_dotenv_in_production=["LOG_LEVEL"], # only allow this key from .env
enforce_allowed_sources={
"DATABASE_URL": ["aws-secrets"],
},
)
)
config = await resolve(AppConfig, options=options)
```
Policy violations surface as clear `ValueError`s before your application boots.
## Audit trail
Enable auditing to record where every value came from—helpful in staging and production alike.
```python
from python_env_resolver import ResolveOptions, get_audit_log
config = await resolve(
AppConfig,
options=ResolveOptions(enable_audit=True),
)
for event in get_audit_log():
print(event.type, event.source, event.details)
```
## Safer boot paths
Need a non-raising API for CLI tools or tests? Use `safe_resolve`:
```python
from python_env_resolver import safe_resolve, safe_resolve_sync
result = await safe_resolve(AppConfig)
if result.success:
config = result.data
else:
raise RuntimeError(result.error)
# Synchronous helper
sync_result = safe_resolve_sync(AppConfig)
if not sync_result.success:
raise RuntimeError(sync_result.error)
```
## Typed helpers
The package ships with focused validators and utilities for common scenarios:
- `TTL` constants for readable cache configuration (`TTL.minutes5`, `TTL.hour`, etc.).
- Validators: `validate_url`, `validate_port`, `validate_email`, `validate_number_range`.
- Resolver factories: `process_env()`, `dotenv(path)`, `create_resolver()`.
- Base classes: `BaseResolver` for building custom resolvers with full type safety.
Use them directly in your Pydantic models or compose your own domain-specific abstractions on top. The library is designed for maximum flexibility—every component is composable and extensible.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
### Development Setup
```bash
# Clone the repository
git clone https://github.com/jagreehal/python-env-resolver.git
cd python-env-resolver
# Install dependencies
uv pip install -e ".[dev]"
# Run tests
pytest
# Type check
mypy src
# Lint
ruff check .
```
### Publishing
See [PUBLISHING.md](PUBLISHING.md) for detailed instructions on publishing to PyPI.
## License
MIT License - see [LICENSE](LICENSE) file for details.
Raw data
{
"_id": null,
"home_page": null,
"name": "python-env-resolver",
"maintainer": null,
"docs_url": null,
"requires_python": ">=3.10",
"maintainer_email": null,
"keywords": "config, configuration, dotenv, env, environment, pydantic, secrets, settings, validation",
"author": null,
"author_email": "Jag Reehal <jag@jagreehal.com>",
"download_url": "https://files.pythonhosted.org/packages/00/11/1ec879b87bd7d3972bec4b0b5d8df73deb7edadda914062aa03b99f04f8a/python_env_resolver-0.1.0.tar.gz",
"platform": null,
"description": "# python-env-resolver\n\nType-safe environment configuration for Python services, powered by Pydantic. Model the shape of your settings once, enforce policies centrally, and keep secrets fresh without burning latency.\n\n```bash\npip install python-env-resolver\n# or\nuv add python-env-resolver\n```\n\n## Why teams reach for it\n\n- Strong typing and validation with your Pydantic models.\n- Resolver pipeline that merges `os.environ`, `.env`, cloud stores, or any async source.\n- Built-in policies to lock down where secrets may come from, especially in production.\n- Audit trail for compliance or debugging misconfigured environments.\n- Smart TTL caching with stale-while-revalidate so background refreshes never block callers.\n\n## Quickstart\n\n### Async\n\n```python\nfrom pydantic import BaseModel, HttpUrl\nfrom python_env_resolver import resolve\n\nclass AppConfig(BaseModel):\n port: int = 3000\n database_url: HttpUrl\n debug: bool = False\n api_key: str | None = None\n\nasync def main():\n config = await resolve(AppConfig)\n print(config.database_url)\n```\n\n### Sync\n\n```python\nfrom pydantic import BaseModel, HttpUrl\nfrom python_env_resolver import resolve_sync\n\nclass AppConfig(BaseModel):\n port: int = 3000\n database_url: HttpUrl\n debug: bool = False\n api_key: str | None = None\n\nconfig = resolve_sync(AppConfig)\nprint(config.database_url)\n```\n\nBoth APIs share identical behavior. By default they read from `process_env()` (plain `os.environ`). Pass additional resolvers when you need more.\n\n## Resolvers and merge strategy\n\n```python\nfrom python_env_resolver import dotenv, process_env, resolve, resolve_sync, ResolveOptions\n\nasync def load_config():\n return await resolve(\n AppConfig,\n resolvers=[dotenv(\".env\"), process_env()],\n options=ResolveOptions(priority=\"last\") # later resolvers override earlier ones\n )\n\n# Or synchronously:\nconfig = resolve_sync(\n AppConfig,\n resolvers=[dotenv(\".env\"), process_env()],\n options=ResolveOptions(priority=\"last\"),\n)\n```\n\nResolvers are async callables that return `dict[str, str]`. Custom sources only need to implement a `.load()` coroutine and a `name`.\n\n## Custom resolvers\n\nBuild your own resolver to load from any source\u2014databases, HTTP APIs, vault systems, or custom file formats. The interface is minimal and composable:\n\n```python\nfrom python_env_resolver import BaseResolver\n\nclass ConsulResolver(BaseResolver):\n def __init__(self, host: str, prefix: str):\n super().__init__(name=\"consul\")\n self.host = host\n self.prefix = prefix\n \n async def load(self) -> dict[str, str]:\n # Your logic to fetch key-value pairs from Consul\n async with httpx.AsyncClient() as client:\n response = await client.get(f\"{self.host}/v1/kv/{self.prefix}?recurse=true\")\n data = response.json()\n return {item[\"Key\"]: item[\"Value\"] for item in data}\n\n# Use it like any built-in resolver\nconfig = await resolve(\n AppConfig,\n resolvers=[ConsulResolver(\"http://localhost:8500\", \"app/config\"), process_env()],\n)\n```\n\nYou can also create simple function-based resolvers for one-off needs:\n\n```python\nfrom python_env_resolver import create_resolver\n\nasync def load_from_api():\n # Fetch environment variables from your API\n return {\"API_KEY\": \"secret123\", \"REGION\": \"us-west-2\"}\n\napi_resolver = create_resolver(\"my-api\", load_from_api)\nconfig = await resolve(AppConfig, resolvers=[api_resolver])\n```\n\n## Custom validators\n\nCompose Pydantic validators with built-in utilities or create your own for domain-specific constraints:\n\n```python\nfrom pydantic import BaseModel, field_validator\nfrom python_env_resolver import resolve, validate_url, validate_port\n\nclass AppConfig(BaseModel):\n api_url: str\n port: int\n redis_host: str\n \n @field_validator(\"api_url\")\n @classmethod\n def check_api_url(cls, v: str) -> str:\n # Use built-in validator\n return validate_url(v, require_https=True)\n \n @field_validator(\"port\")\n @classmethod\n def check_port(cls, v: int) -> int:\n # Compose with built-in validator\n return validate_port(v, min_port=1024, max_port=65535)\n \n @field_validator(\"redis_host\")\n @classmethod\n def check_redis_host(cls, v: str) -> str:\n # Custom validation logic\n if not v.endswith(\".cache.amazonaws.com\") and v != \"localhost\":\n raise ValueError(\"Redis must be ElastiCache or localhost\")\n return v\n\nconfig = await resolve(AppConfig)\n```\n\nMix and match validators for ultimate flexibility:\n\n```python\nfrom python_env_resolver import validate_email, validate_number_range\n\nclass ServiceConfig(BaseModel):\n admin_email: str\n max_connections: int\n timeout_seconds: float\n \n @field_validator(\"admin_email\")\n @classmethod\n def check_email(cls, v: str) -> str:\n return validate_email(v)\n \n @field_validator(\"max_connections\")\n @classmethod\n def check_max_connections(cls, v: int) -> int:\n return validate_number_range(v, min_val=1, max_val=1000)\n \n @field_validator(\"timeout_seconds\")\n @classmethod\n def check_timeout(cls, v: float) -> float:\n if v <= 0 or v > 300:\n raise ValueError(\"Timeout must be between 0 and 300 seconds\")\n return v\n```\n\n## Keeping secrets fast with caching\n\nWrap any resolver with `cached()` to enable TTL caching, including stale-while-revalidate. Fresh data is served instantly while a background refresh updates the cache, even when multiple callers arrive simultaneously.\n\n```python\nfrom python_env_resolver import CacheOptions, TTL, cached\nfrom python_env_resolver_aws import aws_secrets # example integration\n\nsecrets_resolver = cached(\n aws_secrets(secret_id=\"prod/db\"),\n CacheOptions(\n ttl=TTL.minutes5,\n max_age=TTL.hour,\n stale_while_revalidate=True,\n ),\n)\n\nasync def load_config():\n return await resolve(AppConfig, resolvers=[secrets_resolver])\n```\n\nPrefer the helper when you like opinionated defaults tuned for AWS Secrets Manager:\n\n```python\nfrom python_env_resolver import aws_cache\n\nsecrets_resolver = cached(aws_secrets(secret_id=\"prod/db\"), aws_cache())\n```\n\n## Security policies\n\nKeep risky sources out of production or require critical settings to originate from a specific resolver.\n\n```python\nfrom python_env_resolver import PolicyOptions, ResolveOptions\n\noptions = ResolveOptions(\n policies=PolicyOptions(\n allow_dotenv_in_production=[\"LOG_LEVEL\"], # only allow this key from .env\n enforce_allowed_sources={\n \"DATABASE_URL\": [\"aws-secrets\"],\n },\n )\n)\n\nconfig = await resolve(AppConfig, options=options)\n```\n\nPolicy violations surface as clear `ValueError`s before your application boots.\n\n## Audit trail\n\nEnable auditing to record where every value came from\u2014helpful in staging and production alike.\n\n```python\nfrom python_env_resolver import ResolveOptions, get_audit_log\n\nconfig = await resolve(\n AppConfig,\n options=ResolveOptions(enable_audit=True),\n)\n\nfor event in get_audit_log():\n print(event.type, event.source, event.details)\n```\n\n## Safer boot paths\n\nNeed a non-raising API for CLI tools or tests? Use `safe_resolve`:\n\n```python\nfrom python_env_resolver import safe_resolve, safe_resolve_sync\n\nresult = await safe_resolve(AppConfig)\nif result.success:\n config = result.data\nelse:\n raise RuntimeError(result.error)\n\n# Synchronous helper\nsync_result = safe_resolve_sync(AppConfig)\nif not sync_result.success:\n raise RuntimeError(sync_result.error)\n```\n\n## Typed helpers\n\nThe package ships with focused validators and utilities for common scenarios:\n\n- `TTL` constants for readable cache configuration (`TTL.minutes5`, `TTL.hour`, etc.).\n- Validators: `validate_url`, `validate_port`, `validate_email`, `validate_number_range`.\n- Resolver factories: `process_env()`, `dotenv(path)`, `create_resolver()`.\n- Base classes: `BaseResolver` for building custom resolvers with full type safety.\n\nUse them directly in your Pydantic models or compose your own domain-specific abstractions on top. The library is designed for maximum flexibility\u2014every component is composable and extensible.\n\n## Contributing\n\nContributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.\n\n### Development Setup\n\n```bash\n# Clone the repository\ngit clone https://github.com/jagreehal/python-env-resolver.git\ncd python-env-resolver\n\n# Install dependencies\nuv pip install -e \".[dev]\"\n\n# Run tests\npytest\n\n# Type check\nmypy src\n\n# Lint\nruff check .\n```\n\n### Publishing\n\nSee [PUBLISHING.md](PUBLISHING.md) for detailed instructions on publishing to PyPI.\n\n## License\n\nMIT License - see [LICENSE](LICENSE) file for details.\n",
"bugtrack_url": null,
"license": "MIT",
"summary": "Type-safe environment variable handling for Python with Pydantic",
"version": "0.1.0",
"project_urls": {
"Changelog": "https://github.com/jagreehal/python-env-resolver/releases",
"Documentation": "https://github.com/jagreehal/python-env-resolver#readme",
"Homepage": "https://github.com/jagreehal/python-env-resolver",
"Issues": "https://github.com/jagreehal/python-env-resolver/issues",
"Repository": "https://github.com/jagreehal/python-env-resolver"
},
"split_keywords": [
"config",
" configuration",
" dotenv",
" env",
" environment",
" pydantic",
" secrets",
" settings",
" validation"
],
"urls": [
{
"comment_text": null,
"digests": {
"blake2b_256": "6e9e6b6356532651672e7cd8ded7829068210e5b4d57cdad07a77183a40d1e46",
"md5": "159abe3c1547e36a12a8f373b7a28e8a",
"sha256": "64561e228ded56a443e13f86d90555e9a4ad8568110487f5de2e95cc2194232b"
},
"downloads": -1,
"filename": "python_env_resolver-0.1.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "159abe3c1547e36a12a8f373b7a28e8a",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": ">=3.10",
"size": 15990,
"upload_time": "2025-10-08T22:56:34",
"upload_time_iso_8601": "2025-10-08T22:56:34.548053Z",
"url": "https://files.pythonhosted.org/packages/6e/9e/6b6356532651672e7cd8ded7829068210e5b4d57cdad07a77183a40d1e46/python_env_resolver-0.1.0-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": null,
"digests": {
"blake2b_256": "00111ec879b87bd7d3972bec4b0b5d8df73deb7edadda914062aa03b99f04f8a",
"md5": "ba19ac85b3efa6d69acccea8d2facca7",
"sha256": "250bc6e719c45160c48d2f450ca50ee77412c442913d2c94fdf2a7414b40a986"
},
"downloads": -1,
"filename": "python_env_resolver-0.1.0.tar.gz",
"has_sig": false,
"md5_digest": "ba19ac85b3efa6d69acccea8d2facca7",
"packagetype": "sdist",
"python_version": "source",
"requires_python": ">=3.10",
"size": 40354,
"upload_time": "2025-10-08T22:56:35",
"upload_time_iso_8601": "2025-10-08T22:56:35.892980Z",
"url": "https://files.pythonhosted.org/packages/00/11/1ec879b87bd7d3972bec4b0b5d8df73deb7edadda914062aa03b99f04f8a/python_env_resolver-0.1.0.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2025-10-08 22:56:35",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "jagreehal",
"github_project": "python-env-resolver",
"travis_ci": false,
"coveralls": false,
"github_actions": true,
"lcname": "python-env-resolver"
}