Metadata-Version: 2.5
Name: typedconf
Version: 0.9.3
Summary: A type-safe configuration management library for Python
Project-URL: Repository, https://codeberg.org/the_pi/typedconf
Project-URL: Issues, https://codeberg.org/the_pi/typedconfissues
Author: the_pi
License-Expression: MIT
Keywords: cli,configuration,env,json,pydantic,settings,toml,type-safe
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pydantic>=2.11.7
Provides-Extra: all
Requires-Dist: python-dotenv>=0.9.9; extra == 'all'
Requires-Dist: tomli-w>=1.2.0; extra == 'all'
Requires-Dist: tomli>=1.2.0; (python_version < '3.11') and extra == 'all'
Provides-Extra: dev
Requires-Dist: pytest>=9.0.3; extra == 'dev'
Requires-Dist: python-dotenv>=0.9.9; extra == 'dev'
Requires-Dist: tomli-w>=1.2.0; extra == 'dev'
Requires-Dist: tomli>=1.2.0; (python_version < '3.11') and extra == 'dev'
Provides-Extra: dotenv
Requires-Dist: python-dotenv>=0.9.9; extra == 'dotenv'
Provides-Extra: toml-export
Requires-Dist: tomli-w>=1.2.0; extra == 'toml-export'
Provides-Extra: toml-read
Requires-Dist: tomli>=1.2.0; (python_version < '3.11') and extra == 'toml-read'
Description-Content-Type: text/markdown

# TypedConf

A lightweight, type-safe configuration management library powered by Pydantic. Following the [12-factor application guide](https://12factor.net/config), it centralizes your application configuration/settings by merging data from multiple sources with a defined priority: 
`env > cli > json > toml > payload > defaults`.

TypedConf combines the strengths of Pydantic and Dynaconf: While it requires developers to define classes to 
reflect the configuration structure (unlike [Dynaconf](https://www.dynaconf.com/)), this approach heavily 
reduces headaches 🤕🤕😠 caused by mistyped property names, wrong types, or formatting errors.
TypedConf offers comprehensive IDE support, type safety, and runtime data validation, 
powered by [Pydantic](https://pydantic.dev/docs/validation/latest/get-started/why/).

## Key Features

- **Type-Safe:** Built on Pydantic, ensuring configuration values are validated at runtime.
- **IDE Support:** Full type-hinting and IntelliSense support for seamless development.
- **Nested Support:** Easily handle complex configuration structures.
- **TOML and JSON Interface:** Load configuration from TOML and/or JSON files.
- **CLI and Environment Interface:** Load configuration data from CLI arguments (`--cfg_myint=1`) and/or ENV variables (`export CFG_MYINT=1`).
- **Layered Configuration:** Merges configuration data with a clear priority: `env > cli > json > toml > payload > defaults`.
- **Immutability:** Configuration data is read-only (frozen) by default after loading.
- **Self-Documenting:** Generate help text directly from your configuration schema definition.

---

## Quick Start

Instead of defining scattered constants at the beginning of a script, use `ConfigModel` to manage your application 
configuration centrally, type-safely, and with built-in validation.

```python
from typedconf import ConfigModel

# Define configuration schema
class AppConfig(ConfigModel):
    app_name: str       # Required field, no default
    port: int = 8080    # Default value

# Load configuration (here directly from payload)
conf = AppConfig.load(payload={'app_name': 'app'})
print(f"Running {conf.app_name} on port {conf.port}")   # Running app on port 8080

```

## Load Configuration from TOML (or JSON)

Most of the time you will load data from configuration files. Let's say from this TOML file (`config.toml`):

```toml
app_name = "myapp"
port = 2000

```

```python
from typedconf import ConfigModel

class AppConfig(ConfigModel):
    app_name: str
    port: int = 8080

# Load configuration from toml file(s)
conf = AppConfig.load(toml=['config.toml'])
print(f"Running {conf.app_name} on port {conf.port}")   # Running myapp on port 2000

```

## Data Validation and Documentation

`ConfigModel` is a Pydantic [`BaseModel`](https://pydantic.dev/docs/validation/dev/get-started/why/). 
You can use [`Field`](https://pydantic.dev/docs/validation/dev/concepts/fields/) to add descriptions, constraints, or validation rules.

```python
from pydantic import Field
from typedconf import ConfigModel, ConfigError

class AppConfig(ConfigModel):
    app_name: str = Field(..., description="Application name, required field.")
    port: int = Field(8080, ge=1000, le=9999, description="Port to listen on (1000-9999).")

try:
    conf = AppConfig.load(toml=['config.toml'])
    print(f"Running {conf.app_name} on port {conf.port}")
except ConfigError as e:
    print("Validation failed! Can't load configuration:", e)

```

## Nested Configuration

Nested configurations can be applied by nesting `ConfigModel` classes.

```python
from pydantic import Field
from typedconf import ConfigModel, ConfigError

class DatabaseConfig(ConfigModel):
    con: str = Field(..., description="DB connection string")
    user: str = Field(..., description="DB username")
    pwd: str = Field(..., description="DB password")

class AppConfig(ConfigModel):
    app_name: str = Field(..., description="Application name")
    port: int = Field(8080, ge=1000, le=9999, description="Port")
    db: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Database configuration")

try:
    conf = AppConfig.load(toml=['config.toml'])
    print(f"Running {conf.app_name} on port {conf.port}. DB user: {conf.db.user}")
except ConfigError as e:
    print(e)

```

If your TOML file doesn't match the new nested structure (e.g., missing `[db]` section), TypedConf/Pydantic will raise a `ConfigError`:

```text
3 validation errors for DatabaseConfig 
con Field required [type=missing, ...]
user Field required [type=missing, ...]
pwd Field required [type=missing, ...]

```

Let's fix our TOML file. *Remember: It's best practice not to store sensitive data like passwords inside configuration files!*

```toml
# config.toml
app_name = "toml-app"
port = 9090

[db]
con = "postgresql://localhost:5432/mydb"
user = "db_user_readonly"

```

## ENV & CLI Interface

You can inject missing or secret data (like database passwords) through the CLI or environment variables. Both interfaces are enabled by default.

```sh
# CLI interface
$ python app.py --cfg_db__pwd="secret"
Running toml-app on port 9090.

# Environment variables interface
$ export CFG_DB__PWD="secret"
$ python app.py

# Combining them
$ export CFG_DB__USER="db_user_admin"
$ export CFG_DB__PWD="secret"
$ CFG_PORT=2525 python app.py --cfg_app_name="cli-app"

```

**Conventions:**

* Case sensitivity: CLI is *lowercase*, ENV is *UPPERCASE*.
* CLI uses long format arguments like `--key=val`.
* Prefix: Both use `cfg_` by default (customizable via `cli_prefix`).
* Separator: Nested fields are separated by `__` (customizable via `cli_separator`).

## Priority Chain

TypedConf merges all data sources in a specific order. Higher-priority sources overwrite lower-priority ones:

1. **Environment Variables (Highest):** e.g., `export CFG_DB__PWD="abc"`
2. **CLI Arguments:** e.g., `--cfg_db__pwd='abc'`
3. **JSON Files:** Merged from left to right
4. **TOML Files:** Merged from left to right
5. **Payload:** Dictionary passed directly via `.load(payload=...)`
6. **Defaults (Lowest):** Defined inside the `ConfigModel` class

*Note: The system performs a deep merge, preserving nested structures when partial overrides are provided.*


## CLI Help included

TypedConf can include a `--help` argument to your application and generates a nice helptext for all field-names. Let's step back to our *nested configuration example* and add help for the user by using `user_needs_help()` 

```python
from pydantic import Field
from typedconf import ConfigModel, ConfigError

RFC3986_URI_REGEX = r'^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})(?::\d+)?([\/\w \.-]*)*\/?$'

# define configuration schema
class DatabaseConfig(ConfigModel):
    con: str = Field(..., pattern=RFC3986_URI_REGEX, description="DB connection-string, required field.")
    user: str = Field(..., description="DB username, required field.")
    pwd: str = Field(..., description="DB password, required field.")

class AppConfig(ConfigModel):
    app_name: str = Field(..., description="application name, required field.")
    port: int = Field(8080, gt=1000, lt=9999, description="application listen on port. Between 1000 and 9999, defaullt=8080")
    db: DatabaseConfig = Field(default_factory=DatabaseConfig, description="database configuration")

# need some help?
if AppConfig.user_needs_help():
    print(f"MYAPP Help\n\nAvailable CLI Parameter\n{AppConfig.get_cli_helptext()}")
    exit(0)

# Load configuration
try:
    conf = AppConfig.load(toml_files=['config.toml'])
    print(f"Running {conf.app_name} on port {conf.port}. DB connected {conf.db.user} @ {conf.db.con}")
except ConfigError as e:
    print(e)
```

Same effect creates `cli_help_enabled=True` while loading. This raises a `UserNeedsHelp` Exception, containing a propper version of the  help-text, with respect to `cli_prefix` and `cli_separator`.

```python
try:
    conf = AppConfig.load(cli_help_enabled=True, toml_files=['config.toml'])
    print(f"Running {conf.app_name} on port {conf.port}")
except UserNeedsHelp as e:
    print(f"MYAPP Help\n\nAvailable CLI Parameter\n{e}")
    exit(0)
except ConfigError as e:
    print(e)
```

However, TypedConf adds in both versions an additional `--help` flag to the CLI interface:
```text
$ python main.py --help
MYAPP Help

Available CLI Parameter
--cfg_app_name (AppConfig.app_name)
   type=str, default=None
   application name, required field.

--cfg_db__con (DatabaseConfig.con)
   type=str, default=None
   DB connection-string, required field.

--cfg_db__pwd (DatabaseConfig.pwd)
   type=str, default=None
   DB password, required field.

--cfg_db__user (DatabaseConfig.user)
   type=str, default=None
   DB username, required field.

--cfg_port (AppConfig.port)
   type=int, default=8080
   application listen on port. Between 1000 and 9999, defaullt=8080
```


## Mutable Configuration

ConfigModel sets the pydantic model_config to:

```python
model_config = {
    "frozen": True,
    "extra": "forbid",
    "validate_default": True,
    "validate_assignment": True,
}
```

- Set ConfigModel to readonly
- Raise error, when loading unknown extra data
- Validate default values when loading
- validate when assigning a new value to a writeable ConfigModel

If you explicitly need a mutable/writable configuration at runtime, set (all nested) Pydantic's `frozen` Model_config to `False`:

```python
class AppConfig(ConfigModel):
    model_config = {'frozen': False}  # Makes pydantic basemodel mutable
    
    app_name: str
    port: int = 8080

conf = AppConfig.load(payload={'app_name': 'test'})
conf.port = 9999  # is now valid

```

## Exporting Configurations

Export your current configuration instance to JSON or TOML format.

```python
# Export TOML string (requires optional python package 'tomli_w')
print(conf.dumps_toml())

# Export JSON string
print(conf.dumps_json())
```

*Note: Exporting to TOML requires python package `tomli-w`.*



## Install 

```bash
pip install typedconf                          # minimal — nur pydantic
pip install "typedconf[dotenv]"                # + .env-Support
pip install "typedconf[toml_export]"           # + TOML-Export (dumps_toml)
pip install "typedconf[toml_read]"             # + TOML-Import (nur Python <3.11)
pip install "typedconf[all]"                   # alles
pip install "typedconf[dev]"                   # Entwicklung: pytest + alles
pip install "typedconf[dotenv,toml_export]"    # kombiniert
```

---

## TODOs
- 🙈

## Changelog
- v0.9.0 
  - cleanup code for 1st release/tag

## License

This project is licensed under the GPL-3.0 License - see the [LICENSE](https://www.google.com/search?q=LICENSE) file for details.


## Demos

### TypedConf Demo 00

```python
from typedconf import ConfigModel

class MyConfig(ConfigModel):
    app: str = 'myapp'
    log_enabled: bool = False
    tags: list[str] = ['a', 'b']

conf = MyConfig.load()
print(conf)     # app='myapp' log_enabled=False tags=['a', 'b']
```

### TypedConf Demo-01

```python
import sys
import logging
from typedconf import ConfigModel, ConfigError, UserNeedsHelp
from pydantic import Field

logging.basicConfig(level=logging.DEBUG, format='%(levelname)s - %(message)s')
RFC3986_URI_REGEX = r'^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})(?::\d+)?([\/\w \.-]*)*\/?$'


## CONFIGURATION
class MyConfig(ConfigModel):
    """Configuration for this script. In it's core ConfigModel is a pydantic Basemodel"""
    app: str
    log_enabled: bool = False
    loglevel: int = Field(0, ge=0, le=2, description="database log-level: 0=no logging, 1=errors, 2=info. Default 0")
    url: str = Field("http://myserver.com:1234/mydatabase", pattern=RFC3986_URI_REGEX, description="load data from this URL. validates url against RFC3986")
    tags: list[str] = Field(['a', 'b'], description="A list of strings. Hint: json list-style input is available in CLI, but not in ENV")


## MAIN
script_fn = sys.argv[0]
try:
    conf = MyConfig.load(
        payload={'app':'payload-app'},  # source payload
        toml=['config.toml'],   # load data from TOML file
        cli_help_enabled=True,  # enable --help in CLI, raises UserNeedsHelp
        skip_errors = True      # ignore errors while loading and parsing files. Default = False. NB: skip_errors does not effect pydantic's validation
    )
    print(conf)
    print(f"== JSON {'=' * 42}\n{conf.dumps_json()}\n{'=' * 50}") # conf.dumps_toml() available, if python package 'tomli_w' is installed

except UserNeedsHelp as e:
    help_config_fields = e
    help_message = f"""\
TypedConfig Demo-01.
Loads config from default, payload, toml-file (ignoring file errors). Available CLI arguments / ENV variables
{help_config_fields}

Examples:
uv run {script_fn}  # loads config from defaults and payload (and files)
uv run {script_fn} --cfg_app=cli-app  # overwrite config from CLI
uv run {script_fn} --cfg_app=cli-app --cfg_loglevel=2 --cfg_log_enabled=1 --cfg_tags='["x","y"]'
CFG_APP='env-app' uv run {script_fn} --cfg_app=cli-app  # ENV overrides CLI
uv run {script_fn} --cfg_loglevel=9  # validation failed
"""
    print(help_message)

    sys.exit(1)
except ConfigError as e:
    print(f"⚠️ ConfigError raised! {e}")
    sys.exit(2)

print(f"Try `uv run {script_fn} --help`")
sys.exit(0)
```

```text
DEBUG - <typedconf> LibWrapper using python 3.12.3, package to read toml [tomllib=True, tomli=False], optional package to write toml [tomli_w=True], optional package [dotenv=False]
WARNING - <typedconf> failed to read and parse toml file 'XX_config_demo01.toml'! file not found.
DEBUG - <typedconf> cli nothing found. prefix: '--cfg_' seperator: '__'
DEBUG - <typedconf> env nothing found. prefix: 'CFG_' seperator: '__'
INFO - <typedconf> ConfigModel 'MyConfig' loaded (readonly)
app='payload-app' log_enabled=False loglevel=0 url='http://myserver.com:1234/mydatabase' tags=['a', 'b']
== JSON ==========================================
{
  "app": "payload-app",
  "log_enabled": false,
  "loglevel": 0,
  "url": "http://myserver.com:1234/mydatabase",
  "tags": [
    "a",
    "b"
  ]
}
==================================================
Try `uv run main_demo01.py --help`
```


### TypedConf Demo-02

```python
import logging
from enum import Enum
import sys
from pydantic import Field
from typedconf import ConfigError, ConfigModel, UserNeedsHelp

logging.basicConfig(level=logging.DEBUG, format='%(levelname)s - %(message)s')
script_fn = sys.argv[0]


class StatusEnum(str,Enum):
    none  = "NONE"
    ok    = "OK"
    nok   = "NOT-OK"
    warn  = "WARNING"
    err   = "ERROR"

## configuration-schema
class L2(ConfigModel):
    name:str = 'L2'
    d2: int = Field(2, description="data L2-D2")
    d2b: list[str] = Field(['a','b','c'], description="list-data L2-D2b")
    d2c: int = Field(1111, ge=1000, le=9999, description="int-data L2-D2c, validate between 1000 and 9999.")
    d2d: StatusEnum = Field(StatusEnum.none, description="Last known status. Default none")

class L1(ConfigModel):
    name:str = 'L1'
    d1: int = Field(1, description="data L1-D1")
    l2: L2 = Field(default_factory=L2, description="link to a L2")
    
class L0(ConfigModel):
    name:str = 'L0'
    d0: int = Field(0, description="data L0-D0")
    l1: L1 = Field(default_factory=L1, description="link to a L1")

## main
try:
    conf = L0.load(
        payload={'name':'payload_L0', 'l1':{'name':'payload_L1', 'l2':{'d2c':2222, 'd2d':StatusEnum.ok}}},
        cli_help_enabled=True,          # --help enabled
        cli_prefix='',                  # no prefix for cli, env is disabled
        env=False,
        cli=True,
    )
    if conf.dumps_toml():
        print(f"== TOML {'=' * 42}\n{conf.dumps_toml()}\n{'=' * 50}")   # optional tomli_w is installed :)
    else:
        print(f"== JSON {'=' * 42}\n{conf.dumps_json()}\n{'=' * 50}")   # json fallback

except UserNeedsHelp as e:
    cli_args_helptxt = e
    print(f"""\
TypedConfig Demo-02. Available cli/env arguments:

{cli_args_helptxt}

Examples:
# 1) loads config from defaults and payload
  uv run {script_fn}
# 2) cli interface (no prefix, env is disabled)
  uv run {script_fn}  --name='cli_L0'
  uv run {script_fn} --l1__l2__d2d=ERROR         # using enum-values
  uv run {script_fn} --l1__l2__d2b='["x","y"]'   # using json-style array
# 3) data validation
  uv run {script_fn} --l1__l2__d2c=7777          # ok
  uv run {script_fn} --l1__l2__d2c=77            # fail
  uv run {script_fn} --l1__l2__d2d='WARNING      # ok
  uv run {script_fn} --l1__l2__d2d='UNKNOWN'     # fail
""")
    sys.exit(1)

except ConfigError as e:
    print(f"Configuration ERROR: {e}")
    sys.exit(2)

print(f"Try `uv run {script_fn} --help`")
sys.exit(0)
```

```text
DEBUG - <typedconf> LibWrapper using python 3.12.3, package to read toml [tomllib=True, tomli=False], optional package to write toml [tomli_w=True], optional package [dotenv=False]
DEBUG - <typedconf> cli nothing found. prefix: '--' seperator: '__'
INFO - <typedconf> ConfigModel 'L0' loaded (readonly)
== TOML ==========================================
name = "payload_L0"
d0 = 0

[l1]
name = "payload_L1"
d1 = 1

[l1.l2]
name = "L2"
d2 = 2
d2b = [
    "a",
    "b",
    "c",
]
d2c = 2222
d2d = "OK"

==================================================
Try `uv run main_demo02.py --help`
```
