PydanDict
Pydantic models. Mapping APIs. Validated changes.
Define a record once, use it through attributes or mapping-oriented code, and keep
its supported state valid as it changes. DictModel is a genuine Pydantic
BaseModel and a MutableMapping[str, object]—not a wrapper around a second
dictionary.
- One state:
record.ageandrecord["age"]read and write the same field. - Atomic updates: coupled fields validate together; rejected changes leave committed values and model metadata unchanged.
- Mapping-native reads: keys, live views,
get, unpacking anddict(record)work without calling serializers. - Protected descendants: supported nested containers and child models route mutations through the root validation boundary.
- Explicit lifecycle: reset defaults and make validated, independent model copies.
- Pydantic integration: retain supported fields, constraints, aliases, serializers, JSON Schema and FastAPI integration on the pinned stack.
Release status: 0.2.0 is the latest published release. This checkout prepares 0.3.0. The final release check reopened native strings-input rejection; its fix requires renewed production review and independent final approval, alongside successful final-candidate CI and artifact checks. No 0.3.0 tag or publication has occurred. See release readiness.
Install
Python 3.11–3.14; the sole direct runtime dependency is
pydantic==2.13.4. The package is alpha software, licensed under MIT.
Install the published package:
python -m pip install pydandict
To work with the prepared 0.3.0 checkout, run this from the repository root:
python -m pip install -e ".[dev]"
The examples below describe this checkout's supported contract. FastAPI and testing/build tools are development or optional integration dependencies, not requirements for ordinary library use.
Quick start
from collections.abc import Mapping, MutableMapping
from pydantic import BaseModel, Field, ValidationError
from pydandict import DictModel
class User(DictModel):
name: str
age: int = Field(ge=0)
user = User(name="Eddie", age=40)
assert user.name == user["name"] == "Eddie"
user["age"] = 41
assert user.age == 41
try:
user.age = -1
except ValidationError:
assert user.age == 41
else:
raise AssertionError("invalid age was accepted")
assert isinstance(user, BaseModel)
assert isinstance(user, Mapping)
assert isinstance(user, MutableMapping)
assert list(user) == ["name", "age"]
assert dict(user) == {"name": "Eddie", "age": 41}
Pydantic supplies schemas and validation. PydanDict adds canonical key iteration and an isolated transaction boundary around supported writes.
Use existing mapping-oriented code
No conversion or PydanDict-specific branch is needed for a consumer that accepts
a Mapping:
from collections.abc import Mapping
def describe(record: Mapping[str, object]) -> str:
return ", ".join(f"{key}={value}" for key, value in record.items())
assert describe(user) == "name=Eddie, age=41"
assert user.get("missing", "fallback") == "fallback"
assert {**user} == {"name": "Eddie", "age": 41}
Keys are canonical field names in declaration order, followed by allowed extras in insertion order. Defaults and serialization-excluded fields remain mapping entries. Views are live; unfinished key iterators are invalidated by committed key/order changes, not value-only updates.
dict(record) is a shallow mapping copy, not serialization or a detached
nested payload. Use model_dump or model_dump_json for Pydantic serialization.
Change related fields atomically
A valid final state may require changing more than one field. update validates
the merged candidate as a whole instead of assigning fields one at a time.
from typing import Self
from pydantic import Field, ValidationError, model_validator
from pydandict import DictModel
class Bounds(DictModel):
low: int = Field(default=1, ge=0)
high: int = Field(default=3, ge=0)
@model_validator(mode="after")
def ordered(self) -> Self:
if self.low > self.high:
raise ValueError("low must not exceed high")
return self
bounds = Bounds()
bounds.update(low=5, high=8) # A single low=5 write would fail against high=3.
assert dict(bounds) == {"low": 5, "high": 8}
before = dict(bounds)
try:
bounds.update(low=9, high=4)
except ValidationError:
assert dict(bounds) == before
else:
raise AssertionError("invalid batch was accepted")
update accepts mappings, pairs and keyword arguments. Later duplicates win;
keywords take precedence over positional input. Inputs are fully consumed before
commit, so malformed pairs or a late iterable exception cannot partially apply a
batch. record |= changes uses the same transaction and retains model identity.
Validators must be safe to rerun on canonical state. Whole-root revalidation can repeat callbacks; external side effects are not rolled back. See validator and transaction semantics.
Defaults, extras and copies
clone = bounds.model_copy(update={"high": 10})
assert clone is not bounds
assert clone.high == 10 and bounds.high == 8
bounds.reset("low", "high")
assert dict(bounds) == {"low": 1, "high": 3}
assert bounds.model_fields_set == set()
Declared fields cannot be deleted—even when optional or defaulted. Use reset
to restore selected defaults. Required fields have no reset value.
Unknown input is forbidden by default. Enable ConfigDict(extra="allow") to
store extras; extras can be inserted and removed through mapping operations.
Unknown writes under extra="ignore" still fail rather than disappear silently.
| Operation | Behavior |
|---|---|
record[key] = value / record.field = value |
Validate and commit one candidate state |
record.update(...) / record |= changes |
Apply a batch atomically |
record.setdefault(key, default) |
Return the existing value without validating an unused default, or validate insertion |
record.pop(key[, default]) / del record[key] |
Remove an allowed extra; declared fields are protected |
record.popitem() |
Attempt the last key; do not skip a protected declared field |
record.clear() |
Remove extras atomically on a fieldless model; declared fields prevent clearing |
record.reset(*names) |
Reevaluate selected defaults atomically; no names means no-op |
record.model_copy(update=...) |
Return a new validated model; leave the source unchanged |
For a complete defaults/extras/metadata workflow, run
examples/library_config.py.
Keep supported nested values guarded
Use mutable ABC annotations, not concrete list/dict/set annotations.
Ordinary built-in inputs are still accepted; stored values expose guarded ABC
interfaces.
from collections.abc import MutableSequence
from typing import Self
from pydantic import ValidationError, model_validator
from pydandict import DictModel
class Cart(DictModel):
budget: int
costs: MutableSequence[int]
@model_validator(mode="after")
def within_budget(self) -> Self:
if sum(self.costs) > self.budget:
raise ValueError("costs exceed budget")
return self
source = [2, 3]
cart = Cart(budget=10, costs=source)
source.append(100) # Caller input is detached from stored state.
assert list(cart.costs) == [2, 3]
cart.costs.append(4)
try:
cart.costs.append(2) # Valid integer, invalid parent state.
except ValidationError:
assert list(cart.costs) == [2, 3, 4]
else:
raise AssertionError("parent constraint was bypassed")
MutableMapping, MutableSet and nested DictModel fields follow the existing
closed ownership envelope. Replacing/removing an owned node can make previously
borrowed handles stale. This is not arbitrary mutable-object support or thread
safety; see nested values and ownership.
Separate mapping state from serialized output
Aliases belong to validation/serialization boundaries, not alternate mapping keys. Serialization exclusion is not access control for mapping readers.
from pydantic import Field
from pydandict import DictModel
class Account(DictModel):
user_id: int = Field(alias="userId")
token: str = Field(exclude=True)
account = Account(userId=7, token="private")
assert account["user_id"] == 7 and "userId" not in account
assert "token" in account
assert account.model_dump(by_alias=True) == {"userId": 7}
Standard supported serializers, filters and serialization context remain Pydantic's responsibility. A custom serializer may produce a non-dictionary payload without changing mapping membership.
FastAPI integration
With the optional pinned fastapi==0.141.1 integration dependency installed,
use ordinary model annotations. This example uses User from the quick start:
from fastapi import FastAPI
app = FastAPI()
@app.post("/users", response_model=User)
def create_user(user: User) -> User:
user.update(age=user.age + 1)
return user
Request validation, response serialization and OpenAPI are covered by the pinned integration tests. No special encoder or framework plugin is required for the supported paths. See compatibility.
Deliberate differences and support limits
| Compared with | Important difference |
|---|---|
dict |
A model is a mutable mapping, not a built-in dictionary; model equality does not become dict equality |
dict |
Keys must be strings; declared fields cannot disappear; no binary |, fromkeys or dict-style copy API |
BaseModel |
Iteration yields canonical keys, not key/value pairs |
BaseModel |
model_copy(update=...) validates updates and detaches supported mutable descendants even with deep=False |
BaseModel |
Trusted model_construct/deprecated construct and pickle are disabled |
| Plain frozen models | Frozen ancestors/fields also protect supported descendant writes |
Qualified scalar leaves are exact None, bool, int, float, str, bytes,
Decimal, date, datetime, time, timedelta and UUID types. Temporal
timezone values must be absent or exact datetime.timezone instances.
Supported nullable/unions, Literal, Annotated constraints and explicitly
specialized generics stay within the closed envelope. Any, object and extras
do not bypass input/output safety checks.
Arbitrary objects, ordinary BaseModel values stored inside a DictModel,
enums, custom scalar subclasses/timezones and concrete mutable field annotations
are unsupported. Ordinary BaseModel envelopes containing a DictModel are a
separate supported integration. Custom initialization/finalizers,
model_post_init, private attributes and writable properties are also outside
the supported hook contract.
Attribute types retain their declared precision. Generic mapping values are
object and require narrowing; automatic per-key type inference is not promised.
See typing. There is no async API, persistence feature or shared
writer/thread-safety guarantee. Validation uses trusted schemas; deliberate
reflection/base-method bypass is not a security sandbox.
Migration
From 0.1.0, replace mutable annotations recursively:
| Previous annotation | Supported annotation |
|---|---|
list[T] |
collections.abc.MutableSequence[T] |
dict[K, V] |
collections.abc.MutableMapping[K, V] |
set[T] |
collections.abc.MutableSet[T] |
Specialize generic models explicitly. The outer
__pydantic_extra__: dict[str, V] metadata declaration remains valid.
See the 0.2 migration contract.
For 0.3.0, non-string pop keys raise TypeError even with a fallback. Use string
mutation keys, or get when a read-only fallback is intended. Native JSON/strings
and supported embedded ingress retain their documented validation boundaries.
No persisted-data migration is required; see the changelog.
Documentation and development
- API reference and mutation semantics
- Nested ownership, compatibility and typing
- Phase 0.3 contract, passed review and release preparation
- Documentation index, roadmap and contributing
After installing .[dev], run from the repository root:
PYTHONPATH=src PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q
python tools/check_typing.py
PYTHONPATH=src python -m pyright --verifytypes pydandict --ignoreexternal
ruff check src tests tools/check_typing.py tools/qualify_package.py tools/benchmark.py
ruff format --check src tests tools/check_typing.py tools/qualify_package.py tools/benchmark.py
python tools/check_docs.py
Artifact qualification is a separate clean, committed-source check:
python tools/qualify_package.py. Follow the release checklist
before tagging; historical evidence does not automatically qualify a later commit.
Use the private security reporting route for vulnerabilities. The package is MIT licensed.
Metadata
Release files for pydandict 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pydandict-0.3.0.tar.gz | 69.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pydandict-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 101.3 kB
Release files / pydandict-0.3.0.tar.gz
| Download URL | pydandict-0.3.0.tar.gz |
|---|---|
| Size | 69.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9bf9ad9352edf0189f4440a6e25824fefc74989816b7b126289c292dce32d78b
|
|
BLAKE2b-256 checksum How to use checksums |
ae40b48a280e126dae1ccdb4d70a878da1e85c9e7dcf5dfae042a9478ae950cc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency logRelease files / pydandict-0.3.0-py3-none-any.whl
| Download URL | pydandict-0.3.0-py3-none-any.whl |
|---|---|
| Size | 31.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3efc44c005f39ef16713d1c5020f7f55e7bbb6544ea3ac43372b7877478497dc
|
|
BLAKE2b-256 checksum How to use checksums |
7c5f2793b8776f3a5c9b9df4a66d8133c4bbc8c602d74de01ce64f90d4e6ed5a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency log