Tutorials¶
Hands-on guides that show how to assemble real PKonfig setups: compose storages, structure nested configs, and extend the library when built-ins are not enough.
Understand which storage backend fits each source of truth.
Layer storages with predictable precedence and sensible defaults.
Model complex configuration trees with nested
Config
classes and aliases.Extend PKonfig with custom fields, descriptors, and convenience helpers.
Prerequisites¶
Install PKonfig with any extras you plan to use (
pip install pkonfig[yaml,toml]
).Activate a virtual environment so examples do not mutate your system interpreter.
When examples temporarily mutate
os.environ
, clean up afterwards if you are running them in a long-lived shell.
Configuration sources¶
PKonfig ships with several storage backends. They all implement the Mapping
interface expected by Config
and flatten nested structures into tuple keys.
In-memory defaults with DictStorage
¶
Use DictStorage
when you want code-defined defaults or you prefer to keep sensitive values outside the class definition.
from pkonfig import Config, DictStorage, Str
class AppConfig(Config):
foo = Str() # raises ConfigValueNotFoundError if not provided
cfg = AppConfig(DictStorage(foo="baz"))
print(cfg.foo) # 'baz'
You can also pass values as keyword arguments directly to Config. They are automatically wrapped into a DictStorage and composed with any other storages you pass.
from pkonfig import Config, Str
from pkonfig.storage import Env
class AppConfig(Config):
foo = Str()
# simplest form — values via kwargs
cfg = AppConfig(foo="baz")
print(cfg.foo) # 'baz'
# when combined with other storages, kwargs are appended as the last (lowest-priority) DictStorage
cfg = AppConfig(Env(prefix="APP"), foo="fallback")
Tip
DictStorage
is also handy in unit tests because you can inject dictionaries directly instead of touching the filesystem or environment.
Environment variables (Env
)¶
Environment variables are the most portable way to configure services. Env
understands prefixes and delimiters so you can group values.
from os import environ
from pkonfig.storage import Env
environ.update({
"APP_OUTER": "foo",
"APP_INNER_KEY": "baz",
"IGNORED": "value",
})
source = Env(prefix="APP", delimiter="_")
print(source[("outer",)]) # foo
print(source[("inner", "key")]) # baz
Note
Keys are matched case-insensitively. Pass prefix=None
(or ""
) to opt out and read every variable.
from os import environ
from pkonfig.storage import Env
environ["WHATEVER"] = "value"
print(Env(prefix=None)[("whatever",)]) # value
.env
files (DotEnv
)¶
.env
files are convenient during local development. DotEnv
mirrors Env
, trimming the prefix and delimiter as it loads lines.
# test.env
APP_DB_HOST=db.local
APP_DB_PORT=5432
from pkonfig.storage import DotEnv
dev_overrides = DotEnv("test.env", prefix="APP", delimiter="_", missing_ok=True)
print(dev_overrides[("db", "host")]) # db.local
INI files (Ini
)¶
Ini
wraps configparser.ConfigParser
, exposing the same configuration knobs.
# config.ini
[DEFAULT]
ServerAliveInterval = 45
[bitbucket.org]
User = hg
from pkonfig.storage import Ini
storage = Ini("config.ini", missing_ok=False)
print(storage[("bitbucket.org", "User")]) # hg
print(storage[("bitbucket.org", "ServerAliveInterval")]) # 45
JSON, YAML, and TOML¶
Each structured file format has a dedicated backend. Install the optional extras if you use YAML or TOML.
from pkonfig.storage import Json, Toml, Yaml
json_settings = Json("config.json", missing_ok=True)
yaml_settings = Yaml("config.yaml", missing_ok=False)
toml_settings = Toml("config.toml", missing_ok=False)
Important
Toml
uses tomllib
on Python ≥3.11 and falls back to tomli
on earlier versions. Make sure the toml
extra is installed if you target Python 3.10 or below.
Ordering storages for precedence¶
Storages are evaluated left-to-right, so earlier sources override later ones. Chain together as many as you need.
from pkonfig import Config, DotEnv, Env, Str, Yaml
class AppConfig(Config):
foo = Str()
cfg = AppConfig(
DotEnv("test.env", missing_ok=True), # developer overrides
Env(prefix="APP"), # runtime overrides
Yaml("base.yaml"), # defaults committed to the repo
)
Tip
Call cfg.get_storage().maps
to inspect the underlying ChainMap
if you need to debug which source supplied a value.
When you provide values via keyword arguments to Config(…), they are wrapped into a DictStorage and appended as the last (lowest-priority) source in the chain.
Building configuration classes¶
Declare fields on subclasses of Config
. PKonfig eagerly validates them (unless you disable fail_fast
).
from pkonfig import Config, Float, Int
class AppConfig(Config):
ratio = Float()
workers = Int(default=1)
cfg = AppConfig(ratio="0.33")
print(cfg.ratio) # 0.33
print(cfg.workers) # 1
Nested configs¶
Group related settings by nesting other Config
classes.
from pkonfig import Config, Int, Str
class Database(Config):
host = Str(default="localhost")
port = Int(default=5432)
class App(Config):
db = Database(alias="db")
timezone = Str(default="UTC")
cfg = App(db={"port": 6432})
print(cfg.db.port) # 6432
print(cfg.timezone) # UTC
Loading multilevel keys from .env
¶
from pkonfig import Config, DotEnv, Int, Str
class Pg(Config):
host = Str(default="localhost")
port = Int(default=5432)
class Redis(Config):
host = Str(default="localhost")
port = Int(default=6379)
class AppConfig(Config):
pg = Pg()
redis = Redis()
cfg = AppConfig(DotEnv(".env", delimiter="__", prefix="APP"))
print(cfg.pg.host)
print(cfg.redis.host)
# .env
APP__PG__HOST=db_host
APP__PG__PORT=6432
APP__REDIS__HOST=redis
Aliases for ergonomic keys¶
Aliases let storages look up alternative names without changing attribute access in Python.
from pkonfig import Config, DotEnv, Int, Str
class Host(Config):
host = Str(default="localhost")
password = Str(alias="PASS")
class AppConfig(Config):
pg = Host(alias="DB")
retries = Int(alias="MY_ALIAS", default=1)
cfg = AppConfig(DotEnv(".env", delimiter="__", prefix="APP"))
print(cfg.pg.password)
print(cfg.retries)
# .env
APP__DB__PASS=password
APP__MY_ALIAS=5
Hint
Aliases are especially helpful when migrating from an older configuration naming scheme—you can keep legacy keys alive while exposing clean attribute names in code.
Field behaviour and customization¶
Fields encapsulate casting, validation, and caching. The snippets below highlight common patterns.
Type hints and caching¶
Declaring type annotations is enough for many cases—PKonfig resolves them to appropriate fields and caches the validated result after the first access.
from pathlib import Path
from pkonfig import Config
class Paths(Config):
bucket: str
log_level: str
config_file: Path
cfg = Paths(bucket="assets", log_level="INFO", config_file="config.yaml")
print(cfg.config_file)
Defining configs using only type hints¶
You can define required fields by annotating attributes without assigning field instances. PKonfig will infer sensible field types from the annotations and validate/cast values from storages.
from pkonfig import Config, DictStorage
class Simple(Config):
host: str # required
port: int # required
cfg = Simple(DictStorage(host="localhost", port=5432))
assert cfg.host == "localhost"
assert cfg.port == 5432
Provide defaults by assigning plain Python literals to typed attributes. The default will be used if the value is not found in storages.
from pkonfig import Config
class WithDefaults(Config):
retries: int = 3
region: str = "us-east-1"
cfg = WithDefaults()
assert cfg.retries == 3
assert cfg.region == "us-east-1"
You can also annotate with PKonfig field classes and still assign plain literals as defaults.
This is useful when you want the behavior of a specific field (e.g., File
, Decimal
) while keeping a concise declaration.
from pathlib import Path
from pkonfig import File, Config
class AnnotatedWithField(Config):
foo: File = "foo.txt"
assert AnnotatedWithField().foo == Path("foo")
Nested configs with type hints¶
Nested configuration groups can be declared purely via type annotations by referencing other Config
subclasses as types:
from pkonfig import Config
class Inner(Config):
required: int
class App(Config):
inner: Inner # required group
cfg = App(inner={"required": 1234})
assert cfg.inner["required"] == 1234
You can mix instance-style and type-hint-style declarations side by side:
from pkonfig import Int, Str, Config
class Inner(Config):
foo = Str("baz", nullable=False)
fiz = Int(123, nullable=False)
required = Int()
class App(Config):
inner_1 = Inner() # instance declaration
inner_2: Inner # type-hint declaration
foo = Int()
Each nested config attribute maintains its own independent cache even if they share the same type:
from pkonfig import Config, DictStorage
class Inner(Config):
required: int
class Duo(Config):
i1: Inner
i2: Inner
cfg = Duo(DictStorage(i1={"required": 1234}, i2={"required": 4321}))
assert cfg.i1["required"] == 1234
assert cfg.i2["required"] == 4321
Caching semantics for type-hinted fields¶
PKonfig caches the validated value after the first access. Subsequent changes in the underlying storage won’t affect already accessed attributes until you reconstruct the config object.
from pkonfig import Config, DictStorage
class CacheDemo(Config):
foo: str
storage = DictStorage(foo="bar")
cfg = CacheDemo(storage)
assert cfg.foo == "bar"
# Mutate the storage after access
storage._actual_storage[("foo",)] = "baz"
# Value is cached on the config instance
assert cfg.foo == "bar"
Default values and nullability¶
from pkonfig import Config, DictStorage, Int, Str
class MaybeConfig(Config):
retries = Int(default=3)
optional_token = Str(default=None)
cfg = MaybeConfig(DictStorage(optional_token=None))
print(cfg.retries) # 3
print(cfg.optional_token) # None
Set nullable=True
to allow None
without casting.
from pkonfig import Config, DictStorage, Int
class NullableExample(Config):
retries = Int(nullable=True)
cfg = NullableExample(DictStorage(retries=None))
print(cfg.retries is None) # True
Custom computed properties¶
from pkonfig import Bool, Config, Str
class FeatureFlags(Config):
enabled = Bool(default=True)
environment = Str(default="test")
@property
def is_prod(self) -> bool:
return self.enabled and self.environment == "prod"
cfg = FeatureFlags(environment="prod")
print(cfg.is_prod)
Custom fields and validators¶
Extend built-in fields when you need bespoke validation or casting.
from pkonfig import Config, Field, Int
class PositiveInt(Int):
def validate(self, value: int) -> None:
if value < 0:
raise ValueError("Only positive values accepted")
class CommaSeparated(Field[list[str]]):
def cast(self, raw: str) -> list[str]:
return [part.strip() for part in raw.split(",") if part]
class CustomConfig(Config):
ports = PositiveInt()
tags = CommaSeparated(default="alpha,beta")
Specialized fields¶
PKonfig includes helpers for filesystem paths, enums, log levels, and constrained choices.
from enum import Enum
from pathlib import Path
import logging
from pkonfig import Choice, Config, DictStorage, EnumField, File, LogLevel, PathField
class Mode(Enum):
prod = "prod"
staging = "staging"
class App(Config):
mode = EnumField(Mode)
config_path = File()
debug_level = LogLevel(default="INFO")
region = Choice(["us-east-1", "eu-west-1"])
cfg = App(
DictStorage(
mode="prod",
config_path=__file__,
debug_level="warning",
region="us-east-1",
)
)
print(cfg.mode, cfg.config_path, cfg.debug_level)
Working with lists (ListField and type hints)¶
PKonfig can parse comma-separated strings or existing iterables into Python lists. There are two convenient ways to use lists:
Explicitly declare a
ListField
with acast_function
that converts each element.Use Python type hints like
list[int]
/list[str]
; PKonfig will internally create aListField
for you.
Explicit ListField¶
from pkonfig import Config, DictStorage
from pkonfig.fields import ListField
class C(Config):
ids = ListField(cast_function=int)
cfg = C(DictStorage(ids="1, 2, 3"))
assert cfg.ids == [1, 2, 3]
Use a custom separator and element type:
from pkonfig import Config, DictStorage
from pkonfig.fields import ListField
class Csv(Config):
floats = ListField(separator=";", cast_function=float)
assert Csv(DictStorage(floats="1.0; 2.5; 3")).floats == [1.0, 2.5, 3.0]
Type-hinted lists¶
from pkonfig import Config, DictStorage
class App(Config):
ports: list[int]
cfg = App(DictStorage(ports="8000,8001,8002"))
assert cfg.ports == [8000, 8001, 8002]
You can also provide a default value:
from pkonfig import Config
class Defaults(Config):
tags: list[str] = ["latest", "prod"]
assert Defaults().tags == ["latest", "prod"]
Environment-specific configuration files¶
Select configuration files dynamically by reading a simple config first.
from pkonfig import Choice, Config, Env, Yaml
CONFIG_FILES = {
"prod": "configs/prod.yaml",
"staging": "configs/staging.yaml",
"local": "configs/local.yaml",
}
def resolve_config_path() -> str:
class _Config(Config):
env = Choice(["prod", "local", "staging"], cast_function=str.lower, default="prod")
selector = _Config(Env(prefix="APP"))
return CONFIG_FILES[selector.env]
class AppConfig(Config):
...
config = AppConfig(Env(), Yaml(resolve_config_path()))
Where to go next¶
Revisit the API reference for detailed signatures and extension points.
Skim the Quickstart if you want a linear setup guide.
Explore the
tests/
directory for executable examples that pair with these tutorials.