Typed results
A load returns plain data — dict, list and scalars. Pass into= to get an
instance of a class instead, with every field coerced through its annotation:
validation, defaults and editor completion instead of string keys on an
untyped mapping.
import dataclasses
import anycfg
@dataclasses.dataclass
class Database:
host: str
port: int = 5432
db = anycfg.load("db.json", into=Database)
into= is accepted by load(), loads(), load_all(), loads_all(),
Loader.load() and Loader.load_all() (the last three apply it to each
document). The same engine is
available on its own as anycfg.hydrate(cls, data):
from anycfg import hydrate
print(hydrate(Database, {"host": "db", "port": "6432"})) # Database(host='db', port=6432)
The type steers the parse
YAML 1.1 reads a plain (unquoted) NO as False, 1.10 as the float 1.1
and 0755 as the octal integer 493 — a classic source of configuration bugs.
When you load into= a type, the type decides instead. While loading, YAML
keeps every plain scalar it read as a bool, number or date beside the text
that was written, and each field takes what its annotation asks for:
import dataclasses
import datetime
from typing import Any
import anycfg
document = """
country: NO
version: 1.10
mode: 0755
enabled: yes
port: 8080
released: 2024-01-02
extra: on
"""
@dataclasses.dataclass
class Release:
country: str
version: str
mode: str
enabled: bool
port: int
released: datetime.date
extra: Any = None
print(anycfg.loads(document, format="yaml", into=Release))
# Release(country='NO', version='1.10', mode='0755', enabled=True, port=8080, released=datetime.date(2024, 1, 2), extra=True)
- A
strfield gets the text as written:NOstays"NO",1.10stays"1.10",0755stays"0755". ADecimalfield also starts from the text, so0.10keeps its digits. - Any other type is built from the value YAML read (
enabled: boolgetsTrue,level: 1anIntEnummember,count: 1.0the int1), and from the text only when that value does not convert. A type with a_load_hook gets the text. - Places without type information —
Any,object, an unannotateddictorlist, keys the class does not declare — get the value YAML read. - Keys are never affected. A union picks the member holding the read
value, then the one holding the text, so
Union[int, str]keepsyesas"yes". - A Pydantic model gets the text for a field annotated
strand the read value everywhere else, then validates it.
The text is for the type only. Merging, includes, transform, key= and the
duplicate-key check see the values YAML read, exactly as without into=, so
adding into= never changes which document wins or what an include does.
Without into=, nothing changes: values are exactly what the format read.
So anycfg.load("app.yaml") still returns {'country': False, ...} —
quote such values, or load into a type.
Coercion
Formats such as INI, dotenv and the environment have only strings, and a
command or a JSON file may write a number as text. Hydration coerces each value
to its field's annotation, and never loses data doing it: "8080" becomes
8080, "false" becomes False, an ISO string becomes a date, but 1.9
for an int field raises rather than truncating, True for an int field
raises rather than becoming 1, and "maybe" for a bool raises rather than
becoming True. A Literal[...] takes only its values, and a NewType coerces
as its base type. The full table is in
Typed merge.
That makes the environment usable as typed configuration:
import dataclasses
from typing import Optional
from anycfg import Loader
from anycfg.sources import Env
@dataclasses.dataclass
class Settings:
debug: bool = False
workers: int = 1
region: Optional[str] = None
environ = {"APP_DEBUG": "yes", "APP_WORKERS": "4"}
settings = Loader().load(Env("APP_", lowercase=True, environ=environ), into=Settings)
print(settings) # Settings(debug=True, workers=4, region=None)
What into= accepts
Dataclasses
Fields are built from the document's keys:
- a key the class does not accept is dropped by default; pass
extra="forbid"tohydrateto raiseConfigTypeErrornaming it; - a missing key takes the field's default; a missing required field raises
ConfigTypeError, naming the model; - a field annotated with another dataclass (or
Optional[...]of one) is built recursively, and so are lists and dicts of them (List[Host],Dict[str, Host]); field(init=False)fields are left to__post_init__.
from typing import List
@dataclasses.dataclass
class Host:
name: str
port: int = 80
@dataclasses.dataclass
class App:
name: str
hosts: List[Host] = dataclasses.field(default_factory=list)
app = hydrate(App, {"name": "web", "hosts": [{"name": "a", "port": "8080"}, {"name": "b"}]})
print(app.hosts) # [Host(name='a', port=8080), Host(name='b', port=80)]
TypedDict and mapping types
A TypedDict comes back as a dict with its declared keys coerced; other
keys are kept with the value the format read. dict subclasses and
Dict[str, int] are built from the coerced items.
from typing import TypedDict
class Server(TypedDict):
host: str
port: int
print(anycfg.loads("host: example.org\nport: '8080'\n", format="yaml", into=Server))
# {'host': 'example.org', 'port': 8080}
Ordinary classes
Any other class is built from a mapping by passing its fields to the
constructor as keywords, coerced through the class's annotations or, when
it declares none, its constructor's. A constructor taking **kwargs receives
every key; a constructor that refuses the data raises ConfigTypeError.
class Endpoint:
def __init__(self, host: str, port: int = 80):
self.host, self.port = host, port
endpoint = hydrate(Endpoint, {"host": "example.org", "port": "8443"})
print(endpoint.host, endpoint.port) # example.org 8443
Namespaces
argparse.Namespace and TypedNamespace take the keys as attributes; a
TypedNamespace applies its _parse_<field> hooks (see
Coercing one field).
Pydantic
A Pydantic model (v1 or v2) is validated by Pydantic itself, so its own coercion, validators and errors apply — and it receives the scalars' texts:
from pydantic import BaseModel
import anycfg
class DBConfig(BaseModel):
host: str
port: int
db = anycfg.load("config.yaml", into=DBConfig)
Pydantic stays optional: anycfg never imports it, and recognizes a model only through a Pydantic that your code has already imported.
Building a type from a value
A field whose type is not a container, a dataclass or a scalar — an IP
network, a URL, a size with units — is built by its classmethod
_load_(cls, value). A scalar arrives as the text written in the
document when that is known (otherwise the parsed scalar), a mapping or list
as plain data, and several mapping layers are deep-merged first. The method
_dump_(self) is the inverse: it returns the instance as plain data, for
writing.
import ipaddress
class Network:
def __init__(self, cidr):
self.cidr = ipaddress.ip_network(cidr)
@classmethod
def _load_(cls, value):
return cls(value)
def _dump_(self):
return str(self.cidr)
@dataclasses.dataclass
class Lan:
subnet: Network
gateway: str
lan = anycfg.loads("subnet: 10.0.0.0/8\ngateway: 10.0.0.1\n", format="yaml", into=Lan)
print(lan.subnet.cidr.num_addresses) # 16777216
Both hooks are looked up on the unwrapped type, so an Optional[Network]
field finds them too.
Writing typed results back
anycfg.dehydrate(obj) turns typed data back into plain data a format
can write — the inverse of hydrate:
- a type's
_dump_()result; - a dataclass, a namespace or a
NamedTupleas a mapping; - an
Enummember as its value; - a path, a
Decimalor aUUIDas its text; - mappings, lists and tuples walked, aliases and cycles kept;
- anything else as it is, for the format to accept or refuse.
dump() and dumps() apply it before writing, so a typed configuration
writes directly, and a load into a type followed by a dump round-trips:
anycfg.dump(lan, "lan.yaml")
print(open("lan.yaml").read())
# subnet: 10.0.0.0/8
# gateway: 10.0.0.1
print(anycfg.load("lan.yaml", into=Lan).subnet.cidr) # 10.0.0.0/8
Errors
A value that does not fit raises ConfigTypeError or ConfigValueError with
the field's path and the model you asked for — in the message and as
error.config_key and error.config_model:
try:
hydrate(App, {"name": "web", "hosts": [{"name": "a", "port": "eighty"}]})
except anycfg.ConfigError as error:
print(error.config_key, error.config_model) # ('hosts', 0, 'port') App
Hydrating no data at all — every source was optional and none loaded — raises
ConfigTypeError; pass default={} to Loader.load() to build from the
class's defaults instead.
Merging typed layers
typed_merge(cls, *layers) merges several layers into one instance, field by
field — the many-layer form of hydrate. It and its hooks (__merge__,
OpaqueMerge, opaque, TypedNamespace) are described under
Typed merge.