A dict subclass with optional typed fields, validators, computed values, and deep nested ops
Project description
modict
modict (short for modern dict or model dict) is a Python dict subclass with an optional model-like layer: typed fields, defaults, factories, validators, computed values, and a full set of deep nested operations.
It stays a real dict throughout — every standard dict method works, and modict instances are accepted everywhere a dict or Mapping is expected, without conversion.
Why not just use…
dict — Great for free-form data, but no types, no validation, no computed fields, no deep ops. You end up reimplementing the same helpers everywhere.
dataclass — Clean syntax for typed containers, but not a dict: you need dataclasses.asdict() at every boundary, no runtime type checking, no coercion, no computed fields with cache invalidation, no extra keys, no nested ops.
TypedDict — A static typing annotation, not a runtime object. No behavior, no defaults, no validators. Only useful for type checkers.
Pydantic BaseModel — Excellent for strict data contracts and API modeling, but model-first: not a dict subclass, requires explicit .model_dump() conversion at boundaries, and is designed around validation-as-contract rather than mutable data manipulation.
attrs — Similar expressiveness to dataclass for modeling, but again not a dict. Adds descriptors and slots but no nested ops, no JSONPath, no coercion pipeline.
modict occupies a different position: it's a dict that can be progressively enriched. Start with raw data, add structure as it stabilizes, keep full dict compatibility throughout. No conversion, no boundaries, no paradigm switch.
When to use modict
- Config and settings: typed defaults, computed derived values, merge/diff/patch between configs.
- JSON/API payloads: parse directly with
modict.loads(), navigate with JSONPath, validate selectively — no schema required upfront. - ETL and data pipelines: transform nested structures with
walk/unwalk/merge, track changes withdiff/diffed. - Typed events and messages: a
modictsubclass withextra="forbid"andrequired=Truefields behaves like aTypedDictwith runtime enforcement — and is still a plain dict you can pass directly to any serializer or bus. - Prototyping: start free-form, progressively add hints/validators/computed as your data shape stabilizes.
Contents
- Installation
- Examples
- Quick Start
- Core Concepts
- Field Definition
- Factories
- Validators
- Computed Fields
- Validation Pipeline
- Path-Based Tools
- Configuration (Deep Dive)
- Type Checking & Coercion
- Serialization
- Deep Conversion & Deep Ops
- Payload vs Runtime
- Package Tour (Internal Modules)
- Public API Reference
- Development
- Contributing
- License
Installation
pip install modict
Requirements:
- Python 3.10+
- JSONPath support relies on
jsonpath-ng(the only external dependency)
Examples
See examples/README.md for a small set of practical, end-to-end scripts covering:
- typed webhook payloads
- config rollouts and patching
- adapting SDK / ORM objects with
from_attributes - redaction / export flows with
Query,Path,walk, andunwalk
Quick Start
Use it as a dict (default)
from modict import modict
m = modict({"user": {"name": "Alice"}, "count": 1})
assert m["count"] == 1
assert m.user.name == "Alice" # attribute access for keys
m.extra = 123 # extra keys are allowed by default
assert isinstance(m, dict) # True: modict is a real dict
Define a typed modict class with defaults
from modict import modict
class User(modict):
name: str # annotation-only: not required, but validated/coerced if provided
age: int = 25 # default value
country: str = "FR" # another default (not passed at init)
u = User({"name": "Alice", "age": "30"})
assert u.age == 30 # best-effort coercion unless strict=True
assert u.country == "FR"
Tip: Annotated fields without defaults are not required by default; they provide type validation/coercion when the key is present. Plain annotations and class defaults are enough for most fields. Use modict.field(...) when you need more control (required flag, explicit hint).
repr(modict) shows the live user-facing view of the structure. Computed fields are evaluated for display and rendered as Computed(current_value) so they stay identifiable in console output.
Core Concepts
modictis a realdict: it supports standard dict operations and behaves like a mutable mapping.- A
modictclass can declare fields with type annotations andmodict.field(...). - The model-like behavior is controlled by the
_configclass attribute (amodictConfigdataclass):
from modict import modict
class User(modict):
_config = modict.config(extra="allow") # see "Configuration (Deep Dive)" for full reference
Field Definition
For most fields, the recommended style is still the simple direct one:
from modict import modict
class User(modict):
name: str
age: int = 25
Use modict.field(...) when you need more explicit control over a field
definition. It is the more expert/advanced form, not the default style:
from modict import MISSING, modict
modict.field(
default=MISSING, # default value (MISSING means "no default value")
hint=None, # type hint, None = use class annotation when provided
required=False, # whether the field is required
validators=None, # internal: used by the metaclass when collecting @modict.validator(...)
)
default=MISSING is already the default behavior, so you usually do not need to
write it explicitly.
Example (explicit defaults):
from modict import modict
class User(modict):
name: str = modict.field(required=True)
age = modict.field(default=25, hint=int)
u = User({"name": "Alice", "age": "30"})
assert u.age == 30
Note: if you pass hint=... explicitly in modict.field(...), it takes precedence over the class annotation.
Factories
Use modict.factory(callable) to define dynamic defaults (a fresh value per instance), similar to Pydantic's default_factory. This is essential for mutable defaults like lists or dicts — without it, all instances would share the same object.
from modict import modict
class User(modict):
name: str
tags: list[str] = modict.factory(list) # new list per instance
u1 = User(name="Alice")
u2 = User(name="Bob")
u1.tags.append("python")
assert u2.tags == []
Validators
Field validators
Use @modict.validator(field_name, mode="before"|"after") to validate and/or transform a single field value.
mode="before"(default): runs before coercion and type checking — receives the raw input value. This is the right place for type-changing transforms.mode="after": runs after coercion and type checking — receives the already-typed value. Should return a value of the same type (or raise). The type is not re-checked after this step, so returning a different type silently bypasses the hint.
from modict import modict
class User(modict):
email: str
@modict.validator("email")
def normalize_email(self, value):
return value.strip().lower()
u = User(email=" ALICE@EXAMPLE.COM ")
assert u.email == "alice@example.com"
Model validators (cross-field invariants)
Use @modict.model_validator(mode="before"|"after") for checks that span multiple fields.
In mode="after", the instance is already fully populated and all field validators have run. The method receives (self, values) where values is the final dict of field values.
from modict import modict
class Range(modict):
start: int
end: int
@modict.model_validator(mode="after")
def check_order(self, values):
if values["start"] > values["end"]:
raise ValueError("start must be <= end")
Computed Fields
Computed fields are virtual values evaluated on access. They are stored as Computed objects inside the dict and evaluated via __getitem__.
When a computed is declared on the class, it becomes part of the model contract just like any other declared field:
- it is collected into
__fields__ - it participates in key-level model semantics
- its return value is checked against the field hint (class annotation first, callable return annotation second)
from modict import modict
class Calc(modict):
a: int
b: int
@modict.computed(cache=True, deps=["a", "b"])
def sum(self) -> int:
return self.a + self.b
c = Calc(a=1, b=2)
assert c.sum == 3
c.a = 10
assert c.sum == 12 # cache invalidated because "a" changed
The modict.computed API is quite flexible and supports inline definitions as well:
Inline (non-decorator) form:
from modict import modict
class Calc(modict):
a: int
b: int
sum:int = modict.computed(lambda m: m.a + m.b, cache=True, deps=["a", "b"])
By contrast, a computed attached dynamically on an instance is just a dynamic value stored under that key. It does not create a new model field or change the class-level contract:
Inline "dict value" form (no subclass):
from modict import modict
m = modict({"a": 1, "b": 2})
m["sum"] = modict.computed(lambda m: m.a + m.b)
assert m.sum == 3
Notes:
- Computed values still go through the validation pipeline (type checks, JSON serializability check, …) when enabled.
- Dynamic instance-level computeds only benefit from field-level type hints if the key was already declared on the class. Otherwise they remain plain dynamic values.
- Cache invalidation semantics:
deps=None(default): invalidate on any key change.deps=[...]: invalidate only when one of the listed keys changes (can include other computed names).deps=[]: never invalidate automatically.
Manual invalidation:
from modict import modict
m = modict({"a": 1, "b": 2})
m["sum"] = modict.computed(lambda m: m.a + m.b, cache=True, deps=[])
_ = m.sum # cached
m.a = 10 # deps=[] → no auto-invalidation
assert m.sum == 3
m.invalidate_computed("sum")
assert m.sum == 12
# or invalidate all at once:
m.invalidate_computed()
Validation Pipeline
The pipeline is controlled by _config.check_values:
check_values="auto"(default): enabled when the class looks model-like (has hints, validators, or relevant config).check_values=True: always enabled.check_values=False: bypassed (pure dict behavior).
Related config flags:
strict: whenTrue, no coercion is attempted — values must already match the declared type.validate_assignment: whenTrue(default), every assignment goes through the full pipeline (coercion, type check, validators). Set toFalseto only run validation at init time.enforce_json: whenTrue, values must be JSON-serializable after processing.
When enabled, the pipeline runs:
- eagerly at initialization (
__init__→validate()) - on each assignment when
validate_assignment=True - on reads of computed fields (
__getitem__evaluates and then validates the returned value)
Order of operations for a field value:
- field validators in
mode="before"(receive raw input) - coercion (only when
strict=False) - type check against hint (if the field has a type annotation)
- field validators in
mode="after"(receive coerced, typed value) - JSON-serializability check (
enforce_json=True, with optional encoders)
If any step raises, the whole assignment is rejected.
Path-Based Tools
modict comes with a consistent path system for reading/writing nested structures (including inside lists), plus a Path object for disambiguation and introspection.
Supported path formats
You can target nested values using:
- JSONPath strings (RFC 9535):
$.users[0].name - Tuples of keys/indices:
("users", 0, "name") Pathobjects:Path("$.users[0].name")
Path(...) also accepts relative dotted strings such as users[0].name and
normalizes them back to the absolute JSONPath form in str(path).
The Path object
Path is a parsed, strongly-typed representation of a nested path.
from modict import Path
p = Path("$.users[0].name")
p = Path("users[0].name")
assert tuple(p) == ("users", 0, "name")
assert str(p) == "$.users[0].name" # __str__ renders back to JSONPath
assert repr(p) == "Path($.users[0].name)"
assert p.starts_with(("users", 0))
assert p.relative_to(("users",)) == Path((0, "name"))
bound = p.with_root({"users": [{"name": "Alice"}]})
assert bound.resolve() == "Alice"
Internally, a Path is a tuple of PathNode components. Each node carries:
- the key/index (
"users",0,"name") - an optional reference to the origin container instance when it can be inferred, which lets helpers distinguish
MappingvsSequencestructure during reconstruction.
All nested helpers accept JSONPath strings, tuples, or Path objects interchangeably.
Nested operations
from modict import modict
m = modict({"user": {"name": "Alice"}})
m.get_nested("$.user.name") # "Alice"
m.set_nested("$.user.age", 30)
m.has_nested("$.user.age") # True
m.pop_nested("$.user.age") # 30
m.del_nested("$.user.name") # removes the key
m.set_nested(
"$.prefs.theme",
"dark",
create_missing=True,
container_factory=lambda path: {},
)
Paths in deep traversal
walk()yields(Path, value)leaf pairs.walked()returns a{Path: value}mapping.unwalk(walked)reconstructs a nested structure from a{Path: value}mapping using plaindict/listcontainers.unwalk(..., kind_resolver=...)can refine the inferred structure per container path.ignore_types=Trueremains available as a legacy mode to ignorePathhints and use only local key-shape heuristics.modict.unwalk(...)then retypes the root mapping through the target class, so model validation/coercion can re-establish the desired root type.
Configuration (Deep Dive)
All model-like behavior is controlled by the class attribute _config, a modictConfig dataclass created via modict.config(...).
Only modict-supported options are accepted — unknown keys raise TypeError.
from modict import modict
class User(modict):
_config = modict.config(
check_values="auto",
extra="allow",
strict=False,
validate_assignment=True, # default
auto_convert=True,
)
Config reference
check_values:True/False/"auto"."auto"enables the pipeline when the class looks model-like: it has hints, validators, model validators, or config constraints (e.g.extra != "allow",enforce_json=True,strict=True, …).
check_keys:True/False/"auto".- Key-level constraints are structural checks (presence/allowed-keys/invariants), separate from value validation.
"auto"enables key constraints when the model declares them (e.g.extra != "allow",require_all=True, computed fields, or any field withrequired=True).- When
False,modictbehaves more like a plain dict regarding keys:required=True,require_all=True,extra="forbid"/"ignore", and computed overwrite/delete protection are all skipped. frozen=Trueis always enforced regardless ofcheck_keys.
Example: keep structure strict but skip value coercion/type checking:
from modict import modict
class Msg(modict):
_config = modict.config(check_values=False, check_keys=True, extra="forbid")
role: str = modict.field(required=True)
content: str = modict.field(required=True)
extra:"allow"(default) /"forbid"/"ignore"."forbid"raises on unknown keys at init and on assignment."ignore"drops unknown keys silently.
strict: whenTrue, disables coercion (type checking still applies when hints exist).validate_assignment: whenTrue(default), every assignment re-runs the full pipeline. Set toFalseto only validate at init.frozen: whenTrue,__setitem__/__delitem__raise — effectively read-only instances.auto_convert: whenTrue(default), values stored in nested mutable containers are lazily upgraded on access:- nested plain
dict→modict(plainmodict, not your subclass) - applies recursively inside lists, tuples, sets, and dicts as you touch them.
- nested plain
enforce_json: whenTrue, values must be JSON-serializable after the pipeline runs.allow_inf_nan: controls whetherNaN/Infinitypass the JSON check (default:True).json_encoders: a{type: callable}mapping used as fallback encoders bydumps()/dump().
validate_default: whenTrue, default field values are type-checked at class creation time (skipsFactory/Computed).from_attributes: whenTrue,MyModict(obj)can read declared fields fromobj.fieldattributes (whenobjis not a mapping).override_computed: whenFalse(default), computed fields are protected at runtime: you cannot overwrite or delete them on an existing instance. During model construction/casting, class-declared computed fields still win over incoming values so the target model contract is preserved.require_all: whenTrue, all declared class fields must be present at initialization; annotation-only fields become required and cannot be deleted.evaluate_computed: whenTrue(default), computed fields are evaluated on access. WhenFalse, theComputedobject itself is returned (pure storage mode, no evaluation).
Required vs defaults (dict-first semantics):
- A class default (e.g.
age: int = 25) is an initializer: injected once at construction if missing, but still removable later whenrequire_all=False. - A field is an invariant only when you opt in: set
required=Trueon the field (orrequire_all=Trueon the model) to enforce presence.
Performance / dict-like mode
If you want modict to behave as close as possible to a plain dict (minimal overhead), opt out of most advanced features:
from modict import modict
class Fast(modict):
_config = modict.config(
check_values=False, # skip validation/coercion pipeline
check_keys=False, # skip structural key constraints (required/extra/...)
auto_convert=False, # skip lazy conversion of nested containers on access
evaluate_computed=False # treat Computed as raw stored objects
)
Config inheritance / merging
Config values are merged across the MRO. The key rule: only values explicitly passed to modict.config(...) participate in the merge — default values never silently override a parent's choice.
from modict import modict
class Base(modict):
_config = modict.config(extra="forbid", strict=True)
class Child(Base):
# No _config — inherits Base's config as-is.
# effective: extra="forbid", strict=True
class Override(Child):
_config = modict.config(extra="allow")
# Only `extra` was explicitly set here, so only `extra` overrides.
# effective: extra="allow", strict=True (strict inherited from Base)
With multiple inheritance, the left-most base wins for any conflicting explicitly-set key:
from modict import modict
class A(modict):
_config = modict.config(strict=True)
class B(modict):
_config = modict.config(strict=False, extra="forbid")
class C(A, B):
pass
# strict → True (A wins over B, A is left-most)
# extra → "forbid" (only B set it, no conflict)
Type Checking & Coercion
modict relies on its internal runtime type system (modict/typechecker/) for:
- type checking against annotations (
check_type(hint, value)) - best-effort coercion (
coerce(value, hint)) whenstrict=False - the
@typecheckeddecorator for runtime checking of function arguments/return values
This subsystem supports common typing constructs (Union, Optional, list[str], dict[str, int], tuple[T, ...], ABCs from collections.abc, …).
When coercion fails, the original value is kept; the subsequent type check then decides whether it's accepted (based on hints and strict mode). This means strict=False is permissive but not silent — type mismatches still raise if the hint doesn't match.
Serialization
modict uses a JSON-like API backed by json.dumps/json.dump/json.loads/json.load.
dumps / dump (output)
Serialize a modict instance to a JSON string or file. Both methods accept:
exclude_none: drop keys withNonevaluesencoders:{type: callable}mapping for custom serialization (overridesjson_encodersfrom config)
from datetime import datetime
from modict import modict
class Event(modict):
_config = modict.config(json_encoders={datetime: lambda d: d.isoformat()})
name: str
ts: datetime
e = Event(name="launch", ts=datetime(2024, 1, 1))
print(e.dumps())
# {"name": "launch", "ts": "2024-01-01T00:00:00"}
json_encoders in _config serves as the default encoder table for all dumps()/dump() calls on that class. You can override it per-call by passing encoders=... directly.
from datetime import datetime
from modict import modict
class Event(modict):
_config = modict.config(json_encoders={datetime: lambda d: d.isoformat()})
name: str
ts: datetime
e = Event(name="launch", ts=datetime(2024, 1, 1))
e.dumps(encoders={datetime: lambda d: d.timestamp()})
# {"name": "launch", "ts": 1704067200.0}
dump() writes to a file path or file-like object:
from datetime import datetime
from modict import modict
class Event(modict):
_config = modict.config(json_encoders={datetime: lambda d: d.isoformat()})
name: str
ts: datetime
e = Event(name="launch", ts=datetime(2024, 1, 1))
e.dump("event.json")
loads / load (input)
Class-level methods that parse JSON and return a modict instance:
from modict import modict
m = modict.loads('{"name": "launch", "ts": "2024-01-01T00:00:00"}')
m = modict.load("event.json")
These are thin wrappers around json.loads/json.load — no custom deserialization logic is applied. For typed deserialization with coercion, construct from the parsed dict directly:
from datetime import datetime
from modict import modict
class Event(modict):
name: str
ts: datetime
data = modict.loads('{"name": "launch", "ts": "2024-01-01T00:00:00"}')
event = Event(**data) # pipeline runs: coercion, type checks, validators, ...
to_jsonable
For serialization beyond JSON (e.g. YAML, MessagePack), to_jsonable(obj, encoders) from modict.collections_utils recursively converts a structure to plain JSON-safe types (dicts, lists, primitives). This is what dumps/dump use internally.
Deep Conversion & Deep Ops
Deep conversion
modict ships with conversion utilities that preserve container identity as much as possible:
modict.convert(obj): recursively upgradesdictnodes tomodict(and walks into mutable containers).- The root dict becomes your class; nested dicts become plain
modictunless they were already instances. recurse=Falsestops recursion when reaching amodictnode (used internally for lazyauto_convert).
- The root dict becomes your class; nested dicts become plain
m.to_modict(): deep conversion of an instance in-place (callsconvert(self)).m.to_dict(): deep un-conversion back to plain containers.
Deep operations on nested structures
walk()/walked(): flatten a nested structure to(Path, value)pairs.unwalk(walked, *, kind_resolver=None): reconstruct a nested structure from a{Path: value}mapping using structuraldict/listcontainers, with an optional hook to refine inferredmapping/sequencekinds per path. The root can then be recast throughmodict.unwalk(...).merge(mapping): deep, in-place merge (mappings merge by key; sequences merge by index). ReturnsNone— modifies in place.diff(mapping): deep diff — returns{Path: (left, right)}withMISSINGfor absent values.diffed(mapping): minimal nested patch — returns a plain modict containing only the changes needed so thatself.merge(self.diffed(other))equalsother. Keys removed inotherare set toMISSING.deep_equals(mapping): deep equality by comparing walked representations (container types are ignored — a modict and a plain dict with the same leaves are equal). Use this when cross-type equality is needed;==uses the native dict comparison (type-sensitive).
Payload vs Runtime
This is an advanced pattern.
One of modict's more flexible use cases is building objects that are:
- real dict payloads for serialization, diffing, patching, and transport
- real Python objects with methods, runtime metadata, and business behavior
The important distinction is:
- mapping keys are the payload
- attrs are runtime/business context
That separation is what keeps the object plastic without making the serialized representation fuzzy.
Why this exists
In many codebases, the same conceptual object gets split into several layers:
- a DTO / payload dict for transport
- a model object for validation
- a runtime object carrying services, registries, caches, renderers, parent refs, etc.
modict can collapse a lot of that back into one object:
- the payload still lives directly in the dict
- the behavior lives on the class as normal methods
- the runtime context lives in attrs outside the dict
That means the same object can be:
- dumped to JSON
- diffed and patched deeply
- passed anywhere a
dictorMappingis expected - still used as a domain object with methods like
render(),mount(),resolve_theme(),dispatch(), ...
When to use attr(...)
Use modict.attr(...) when a value should stay an attribute instead of becoming
a field or a payload key.
Typical cases:
- class metadata such as
source_system,component_kind,schema_version - instance metadata such as
trace_id,request_context,parent,dom_ref - business/runtime objects that must not leak into JSON output
from modict import modict
class Component(modict):
kind = modict.attr("button")
label: str
component = Component(label="Save")
component.set_attr("trace_id", "req_123")
assert component.kind == "button"
assert component.trace_id == "req_123"
assert "kind" not in component
assert "trace_id" not in component
Rule of thumb:
- if it should serialize, diff, merge, or travel over the wire: put it in the dict
- if it is runtime-only context or behavior support: keep it as an attr
When to use wrap(...)
Use wrap(...) when you need extra constructor-time business parameters but do
not want to break the native dict constructor semantics.
This is important because modict intentionally preserves the predictability of:
MyModict(data)MyModict(**payload)
So instead of inventing a custom __init__(data, registry, renderer, ...),
you keep the dict constructor clean and opt into an explicit wrapped
construction path:
from modict import modict
class Component(modict):
name: str
@classmethod
def __wrap_init__(cls, init, *, registry, renderer):
def wrapped(*dict_args, **dict_kwargs):
obj = init(*dict_args, **dict_kwargs)
obj.set_attr("registry", registry)
obj.set_attr("renderer", renderer)
return obj
return wrapped
component = Component.wrap(registry=my_registry, renderer=my_renderer)(
name="hero"
)
This is there for two reasons:
Component(data)andComponent(**payload)stay predictable and fully dict-like- wrapped construction gets full control around instantiation without polluting the payload with business-only parameters
This gives you full control around instantiation:
- pre-processing incoming dict args before native construction
- post-processing the fully validated/coerced object after construction
- composition through inheritance via multiple
__wrap_init__layers
When inheritance is involved, parameter routing stays explicit. There is only
one wrap-time parameter space, so each __wrap_init__ consumes what it needs
and forwards the rest deliberately:
from modict import modict
class BaseComponent(modict):
name: str
@classmethod
def __wrap_init__(cls, init, *, registry):
def wrapped(*dict_args, **dict_kwargs):
obj = init(*dict_args, **dict_kwargs)
obj.set_attr("registry", registry)
return obj
return wrapped
class Button(BaseComponent):
label: str
@classmethod
def __wrap_init__(cls, init, *, registry, renderer):
# Route `registry` to the parent wrapper yourself.
init = BaseComponent.__wrap_init__(init, registry=registry)
def wrapped(*dict_args, **dict_kwargs):
obj = init(*dict_args, **dict_kwargs)
obj.set_attr("renderer", renderer)
return obj
return wrapped
button = Button.wrap(registry=my_registry, renderer=my_renderer)(
name="save-button",
label="Save",
)
That explicit routing is the point: modict keeps the dict constructor clean,
and wrap(...) gives you full control instead of inventing an implicit second
constructor protocol.
Good use cases
- UI/component trees: serializable props/state/children in the dict, runtime
refs/renderer/registry in attrs, methods like
mount()andrender()on the class - SDK/ORM adapters: transport-ready payload in the dict, source handles/session/context in attrs
- workflow/job objects: diffable state in the dict, runtime executor/logger/trace context in attrs
- event/message envelopes: wire payload in the dict, dispatch helpers and runtime routing context outside it
See examples/ui_component_tree.py for a full end-to-end example of this pattern.
Package Tour (Internal Modules)
This section is an overview of the main internal modules and what functionality they implement. If you only need the user-facing API, skip to Public API Reference.
Several submodules are intentionally usable almost like small standalone
packages. When you want the deeper, module-specific API rather than the
high-level modict overview, jump directly to:
- modict/path_utils/README.md for
Pathand path parsing/resolution - modict/collections_utils/README.md for nested ops, deep traversal, diff/merge, and
Query - modict/typechecker/README.md for runtime typing, coercion, and function decorators
This main README stays focused on the unified user-facing story; the module READMEs are the deeper reference points once you start using those pieces more directly.
modict/core/ (the modict class)
This is the glue layer that turns the subpackages into one coherent dict-first model object.
Core behaviors implemented here:
- dict subclass with attribute access (
m.key↔m["key"]) while keeping Python attributes working - validation pipeline (
validate(), assignment validation, extra handling) - computed fields evaluation and dependency-based cache invalidation
- lazy nested conversion (
auto_convert) implemented in__getitem__ - nested ops (
get_nested,set_nested,pop_nested, …) backed by JSONPath/Path - deep ops (
walk,walked,unwalk,merge,update,diff,diffed,deep_equals,deepcopy) - JSON helpers (
loads,load,dumps,dump) modictMeta: collects declared fields from__annotations__and assigned attributes- supports plain defaults,
modict.field(...)(Field),modict.factory(...)(Factory), and@modict.computed(...)(Computed) - collects
@modict.validator(...)into per-field validators - collects
@modict.model_validator(...)into model-level validators
- supports plain defaults,
modictConfig: configuration object with explicit-key tracking and inheritance merge semanticsmodictKeysView/modictValuesView/modictItemsView: dict views that read through__getitem__(so computed fields, lazy conversion, and validation all apply during iteration)
modict/collections_utils/ (nested structure utilities)
This package is responsible for paths, nested operations, and deep traversal. For the full module-level API, see modict/collections_utils/README.md. Path parsing and representation details live separately in modict/path_utils/README.md.
_path.py:Path/PathNode— JSONPath (RFC 9535) parsing and formatting viajsonpath-ng.- Path components can cache origin container references so
walk()→unwalk()can distinguishMappingvsSequencestructure without recreating arbitrary concrete container classes. Path(...)accepts JSONPath strings, tuples/lists of keys, or anotherPath.
- Path components can cache origin container references so
_basic.py: container-agnosticget_key/set_key/has_key/keys/unroll._advanced.py:get_nested/set_nested/pop_nested/del_nested/has_nested,walk/walked/unwalk,deep_merge/diff_nested/deep_equals._view.py:View— base class for custom collection views over mappings or sequences._missing.py:MISSINGsentinel to distinguish "absent" fromNone.
modict/typechecker/ (runtime typing + coercion)
For the full module-level API, see modict/typechecker/README.md.
TypeChecker: checks values againsttypinghints and collection ABCs.Coercer: best-effort conversions for common hints/containers.- Convenience API:
check_type,coerce,can_coerce, andtypechecked,coercedfor decorator-based type checking and coercion on functions.
modict/model_api/ (field system)
This is the internal field/validator/computed layer used by modict during
class creation and validation. Most users should stay on the class-level
helpers (modict.field, modict.factory, @modict.validator,
@modict.computed) rather than importing this module directly.
Field,Factory,Computed,Attribute: field descriptor types used bymodictMetaduring class creation.Validator,ModelValidator: signature adapters for common call styles (allow flexible validator signatures).build_fields_and_model_validators: metaclass helper that collects fields and validators from a class dict.
Public API Reference
This section lists the main convenience symbols exported by modict and the
main methods on modict instances.
Exports
For most code, the normal import is just:
from modict import modict
Advanced modules expose their richer surfaces directly:
modict.path_utilsmodict.collections_utilsmodict.typecheckermodict.model_api
The root package keeps only the most common convenience symbols:
- Data structure:
modict
- JSONPath types:
Path
- Sentinel:
MISSING
- Search:
Query(path=MISSING, value=MISSING)— combined path + value constraint;.find(root)returns matching(Path, value)pairs.MISSINGmeans "no constraint";Noneasvaluematches leaves whose value is literallyNone
- Type checking / coercion:
check_type(hint, value)coerce(value, hint)can_coerce(value, hint)typechecked(decorator)- Exceptions:
TypeCheckError,TypeCheckException,TypeCheckFailureError,TypeMismatchError,CoercionError
modict class methods
modict.config(**kwargs) -> modictConfigmodict.field(...) -> Fieldmodict.factory(callable) -> Factorymodict.attr(value) -> Attributemodict.wrap(*wrap_args, **wrap_kwargs) -> callable@modict.validator(field_name, mode="before"|"after")@modict.model_validator(mode="before"|"after")@modict.computed(cache=False, deps=None)- JSON helpers:
modict.loads(s, **json_kwargs) -> modictmodict.load(fp_or_path, **json_kwargs) -> modict
- Conversion:
modict.convert(obj, seen=None) -> Anymodict.unconvert(obj, seen=None) -> Anymodict.unwalk(walked: dict[Path, Any], ignore_types: bool = False, *, kind_resolver=None) -> Any
modict instance methods
Instance methods keep standard dict behavior, plus:
- Validation:
validate()
- Runtime attrs:
set_attr(name, value) -> Nonehas_attr(name) -> booldel_attr(name) -> None
- Conversion:
to_modict() -> modict(deep conversion)to_dict() -> dict(deep unconvert)
- Serialization:
dumps(exclude_none=False, encoders=None, **json_kwargs) -> strdump(fp_or_path, exclude_none=False, encoders=None, **json_kwargs) -> None
- Nested operations (JSONPath / tuple / Path):
get_nested(path, default=MISSING)set_nested(path, value, *, create_missing=False, container_factory=None)wherecontainer_factoryis called asfactory(path)del_nested(path)pop_nested(path, default=MISSING)has_nested(path) -> bool
- Key operations:
translate(mapping_or_kwargs) -> modict(returns a plain translated modict)exclude(*keys) -> modictextract(*keys) -> modictfind(query=MISSING, *, path_constraint=MISSING, value_constraint=MISSING) -> Generator— lazily yields(Path, value)pairs matching aQueryor inline constraints (deep)found(query=MISSING, *, path_constraint=MISSING, value_constraint=MISSING) -> modict— same, returned as a{Path: value}modict
- Deep operations:
merge(mapping) -> None(in-place)update(other=(), /, **kwargs) -> None— likedict.update()but routes through validationdiff(mapping) -> dict[Path, tuple]diffed(mapping) -> modict— minimal nested patch;self.merge(self.diffed(other))equalsotherdeep_equals(mapping) -> booldeepcopy() -> modict
- Walking:
walk(callback=None, filter=None, excluded=None) -> Iterable[tuple[Path, Any]]walked(callback=None, filter=None) -> dict[Path, Any]
- Computed cache:
invalidate_computed(*names) -> None(no args = all)
Development
python3 -m pytest -q
Contributing
Contributions are welcome.
- Please open an issue to discuss larger changes.
- For pull requests: add/adjust tests under
tests/and keeppython3 -m pytest -qgreen. - Local setup:
pip install -e ".[dev]".
See CONTRIBUTING.md for details.
License
MIT. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file modict-0.4.5.tar.gz.
File metadata
- Download URL: modict-0.4.5.tar.gz
- Upload date:
- Size: 148.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f9b70871ef95c2912ae2c6b87936d6aa798ded1fe423a7e5d025b05257220f84
|
|
| MD5 |
263dbc7326fda1c3eab624d081cb60e8
|
|
| BLAKE2b-256 |
8bff11a74eba2bfa46f8f5102b46a0b08b170a18e75539f0fc7b91ea3e590c61
|
File details
Details for the file modict-0.4.5-py3-none-any.whl.
File metadata
- Download URL: modict-0.4.5-py3-none-any.whl
- Upload date:
- Size: 138.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c8fec94007f59c127815338d373a8c0b178618ad6211742d3fe80f4fb802fcd
|
|
| MD5 |
378277de757ae29155e6c9ec8a23a5f9
|
|
| BLAKE2b-256 |
5d547557d5b3db983aa57691726d0432e7715c21de91287f2fc3325d969233c5
|