<!-- `envenom` - an elegant application configurator for the more civilized age
Copyright (C) 2024 Artur Ciesielski <artur.ciesielski@gmail.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. -->
# envenom
[![pipeline status](https://gitlab.com/arcanery/python/envenom/badges/main/pipeline.svg)](https://gitlab.com/arcanery/python/envenom/-/commits/main)
[![coverage report](https://gitlab.com/arcanery/python/envenom/badges/main/coverage.svg)](https://gitlab.com/arcanery/python/envenom/-/commits/main)
[![latest release](https://gitlab.com/arcanery/python/envenom/-/badges/release.svg)](https://gitlab.com/arcanery/python/envenom/-/releases)
## Introduction
`envenom` is an elegant application configurator for the more civilized age.
`envenom` is written with simplicity and type safety in mind. It allows
you to express your application configuration declaratively in a dataclass-like
format while providing your application with type information about each entry,
its nullability and default values.
`envenom` is designed for modern usecases, allowing for pulling configuration from
environment variables or files for more sophisticated deployments on platforms
like Kubernetes - all in the spirit of [12factor](https://12factor.net/).
## How it works
An `envenom` config class looks like a regular Python dataclass - because it is one.
The `@envenom.config` decorator creates a new dataclass by converting the config fields
into their `dataclass` equivalents providing the relevant default field parameters. All
config classes created that way are marked as `frozen=True` - because the config should
not change mid-flight - and `eq=True`.
This also means it's 100% compatible with dataclasses. You can:
- use a config class as a property of a regular dataclass
- use a regular dataclass as a property of a config class
- declare static or dynamic fields using standard dataclass syntax
- use the `InitVar`/`__post_init__` method for delayed initialization of fields
- use methods, `classmethod`s, `staticmethod`s, and properties
`envenom` will automatically fetch the environment variable values to populate
dataclass fields (optionally running parsers so that fields are automatically
converted to desired types). This works out of the box with all types trivially
convertible from `str`, like `Enum` and `UUID`, and with any object type that can be
instantiated easily from a single string (any function `(str,) -> T` will work as a
parser).
If using a static type checker the type deduction system will correctly identify most
mistakes if you declare fields, parsers or default values with mismatched types. There
are certain exceptions, for example `T` will always satisfy type bounds `T | None`.
`envenom` also offers reading variable contents from file by specifying an environment
variable with the suffix `__FILE` which contains the path to a file with the respective
secret. This aims to facilitate a common deploy pattern where secrets are mounted as
files (especially prevalent with Kubernetes).
All interaction with the environment is case-sensitive - we'll convert everything to
uppercase, and since `_` is a common separator within environment variable names we use
`_` to replace any and all nonsensical characters, then use `__` to separate namespaces.
Therefore a field `"var"` in namespaces `("ns-1", "ns2")` will be mapped to
`NS_1__NS2__VAR`.
## What `envenom` isn't
`envenom` has a clearly defined scope limited to configuration management from the
application's point of view.
This means `envenom` is only interested in converting the environment into application
configuration and does not care about how the environment gets populated in the first place.
Things that are out of scope for `envenom` include, but are not limited to:
- injecting the environment into the runtime or orchestrator
- retrieving configuration or secrets from the cloud or another storage
(AWS Parameter/Secret Store, Azure Key Vault, HashiCorp Vault, etc.)
- retrieving and parsing configuration from structured config files (`YAML`/`JSON`/`INI` etc.)
# Using `envenom`
## Installing `envenom`
```bash
python -m pip install envenom
```
## Creating a config class
Config classes are created with the `envenom.config` class decorator. It behaves exactly
like `dataclasses.dataclass` but allows to replace standard `dataclasses.field`
definitions with one of `envenom`-specific configuration field types.
```python
from envenom import config
@config()
class MainCfg:
...
```
## `envenom` field types
`envenom` offers four supported field types:
- `required` for configuration variables that have to be provided. If the value cannot
be found, `envenom.errors.MissingConfiguration` will be raised.
- `optional` for configuration variables that don't have to be provided. If the value cannot
be found, it will be set to `None`.
- `with_default` for configuration variables where a default value can be provided. If the
value cannot be found, it will be set to the default.
- `with_default_factory` for configuration variables where a default value can be provided.
If the value cannot be found, it will call the default factory and set the value to the result.
## Basic usage example
This example shows how to build a basic config structure using a database config
as an example. It is available in the `envenom.examples.quickstart` runnable module.
```python
from functools import cached_property
from uuid import UUID, uuid4
from envenom import (
config,
optional,
required,
subconfig,
with_default,
with_default_factory,
)
from envenom.parsers import bool_parser
@config(namespace=("myapp", "db"))
class DbCfg:
scheme: str = with_default(default="postgresql+psycopg://")
host: str = required()
port: int = with_default(int, default=5432)
database: str = required()
username: str | None = optional()
password: str | None = optional()
connection_timeout: int | None = optional(int)
sslmode_require: bool = with_default(bool_parser(), default=False)
@cached_property
def auth(self) -> str:
if not self.username and not self.password:
return ""
auth = ""
if self.username:
auth += self.username
if self.password:
auth += f":{self.password}"
if auth:
auth += "@"
return auth
@cached_property
def query_string(self) -> str:
query: dict[str, str] = {}
if self.connection_timeout:
query["timeout"] = str(self.connection_timeout)
if self.sslmode_require:
query["sslmode"] = "require"
if not query:
return ""
query_string = "&".join((f"{key}={value}" for key, value in query.items()))
return f"?{query_string}"
@cached_property
def connection_string(self) -> str:
return (
f"{self.scheme}{self.auth}{self.host}:{self.port}"
f"/{self.database}{self.query_string}"
)
@config(namespace="myapp")
class AppCfg:
worker_id: UUID = with_default_factory(UUID, default_factory=uuid4)
secret_key: str = required()
db: DbCfg = subconfig(DbCfg)
if __name__ == "__main__":
cfg = AppCfg()
print(f"cfg.worker_id ({type(cfg.worker_id)}): {repr(cfg.worker_id)}")
print(f"cfg.secret_key ({type(cfg.secret_key)}): {repr(cfg.secret_key)}")
print(f"cfg.db.host ({type(cfg.db.host)}): {repr(cfg.db.host)}")
print(f"cfg.db.port ({type(cfg.db.port)}): {repr(cfg.db.port)}")
print(f"cfg.db.database ({type(cfg.db.database)}): {repr(cfg.db.database)}")
print(f"cfg.db.username ({type(cfg.db.username)}): {repr(cfg.db.username)}")
print(f"cfg.db.password ({type(cfg.db.password)}): {repr(cfg.db.password)}")
print(f"cfg.db.connection_timeout ({type(cfg.db.connection_timeout)}): {repr(cfg.db.connection_timeout)}")
print(f"cfg.db.sslmode_require ({type(cfg.db.sslmode_require)}): {repr(cfg.db.sslmode_require)}")
print(f"cfg.db.connection_string ({type(cfg.db.connection_string)}): {repr(cfg.db.connection_string)}")
```
Run the example:
```bash
python -m envenom.examples.quickstart
```
```
Traceback (most recent call last):
...
raise MissingConfiguration(self.env_name)
envenom.errors.MissingConfiguration: 'MYAPP__SECRET_KEY'
```
Immediately throws an error, as soon as it encounters a required field.
Run the example again with the environment set:
```bash
MYAPP__SECRET_KEY='}uZ?uvJdKDM+$2[$dR)).n4q1SX!A$0u{(+D$PVB' \
MYAPP__DB__HOST='postgres' \
MYAPP__DB__DATABASE='database-name' \
MYAPP__DB__USERNAME='user' \
MYAPP__DB__SSLMODE_REQUIRE='t' \
MYAPP__DB__CONNECTION_TIMEOUT='15' \
python -m envenom.examples.quickstart
```
```text
cfg.worker_id (<class 'uuid.UUID'>): UUID('edf6c50a-37a4-42d4-a2d4-c1ee1f3975bc')
cfg.secret_key (<class 'str'>): '}uZ?uvJdKDM+$2[$dR)).n4q1SX!A$0u{(+D$PVB'
cfg.db.host (<class 'str'>): 'postgres'
cfg.db.port (<class 'int'>): 5432
cfg.db.database (<class 'str'>): 'database-name'
cfg.db.username (<class 'str'>): 'user'
cfg.db.password (<class 'NoneType'>): None
cfg.db.connection_timeout (<class 'int'>): 15
cfg.db.sslmode_require (<class 'bool'>): True
cfg.db.connection_string (<class 'str'>): 'postgresql+psycopg://user@postgres:5432/database-name?sslmode=require&timeout=15'
```
# Next steps
See the [documentation](https://arcanery.gitlab.io/python/envenom/) for more examples
of advanced usage and instructions for setting up a development environment.
Raw data
{
"_id": null,
"home_page": "https://gitlab.com/arcanery/python/envenom",
"name": "envenom",
"maintainer": null,
"docs_url": null,
"requires_python": "<4.0,>=3.10",
"maintainer_email": null,
"keywords": "env, environment, config, configuration",
"author": "Artur Ciesielski",
"author_email": "artur.ciesielski@gmail.com",
"download_url": "https://files.pythonhosted.org/packages/8a/9b/49b7aa896dbaac9729a57e533780e3cf8216e7ba37895a9e31c60d51f708/envenom-2.0.4.tar.gz",
"platform": null,
"description": "<!-- `envenom` - an elegant application configurator for the more civilized age\nCopyright (C) 2024 Artur Ciesielski <artur.ciesielski@gmail.com>\n\nThis program is free software: you can redistribute it and/or modify\nit under the terms of the GNU General Public License as published by\nthe Free Software Foundation, either version 3 of the License, or\n(at your option) any later version.\n\nThis program is distributed in the hope that it will be useful,\nbut WITHOUT ANY WARRANTY; without even the implied warranty of\nMERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\nGNU General Public License for more details.\n\nYou should have received a copy of the GNU General Public License\nalong with this program. If not, see <https://www.gnu.org/licenses/>. -->\n\n# envenom\n\n[![pipeline status](https://gitlab.com/arcanery/python/envenom/badges/main/pipeline.svg)](https://gitlab.com/arcanery/python/envenom/-/commits/main)\n[![coverage report](https://gitlab.com/arcanery/python/envenom/badges/main/coverage.svg)](https://gitlab.com/arcanery/python/envenom/-/commits/main)\n[![latest release](https://gitlab.com/arcanery/python/envenom/-/badges/release.svg)](https://gitlab.com/arcanery/python/envenom/-/releases)\n\n## Introduction\n\n`envenom` is an elegant application configurator for the more civilized age.\n\n`envenom` is written with simplicity and type safety in mind. It allows\nyou to express your application configuration declaratively in a dataclass-like\nformat while providing your application with type information about each entry,\nits nullability and default values.\n\n`envenom` is designed for modern usecases, allowing for pulling configuration from\nenvironment variables or files for more sophisticated deployments on platforms\nlike Kubernetes - all in the spirit of [12factor](https://12factor.net/).\n\n## How it works\n\nAn `envenom` config class looks like a regular Python dataclass - because it is one.\n\nThe `@envenom.config` decorator creates a new dataclass by converting the config fields\ninto their `dataclass` equivalents providing the relevant default field parameters. All\nconfig classes created that way are marked as `frozen=True` - because the config should\nnot change mid-flight - and `eq=True`.\n\nThis also means it's 100% compatible with dataclasses. You can:\n\n- use a config class as a property of a regular dataclass\n- use a regular dataclass as a property of a config class\n- declare static or dynamic fields using standard dataclass syntax\n- use the `InitVar`/`__post_init__` method for delayed initialization of fields\n- use methods, `classmethod`s, `staticmethod`s, and properties\n\n`envenom` will automatically fetch the environment variable values to populate\ndataclass fields (optionally running parsers so that fields are automatically\nconverted to desired types). This works out of the box with all types trivially\nconvertible from `str`, like `Enum` and `UUID`, and with any object type that can be\ninstantiated easily from a single string (any function `(str,) -> T` will work as a\nparser).\n\nIf using a static type checker the type deduction system will correctly identify most\nmistakes if you declare fields, parsers or default values with mismatched types. There\nare certain exceptions, for example `T` will always satisfy type bounds `T | None`.\n\n`envenom` also offers reading variable contents from file by specifying an environment\nvariable with the suffix `__FILE` which contains the path to a file with the respective\nsecret. This aims to facilitate a common deploy pattern where secrets are mounted as\nfiles (especially prevalent with Kubernetes).\n\nAll interaction with the environment is case-sensitive - we'll convert everything to\nuppercase, and since `_` is a common separator within environment variable names we use\n`_` to replace any and all nonsensical characters, then use `__` to separate namespaces.\nTherefore a field `\"var\"` in namespaces `(\"ns-1\", \"ns2\")` will be mapped to\n`NS_1__NS2__VAR`.\n\n## What `envenom` isn't\n\n`envenom` has a clearly defined scope limited to configuration management from the\napplication's point of view.\n\nThis means `envenom` is only interested in converting the environment into application\nconfiguration and does not care about how the environment gets populated in the first place.\n\nThings that are out of scope for `envenom` include, but are not limited to:\n\n- injecting the environment into the runtime or orchestrator\n- retrieving configuration or secrets from the cloud or another storage\n(AWS Parameter/Secret Store, Azure Key Vault, HashiCorp Vault, etc.)\n- retrieving and parsing configuration from structured config files (`YAML`/`JSON`/`INI` etc.)\n\n# Using `envenom`\n\n## Installing `envenom`\n\n```bash\npython -m pip install envenom\n```\n\n## Creating a config class\n\nConfig classes are created with the `envenom.config` class decorator. It behaves exactly\nlike `dataclasses.dataclass` but allows to replace standard `dataclasses.field`\ndefinitions with one of `envenom`-specific configuration field types.\n\n```python\nfrom envenom import config\n\n\n@config()\nclass MainCfg:\n ...\n```\n\n## `envenom` field types\n\n`envenom` offers four supported field types:\n\n- `required` for configuration variables that have to be provided. If the value cannot\nbe found, `envenom.errors.MissingConfiguration` will be raised.\n- `optional` for configuration variables that don't have to be provided. If the value cannot\nbe found, it will be set to `None`.\n- `with_default` for configuration variables where a default value can be provided. If the\nvalue cannot be found, it will be set to the default.\n- `with_default_factory` for configuration variables where a default value can be provided.\nIf the value cannot be found, it will call the default factory and set the value to the result.\n\n## Basic usage example\n\nThis example shows how to build a basic config structure using a database config\nas an example. It is available in the `envenom.examples.quickstart` runnable module.\n\n```python\nfrom functools import cached_property\nfrom uuid import UUID, uuid4\n\nfrom envenom import (\n config,\n optional,\n required,\n subconfig,\n with_default,\n with_default_factory,\n)\nfrom envenom.parsers import bool_parser\n\n\n@config(namespace=(\"myapp\", \"db\"))\nclass DbCfg:\n scheme: str = with_default(default=\"postgresql+psycopg://\")\n host: str = required()\n port: int = with_default(int, default=5432)\n database: str = required()\n username: str | None = optional()\n password: str | None = optional()\n connection_timeout: int | None = optional(int)\n sslmode_require: bool = with_default(bool_parser(), default=False)\n\n @cached_property\n def auth(self) -> str:\n if not self.username and not self.password:\n return \"\"\n\n auth = \"\"\n if self.username:\n auth += self.username\n if self.password:\n auth += f\":{self.password}\"\n if auth:\n auth += \"@\"\n\n return auth\n\n @cached_property\n def query_string(self) -> str:\n query: dict[str, str] = {}\n if self.connection_timeout:\n query[\"timeout\"] = str(self.connection_timeout)\n if self.sslmode_require:\n query[\"sslmode\"] = \"require\"\n\n if not query:\n return \"\"\n\n query_string = \"&\".join((f\"{key}={value}\" for key, value in query.items()))\n return f\"?{query_string}\"\n\n @cached_property\n def connection_string(self) -> str:\n return (\n f\"{self.scheme}{self.auth}{self.host}:{self.port}\"\n f\"/{self.database}{self.query_string}\"\n )\n\n\n@config(namespace=\"myapp\")\nclass AppCfg:\n worker_id: UUID = with_default_factory(UUID, default_factory=uuid4)\n secret_key: str = required()\n db: DbCfg = subconfig(DbCfg)\n\n\nif __name__ == \"__main__\":\n cfg = AppCfg()\n\n print(f\"cfg.worker_id ({type(cfg.worker_id)}): {repr(cfg.worker_id)}\")\n print(f\"cfg.secret_key ({type(cfg.secret_key)}): {repr(cfg.secret_key)}\")\n print(f\"cfg.db.host ({type(cfg.db.host)}): {repr(cfg.db.host)}\")\n print(f\"cfg.db.port ({type(cfg.db.port)}): {repr(cfg.db.port)}\")\n print(f\"cfg.db.database ({type(cfg.db.database)}): {repr(cfg.db.database)}\")\n print(f\"cfg.db.username ({type(cfg.db.username)}): {repr(cfg.db.username)}\")\n print(f\"cfg.db.password ({type(cfg.db.password)}): {repr(cfg.db.password)}\")\n print(f\"cfg.db.connection_timeout ({type(cfg.db.connection_timeout)}): {repr(cfg.db.connection_timeout)}\")\n print(f\"cfg.db.sslmode_require ({type(cfg.db.sslmode_require)}): {repr(cfg.db.sslmode_require)}\")\n print(f\"cfg.db.connection_string ({type(cfg.db.connection_string)}): {repr(cfg.db.connection_string)}\")\n```\n\nRun the example:\n\n```bash\npython -m envenom.examples.quickstart\n```\n\n```\nTraceback (most recent call last):\n ...\n raise MissingConfiguration(self.env_name)\nenvenom.errors.MissingConfiguration: 'MYAPP__SECRET_KEY'\n```\n\nImmediately throws an error, as soon as it encounters a required field.\n\nRun the example again with the environment set:\n\n```bash\nMYAPP__SECRET_KEY='}uZ?uvJdKDM+$2[$dR)).n4q1SX!A$0u{(+D$PVB' \\\nMYAPP__DB__HOST='postgres' \\\nMYAPP__DB__DATABASE='database-name' \\\nMYAPP__DB__USERNAME='user' \\\nMYAPP__DB__SSLMODE_REQUIRE='t' \\\nMYAPP__DB__CONNECTION_TIMEOUT='15' \\\npython -m envenom.examples.quickstart\n```\n\n```text\ncfg.worker_id (<class 'uuid.UUID'>): UUID('edf6c50a-37a4-42d4-a2d4-c1ee1f3975bc')\ncfg.secret_key (<class 'str'>): '}uZ?uvJdKDM+$2[$dR)).n4q1SX!A$0u{(+D$PVB'\ncfg.db.host (<class 'str'>): 'postgres'\ncfg.db.port (<class 'int'>): 5432\ncfg.db.database (<class 'str'>): 'database-name'\ncfg.db.username (<class 'str'>): 'user'\ncfg.db.password (<class 'NoneType'>): None\ncfg.db.connection_timeout (<class 'int'>): 15\ncfg.db.sslmode_require (<class 'bool'>): True\ncfg.db.connection_string (<class 'str'>): 'postgresql+psycopg://user@postgres:5432/database-name?sslmode=require&timeout=15'\n```\n\n# Next steps\n\nSee the [documentation](https://arcanery.gitlab.io/python/envenom/) for more examples\nof advanced usage and instructions for setting up a development environment.\n\n",
"bugtrack_url": null,
"license": "GPL-3.0-or-later",
"summary": "An elegant application configurator for the more civilized age",
"version": "2.0.4",
"project_urls": {
"Documentation": "https://arcanery.gitlab.io/python/envenom/",
"Homepage": "https://gitlab.com/arcanery/python/envenom",
"Repository": "https://gitlab.com/arcanery/python/envenom"
},
"split_keywords": [
"env",
" environment",
" config",
" configuration"
],
"urls": [
{
"comment_text": "",
"digests": {
"blake2b_256": "f1d4ce3ce624e945a5eabc26f6107aecf7d6e19a6079618700f9ac3497fde767",
"md5": "c87bd8ad596a3a10126759ff19866ea7",
"sha256": "9b0578b1bc394afb298926ca3a7c91d8ac206c83d26cb6038890f53066808319"
},
"downloads": -1,
"filename": "envenom-2.0.4-py3-none-any.whl",
"has_sig": false,
"md5_digest": "c87bd8ad596a3a10126759ff19866ea7",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": "<4.0,>=3.10",
"size": 36874,
"upload_time": "2024-09-29T01:28:40",
"upload_time_iso_8601": "2024-09-29T01:28:40.348313Z",
"url": "https://files.pythonhosted.org/packages/f1/d4/ce3ce624e945a5eabc26f6107aecf7d6e19a6079618700f9ac3497fde767/envenom-2.0.4-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": "",
"digests": {
"blake2b_256": "8a9b49b7aa896dbaac9729a57e533780e3cf8216e7ba37895a9e31c60d51f708",
"md5": "b3cbb48a1e0f90e48d0d6a4423206f63",
"sha256": "be512de0883b0ccda0b567f6ccefc1b45a57a5bca92c1311ae4f1b17904294fc"
},
"downloads": -1,
"filename": "envenom-2.0.4.tar.gz",
"has_sig": false,
"md5_digest": "b3cbb48a1e0f90e48d0d6a4423206f63",
"packagetype": "sdist",
"python_version": "source",
"requires_python": "<4.0,>=3.10",
"size": 26978,
"upload_time": "2024-09-29T01:28:41",
"upload_time_iso_8601": "2024-09-29T01:28:41.780835Z",
"url": "https://files.pythonhosted.org/packages/8a/9b/49b7aa896dbaac9729a57e533780e3cf8216e7ba37895a9e31c60d51f708/envenom-2.0.4.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2024-09-29 01:28:41",
"github": false,
"gitlab": true,
"bitbucket": false,
"codeberg": false,
"gitlab_user": "arcanery",
"gitlab_project": "python",
"lcname": "envenom"
}