Python Dataclasses — Code Generation, Immutability,: Practice Problems & Exercises
Practice: Dataclasses — Code Generation, Immutability, and Production Patterns
← Back to lessonEasy
Define a Point dataclass with two float fields x and y. The @dataclass decorator will generate __init__ and __repr__ automatically.
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p = Point(3.0, 4.0)
print(p)
print(p.x, p.y)Solution
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p = Point(3.0, 4.0)
print(p) # Point(x=3.0, y=4.0)
print(p.x, p.y) # 3.0 4.0
# __eq__ is also generated:
p2 = Point(3.0, 4.0)
print(p == p2) # True
print(p is p2) # False
Explanation: @dataclass inspects class-level annotations and generates __init__ (with one parameter per annotated field), __repr__ (showing all fields), and __eq__ (comparing field values). This eliminates the ~10 lines of boilerplate that a regular class would need.
from dataclasses import dataclass
# TODO: define a Point dataclass with x: float and y: float
@dataclass
class Point:
pass
p = Point(3.0, 4.0)
print(p)
print(p.x, p.y)Expected Output
Point(x=3.0, y=4.0)\n3.0 4.0Hints
Hint 1: Add x: float and y: float as class-level annotations inside the @dataclass class.
Hint 2: @dataclass generates __init__, __repr__, and __eq__ automatically.
Create two Config instances: one using all defaults, one with custom values. Verify the __repr__ output for both.
from dataclasses import dataclass
@dataclass
class Config:
host: str = "localhost"
port: int = 8080
debug: bool = False
default_cfg = Config()
custom_cfg = Config(host="prod.example.com", port=443, debug=False)
print(default_cfg)
print(custom_cfg)Solution
from dataclasses import dataclass
@dataclass
class Config:
host: str = "localhost"
port: int = 8080
debug: bool = False
default_cfg = Config()
custom_cfg = Config(host="prod.example.com", port=443, debug=False)
partial_cfg = Config(port=9000) # only override port
print(default_cfg) # Config(host='localhost', port=8080, debug=False)
print(custom_cfg) # Config(host='prod.example.com', port=443, debug=False)
print(partial_cfg) # Config(host='localhost', port=9000, debug=False)
Explanation: Fields with default values become optional parameters in the generated __init__. Fields without defaults are mandatory. A critical rule: fields without defaults must be declared before fields with defaults, otherwise Python raises TypeError ("non-default argument follows default argument").
from dataclasses import dataclass
@dataclass
class Config:
host: str = "localhost"
port: int = 8080
debug: bool = False
# TODO: create a default Config and a custom Config
default_cfg = Config()
custom_cfg = Config(host="prod.example.com", port=443, debug=False)
print(default_cfg)
print(custom_cfg)Expected Output
Config(host='localhost', port=8080, debug=False)\nConfig(host='prod.example.com', port=443, debug=False)Hints
Hint 1: Fields with defaults are optional in __init__.
Hint 2: Fields without defaults must come before fields with defaults.
Run the code and verify that o1.items and o2.items are independent lists. Then try replacing default_factory=list with = [] and observe the ValueError dataclasses raises to protect you.
from dataclasses import dataclass, field
from typing import List
@dataclass
class Order:
order_id: int
items: List[str] = field(default_factory=list)
o1 = Order(order_id=1)
o2 = Order(order_id=2)
o1.items.append("Apple")
print(o1.items) # ['Apple']
print(o2.items) # []
# Try the mutable default mistake:
try:
@dataclass
class BadOrder:
items: List[str] = []
except ValueError as e:
print(f"ValueError: {e}")Solution
from dataclasses import dataclass, field
from typing import List, Dict
@dataclass
class Order:
order_id: int
items: List[str] = field(default_factory=list)
metadata: Dict[str, str] = field(default_factory=dict)
o1 = Order(order_id=1)
o2 = Order(order_id=2)
o1.items.append("Apple")
o1.metadata["source"] = "web"
print(o1.items) # ['Apple']
print(o2.items) # [] — independent
print(o1.metadata) # {'source': 'web'}
print(o2.metadata) # {} — independent
# dataclasses raises ValueError to prevent the mutable default trap:
try:
@dataclass
class BadOrder:
items: List[str] = []
except ValueError as e:
print(f"ValueError: {e}")
Explanation: Mutable defaults (like [] or {}) are shared across all instances because Python evaluates default values once at class definition time. default_factory is called for each new instance, guaranteeing isolation. Dataclasses detect the mistake and raise ValueError immediately if you try to use a mutable default directly.
from dataclasses import dataclass, field
from typing import List
@dataclass
class Order:
order_id: int
items: List[str] = field(default_factory=list)
o1 = Order(order_id=1)
o2 = Order(order_id=2)
o1.items.append("Apple")
print(o1.items)
print(o2.items)Expected Output
['Apple']\n[]Hints
Hint 1: default_factory=list means each instance gets its own empty list.
Hint 2: Never use items: List[str] = [] — all instances would share the same list object.
Verify that frozen=True makes a dataclass immutable by attempting to modify an attribute and catching the resulting error.
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
c = Coordinate(51.5, -0.12)
print(c)
try:
c.lat = 0.0
except Exception as e:
print(type(e).__name__, e)Solution
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
c = Coordinate(51.5, -0.12)
print(c) # Coordinate(lat=51.5, lon=-0.12)
print(hash(c)) # frozen dataclasses are hashable
# Attempt mutation:
try:
c.lat = 0.0
except Exception as e:
print(type(e).__name__, e)
# Can be used as a dict key or set member:
visited = {c: "London"}
print(visited[Coordinate(51.5, -0.12)]) # London
# Use dataclasses.replace() to create modified copies:
from dataclasses import replace
c2 = replace(c, lat=40.71)
print(c2) # Coordinate(lat=40.71, lon=-0.12)
Explanation: frozen=True converts the dataclass into a value object. It generates __hash__ (making instances usable in sets and as dict keys) and overrides __setattr__ and __delattr__ to raise FrozenInstanceError. Use dataclasses.replace() to create modified copies — the immutable equivalent of obj.attr = new_value.
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
c = Coordinate(51.5, -0.12)
print(c)
# TODO: try to modify c.lat and catch the FrozenInstanceError
try:
c.lat = 0.0
except Exception as e:
print(type(e).__name__, e)Expected Output
Coordinate(lat=51.5, lon=-0.12)\nFrozenInstanceError cannot assign to field 'lat'Hints
Hint 1: frozen=True generates __hash__ and prevents attribute assignment.
Hint 2: The exception type is dataclasses.FrozenInstanceError (a subclass of AttributeError).
Medium
Implement __post_init__ to validate that value lies within [min_val, max_val]. The validation should run automatically on every instantiation.
from dataclasses import dataclass
@dataclass
class BoundedInt:
value: int
min_val: int
max_val: int
def __post_init__(self):
if not (self.min_val <= self.value <= self.max_val):
raise ValueError(
f"{self.value} is not in range [{self.min_val}, {self.max_val}]"
)
b = BoundedInt(value=5, min_val=1, max_val=10)
print(b)
try:
bad = BoundedInt(value=15, min_val=1, max_val=10)
except ValueError as e:
print(f"ValueError: {e}")Solution
from dataclasses import dataclass
@dataclass
class BoundedInt:
value: int
min_val: int
max_val: int
def __post_init__(self) -> None:
if not (self.min_val <= self.value <= self.max_val):
raise ValueError(
f"{self.value} is not in range [{self.min_val}, {self.max_val}]"
)
# Optional: also validate min_val <= max_val
if self.min_val > self.max_val:
raise ValueError(
f"min_val ({self.min_val}) must be <= max_val ({self.max_val})"
)
b = BoundedInt(value=5, min_val=1, max_val=10)
print(b) # BoundedInt(value=5, min_val=1, max_val=10)
try:
BoundedInt(value=15, min_val=1, max_val=10)
except ValueError as e:
print(f"ValueError: {e}")
try:
BoundedInt(value=5, min_val=10, max_val=1)
except ValueError as e:
print(f"ValueError: {e}")
Explanation: __post_init__ is the canonical place for dataclass validation. It runs after the generated __init__ populates all fields, so you can safely access self.value, self.min_val, etc. This is the dataclass alternative to writing a custom __init__ — you keep code generation but add runtime constraints.
from dataclasses import dataclass
@dataclass
class BoundedInt:
value: int
min_val: int
max_val: int
def __post_init__(self):
# TODO: raise ValueError if value is outside [min_val, max_val]
pass
b = BoundedInt(value=5, min_val=1, max_val=10)
print(b)
try:
bad = BoundedInt(value=15, min_val=1, max_val=10)
except ValueError as e:
print(f"ValueError: {e}")Expected Output
BoundedInt(value=5, min_val=1, max_val=10)\nValueError: 15 is not in range [1, 10]Hints
Hint 1: __post_init__ is called automatically after __init__ completes.
Hint 2: Check if self.value < self.min_val or self.value > self.max_val.
Use InitVar to accept raw_password during construction without storing it. Store only the hashed value as password_hash.
from dataclasses import dataclass, field, InitVar
@dataclass
class HashedPassword:
username: str
password_hash: str = field(init=False)
raw_password: InitVar[str] = None
def __post_init__(self, raw_password: str) -> None:
self.password_hash = str(hash(raw_password))
user = HashedPassword(username="alice", raw_password="secret")
print(user.username)
print(user.password_hash != "secret") # True
print(hasattr(user, "raw_password")) # FalseSolution
from dataclasses import dataclass, field, InitVar
import hashlib
@dataclass
class HashedPassword:
username: str
# Not stored as an instance attribute — init=False means
# the generated __init__ won't accept it as a parameter
password_hash: str = field(init=False, repr=False)
# InitVar: passed to __post_init__ only, never stored
raw_password: InitVar[str] = None
def __post_init__(self, raw_password: str) -> None:
if raw_password is None:
raise ValueError("raw_password is required")
# In production use bcrypt/argon2, not hashlib
self.password_hash = hashlib.sha256(
raw_password.encode()
).hexdigest()
user = HashedPassword(username="alice", raw_password="secret")
print(user.username) # alice
print(user.password_hash != "secret") # True
print(hasattr(user, "raw_password")) # False
# The password is not printed (repr=False):
print(user) # HashedPassword(username='alice')
Explanation: InitVar creates a constructor parameter that is threaded to __post_init__ and then discarded — it never becomes an instance attribute. Combined with field(init=False) for password_hash, this pattern lets you accept a raw value, transform it, and store only the derived result. The repr=False on password_hash prevents sensitive data from appearing in logs.
from dataclasses import dataclass, field
from dataclasses import InitVar
@dataclass
class HashedPassword:
username: str
password_hash: str = field(init=False)
raw_password: InitVar[str] = None
def __post_init__(self, raw_password: str):
# TODO: set self.password_hash to a simple hash of raw_password
# Use hash(raw_password) for simplicity
pass
user = HashedPassword(username="alice", raw_password="secret")
print(user.username)
print(user.password_hash != "secret") # True — it was hashed
print(hasattr(user, "raw_password")) # False — not storedExpected Output
alice\nTrue\nFalseHints
Hint 1: InitVar fields are passed to __post_init__ but NOT stored as instance attributes.
Hint 2: Set self.password_hash = str(hash(raw_password)) in __post_init__.
Use order=True to make Version objects sortable. The comparison should use major, minor, then patch in that order.
from dataclasses import dataclass
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 3)
print(v1 < v2)
print(v1 == v3)
versions = [Version(2, 0, 0), Version(1, 9, 9), Version(1, 2, 3)]
print(sorted(versions))Solution
from dataclasses import dataclass
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 3)
print(v1 < v2) # True (1.2.3 < 1.3.0)
print(v1 == v3) # True (field-by-field equality)
print(v1 > v2) # False
versions = [Version(2, 0, 0), Version(1, 9, 9), Version(1, 2, 3)]
print(sorted(versions))
# [Version(major=1, minor=2, patch=3),
# Version(major=1, minor=9, patch=9),
# Version(major=2, minor=0, patch=0)]
latest = max(versions)
print(latest) # Version(major=2, minor=0, patch=0)
Explanation: order=True generates all six comparison methods based on a tuple of the fields in declaration order. This is equivalent to manually implementing __lt__, __le__, __gt__, __ge__ that compare (self.major, self.minor, self.patch) tuples. The field order in the class definition determines the sort priority.
from dataclasses import dataclass
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
v3 = Version(1, 2, 3)
print(v1 < v2)
print(v1 == v3)
versions = [Version(2, 0, 0), Version(1, 9, 9), Version(1, 2, 3)]
print(sorted(versions))Expected Output
True\nTrue\n[Version(major=1, minor=2, patch=3), Version(major=1, minor=9, patch=9), Version(major=2, minor=0, patch=0)]Hints
Hint 1: order=True generates __lt__, __le__, __gt__, __ge__ based on the tuple of all fields in declaration order.
Hint 2: Fields are compared left-to-right: major first, then minor, then patch.
Use ClassVar to track the total number of Widget instances created. The counter should increment in __post_init__ and be accessible via a class method.
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class Widget:
_count: ClassVar[int] = 0
name: str
def __post_init__(self) -> None:
Widget._count += 1
@classmethod
def total(cls) -> int:
return cls._count
w1 = Widget("Button")
w2 = Widget("Label")
w3 = Widget("Input")
print(Widget.total())Solution
from dataclasses import dataclass, fields
from typing import ClassVar
@dataclass
class Widget:
# ClassVar: excluded from __init__, __repr__, __eq__
_count: ClassVar[int] = 0
name: str
visible: bool = True
def __post_init__(self) -> None:
Widget._count += 1
@classmethod
def total(cls) -> int:
return cls._count
@classmethod
def reset(cls) -> None:
cls._count = 0
w1 = Widget("Button")
w2 = Widget("Label")
w3 = Widget("Input", visible=False)
print(Widget.total()) # 3
print(w1) # Widget(name='Button', visible=True)
# _count does not appear in repr — ClassVar is excluded
# Verify: ClassVar not in dataclass fields
print([f.name for f in fields(Widget)]) # ['name', 'visible']
Explanation: ClassVar[T] signals to @dataclass that this annotation is a class variable, not an instance field. The decorator excludes it from the generated __init__, __repr__, and __eq__. At runtime it behaves as a regular class attribute — shared across all instances and accessible via the class name.
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class Widget:
_count: ClassVar[int] = 0
name: str
def __post_init__(self):
# TODO: increment _count on every new instance
pass
@classmethod
def total(cls) -> int:
return cls._count
w1 = Widget("Button")
w2 = Widget("Label")
w3 = Widget("Input")
print(Widget.total())Expected Output
3Hints
Hint 1: ClassVar fields are excluded from __init__ — they are not per-instance.
Hint 2: In __post_init__, do Widget._count += 1 (or type(self)._count += 1).
Hard
Implement Dog.__post_init__ to call the parent validation (negative age) and add its own (empty breed string). Both validation layers should work independently.
from dataclasses import dataclass
@dataclass
class Animal:
name: str
age: int
def __post_init__(self) -> None:
if self.age < 0:
raise ValueError(f"Age cannot be negative: {self.age}")
@dataclass
class Dog(Animal):
breed: str = "Unknown"
def __post_init__(self) -> None:
super().__post_init__() # run Animal validation
if not self.breed:
raise ValueError("breed cannot be empty")
d = Dog(name="Rex", age=3, breed="Labrador")
print(d)
try:
Dog(name="Max", age=-1, breed="Poodle")
except ValueError as e:
print(f"ValueError: {e}")
try:
Dog(name="Spot", age=2, breed="")
except ValueError as e:
print(f"ValueError: {e}")Solution
from dataclasses import dataclass
@dataclass
class Animal:
name: str
age: int
def __post_init__(self) -> None:
if self.age < 0:
raise ValueError(f"Age cannot be negative: {self.age}")
if not self.name.strip():
raise ValueError("name cannot be blank")
@dataclass
class Dog(Animal):
breed: str = "Unknown"
def __post_init__(self) -> None:
super().__post_init__() # delegate Animal validations first
if not self.breed.strip():
raise ValueError("breed cannot be empty")
@dataclass
class GuideDog(Dog):
handler: str = ""
def __post_init__(self) -> None:
super().__post_init__() # runs Dog -> Animal validations
if not self.handler.strip():
raise ValueError("handler name required for guide dog")
d = Dog(name="Rex", age=3, breed="Labrador")
print(d) # Dog(name='Rex', age=3, breed='Labrador')
try:
Dog(name="Max", age=-1, breed="Poodle")
except ValueError as e:
print(f"ValueError: {e}") # Age cannot be negative: -1
try:
Dog(name="Spot", age=2, breed="")
except ValueError as e:
print(f"ValueError: {e}") # breed cannot be empty
try:
GuideDog(name="Buddy", age=4, breed="Lab", handler="")
except ValueError as e:
print(f"ValueError: {e}") # handler name required for guide dog
Explanation: super().__post_init__() chains validation up the dataclass hierarchy. Each class validates only what it owns — Animal checks age, Dog checks breed, GuideDog checks handler. This mirrors the Open/Closed Principle: adding a new subclass adds new validation without modifying the parent class.
from dataclasses import dataclass
@dataclass
class Animal:
name: str
age: int
def __post_init__(self):
if self.age < 0:
raise ValueError(f"Age cannot be negative: {self.age}")
@dataclass
class Dog(Animal):
breed: str = "Unknown"
def __post_init__(self):
# TODO: call parent __post_init__ AND add own validation
# Raise ValueError if breed is an empty string
pass
d = Dog(name="Rex", age=3, breed="Labrador")
print(d)
try:
bad = Dog(name="Max", age=-1, breed="Poodle")
except ValueError as e:
print(f"ValueError: {e}")
try:
bad2 = Dog(name="Spot", age=2, breed="")
except ValueError as e:
print(f"ValueError: {e}")Expected Output
Dog(name='Rex', age=3, breed='Labrador')\nValueError: Age cannot be negative: -1\nValueError: breed cannot be emptyHints
Hint 1: Call super().__post_init__() first to run parent validation.
Hint 2: Then add your own check: if not self.breed: raise ValueError(...).
Use dataclasses.replace() to create an immutable update — produce a new Person where Alice has moved to Manchester, without modifying the original.
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Address:
street: str
city: str
country: str = "UK"
@dataclass(frozen=True)
class Person:
name: str
address: Address
p = Person(name="Alice", address=Address("10 Downing St", "London"))
print(p)
new_address = replace(p.address, city="Manchester")
p2 = replace(p, address=new_address)
print(p2)
print(p.address.city) # still LondonSolution
from dataclasses import dataclass, replace, asdict
@dataclass(frozen=True)
class Address:
street: str
city: str
country: str = "UK"
@dataclass(frozen=True)
class Person:
name: str
address: Address
p = Person(
name="Alice",
address=Address("10 Downing St", "London")
)
print(p)
# Immutable update pattern: replace returns a new instance
new_address = replace(p.address, city="Manchester")
p2 = replace(p, address=new_address)
print(p2)
print(p.address.city) # London — original unchanged
print(p2.address.city) # Manchester
# asdict() for serialisation
print(asdict(p2))
# Frozen objects are hashable — can be used in sets
people = {p, p2}
print(len(people)) # 2 — different hashes
Explanation: dataclasses.replace() is the functional-update pattern for immutable dataclasses. It creates a new instance with all fields copied from the source, then applies only the overrides you provide. For nested frozen dataclasses you must create the inner replacement first (new Address), then wrap it in the outer replacement (new Person). The original object is never mutated.
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Address:
street: str
city: str
country: str = "UK"
@dataclass(frozen=True)
class Person:
name: str
address: Address
p = Person(name="Alice", address=Address("10 Downing St", "London"))
print(p)
# TODO: create p2 where Alice moved to Manchester
# Use dataclasses.replace() — do not mutate p
p2 = None # replace with correct call
print(p2)
print(p.address.city) # still LondonExpected Output
Person(name='Alice', address=Address(street='10 Downing St', city='London', country='UK'))\nPerson(name='Alice', address=Address(street='10 Downing St', city='Manchester', country='UK'))\nLondonHints
Hint 1: You need a new Address with city="Manchester", then replace(p, address=new_address).
Hint 2: replace() creates a shallow copy with specified fields overridden.
Build a nested config system using dataclasses. Use asdict() to produce a plain-dict serialisation for JSON export, verifying that nested dataclasses are converted recursively.
from dataclasses import dataclass, field, asdict
from typing import List
@dataclass
class DatabaseConfig:
host: str
port: int
name: str
pool_size: int = 5
ssl: bool = False
@dataclass
class AppConfig:
app_name: str
version: str
databases: List[DatabaseConfig] = field(default_factory=list)
feature_flags: dict = field(default_factory=dict)
def add_database(self, db: DatabaseConfig) -> None:
self.databases.append(db)
def to_dict(self) -> dict:
return asdict(self)
cfg = AppConfig(app_name="EngineersOfAI", version="1.0.0")
cfg.add_database(DatabaseConfig("localhost", 5432, "prod_db", ssl=True))
cfg.feature_flags["dark_mode"] = True
result = cfg.to_dict()
print(result["app_name"])
print(result["databases"][0]["host"])
print(result["feature_flags"]["dark_mode"])Solution
import json
from dataclasses import dataclass, field, asdict, astuple
from typing import List, Optional
@dataclass
class DatabaseConfig:
host: str
port: int
name: str
pool_size: int = 5
ssl: bool = False
password: Optional[str] = field(default=None, repr=False)
@dataclass
class AppConfig:
app_name: str
version: str
databases: List[DatabaseConfig] = field(default_factory=list)
feature_flags: dict = field(default_factory=dict)
def add_database(self, db: DatabaseConfig) -> None:
self.databases.append(db)
def to_dict(self) -> dict:
# asdict() recursively converts nested dataclasses
return asdict(self)
def to_json(self) -> str:
return json.dumps(self.to_dict(), indent=2)
cfg = AppConfig(app_name="EngineersOfAI", version="1.0.0")
cfg.add_database(
DatabaseConfig("localhost", 5432, "prod_db", ssl=True)
)
cfg.add_database(
DatabaseConfig("replica.host", 5432, "prod_db_read")
)
cfg.feature_flags["dark_mode"] = True
cfg.feature_flags["beta_features"] = False
result = cfg.to_dict()
print(result["app_name"]) # EngineersOfAI
print(result["databases"][0]["host"]) # localhost
print(result["feature_flags"]["dark_mode"]) # True
# Nested dataclass converted to dict:
print(type(result["databases"][0])) # <class 'dict'>
# Full JSON export:
print(cfg.to_json())
Explanation: asdict() performs a deep conversion — nested dataclasses become nested dicts, lists of dataclasses become lists of dicts. This makes dataclasses a natural fit for configuration models that need to be serialised to JSON, YAML, or stored in databases. The repr=False on password keeps credentials out of logs, while asdict() still includes the field in the dict for actual serialisation when needed.
from dataclasses import dataclass, field, asdict, astuple
from typing import List
@dataclass
class DatabaseConfig:
host: str
port: int
name: str
pool_size: int = 5
ssl: bool = False
@dataclass
class AppConfig:
app_name: str
version: str
databases: List[DatabaseConfig] = field(default_factory=list)
feature_flags: dict = field(default_factory=dict)
def add_database(self, db: DatabaseConfig) -> None:
self.databases.append(db)
def to_dict(self) -> dict:
return asdict(self)
cfg = AppConfig(app_name="EngineersOfAI", version="1.0.0")
cfg.add_database(DatabaseConfig("localhost", 5432, "prod_db", ssl=True))
cfg.feature_flags["dark_mode"] = True
result = cfg.to_dict()
print(result["app_name"])
print(result["databases"][0]["host"])
print(result["feature_flags"]["dark_mode"])Expected Output
EngineersOfAI\nlocalhost\nTrueHints
Hint 1: asdict() recursively converts nested dataclasses to dicts.
Hint 2: The result["databases"][0] will be a plain dict, not a DatabaseConfig.
