Skip to main content

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 with diff/diffed.
  • Typed events and messages: a modict subclass with extra="forbid" and required=True fields behaves like a TypedDict with 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

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, and unwalk

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

  • modict is a real dict: it supports standard dict operations and behaves like a mutable mapping.
  • A modict class can declare fields with type annotations and modict.field(...).
  • The model-like behavior is controlled by the _config class attribute (a modictConfig dataclass):
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: when True, no coercion is attempted — values must already match the declared type.
  • validate_assignment: when True (default), every assignment goes through the full pipeline (coercion, type check, validators). Set to False to only run validation at init time.
  • enforce_json: when True, 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:

  1. field validators in mode="before" (receive raw input)
  2. coercion (only when strict=False)
  3. type check against hint (if the field has a type annotation)
  4. field validators in mode="after" (receive coerced, typed value)
  5. 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")
  • Path objects: 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 Mapping vs Sequence structure 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 plain dict / list containers.
  • unwalk(..., kind_resolver=...) can refine the inferred structure per container path.
  • ignore_types=True remains available as a legacy mode to ignore Path hints 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 with required=True).
    • When False, modict behaves more like a plain dict regarding keys: required=True, require_all=True, extra="forbid"/"ignore", and computed overwrite/delete protection are all skipped.
    • frozen=True is always enforced regardless of check_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: when True, disables coercion (type checking still applies when hints exist).
  • validate_assignment: when True (default), every assignment re-runs the full pipeline. Set to False to only validate at init.
  • frozen: when True, __setitem__ / __delitem__ raise — effectively read-only instances.
  • auto_convert: when True (default), values stored in nested mutable containers are lazily upgraded on access:
    • nested plain dictmodict (plain modict, not your subclass)
    • applies recursively inside lists, tuples, sets, and dicts as you touch them.
  • enforce_json: when True, values must be JSON-serializable after the pipeline runs.
    • allow_inf_nan: controls whether NaN/Infinity pass the JSON check (default: True).
    • json_encoders: a {type: callable} mapping used as fallback encoders by dumps()/dump().
  • validate_default: when True, default field values are type-checked at class creation time (skips Factory/Computed).
  • from_attributes: when True, MyModict(obj) can read declared fields from obj.field attributes (when obj is not a mapping).
  • override_computed: when False (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: when True, all declared class fields must be present at initialization; annotation-only fields become required and cannot be deleted.
  • evaluate_computed: when True (default), computed fields are evaluated on access. When False, the Computed object 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 when require_all=False.
  • A field is an invariant only when you opt in: set required=True on the field (or require_all=True on 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)) when strict=False
  • the @typechecked decorator 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 with None values
  • encoders: {type: callable} mapping for custom serialization (overrides json_encoders from 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 upgrades dict nodes to modict (and walks into mutable containers).
    • The root dict becomes your class; nested dicts become plain modict unless they were already instances.
    • recurse=False stops recursion when reaching a modict node (used internally for lazy auto_convert).
  • m.to_modict(): deep conversion of an instance in-place (calls convert(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 structural dict / list containers, with an optional hook to refine inferred mapping / sequence kinds per path. The root can then be recast through modict.unwalk(...).
  • merge(mapping): deep, in-place merge (mappings merge by key; sequences merge by index). Returns None — modifies in place.
  • diff(mapping): deep diff — returns {Path: (left, right)} with MISSING for absent values.
  • diffed(mapping): minimal nested patch — returns a plain modict containing only the changes needed so that self.merge(self.diffed(other)) equals other. Keys removed in other are set to MISSING.
  • 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 dict or Mapping is 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) and Component(**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() and render() 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:

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.keym["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
  • modictConfig: configuration object with explicit-key tracking and inheritance merge semantics
  • modictKeysView / 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 via jsonpath-ng.
    • Path components can cache origin container references so walk()unwalk() can distinguish Mapping vs Sequence structure without recreating arbitrary concrete container classes.
    • Path(...) accepts JSONPath strings, tuples/lists of keys, or another Path.
  • _basic.py: container-agnostic get_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: MISSING sentinel to distinguish "absent" from None.

modict/typechecker/ (runtime typing + coercion)

For the full module-level API, see modict/typechecker/README.md.

  • TypeChecker: checks values against typing hints and collection ABCs.
  • Coercer: best-effort conversions for common hints/containers.
  • Convenience API: check_type, coerce, can_coerce, and typechecked, coerced for 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 by modictMeta during 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_utils
  • modict.collections_utils
  • modict.typechecker
  • modict.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. MISSING means "no constraint"; None as value matches leaves whose value is literally None
  • 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) -> modictConfig
  • modict.field(...) -> Field
  • modict.factory(callable) -> Factory
  • modict.attr(value) -> Attribute
  • modict.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) -> modict
    • modict.load(fp_or_path, **json_kwargs) -> modict
  • Conversion:
    • modict.convert(obj, seen=None) -> Any
    • modict.unconvert(obj, seen=None) -> Any
    • modict.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) -> None
    • has_attr(name) -> bool
    • del_attr(name) -> None
  • Conversion:
    • to_modict() -> modict (deep conversion)
    • to_dict() -> dict (deep unconvert)
  • Serialization:
    • dumps(exclude_none=False, encoders=None, **json_kwargs) -> str
    • dump(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) where container_factory is called as factory(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) -> modict
    • extract(*keys) -> modict
    • find(query=MISSING, *, path_constraint=MISSING, value_constraint=MISSING) -> Generator — lazily yields (Path, value) pairs matching a Query or 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 — like dict.update() but routes through validation
    • diff(mapping) -> dict[Path, tuple]
    • diffed(mapping) -> modict — minimal nested patch; self.merge(self.diffed(other)) equals other
    • deep_equals(mapping) -> bool
    • deepcopy() -> 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 keep python3 -m pytest -q green.
  • Local setup: pip install -e ".[dev]".

See CONTRIBUTING.md for details.

License

MIT. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

modict-0.4.5.tar.gz (148.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

modict-0.4.5-py3-none-any.whl (138.3 kB view details)

Uploaded Python 3

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

Hashes for modict-0.4.5.tar.gz
Algorithm Hash digest
SHA256 f9b70871ef95c2912ae2c6b87936d6aa798ded1fe423a7e5d025b05257220f84
MD5 263dbc7326fda1c3eab624d081cb60e8
BLAKE2b-256 8bff11a74eba2bfa46f8f5102b46a0b08b170a18e75539f0fc7b91ea3e590c61

See more details on using hashes here.

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

Hashes for modict-0.4.5-py3-none-any.whl
Algorithm Hash digest
SHA256 6c8fec94007f59c127815338d373a8c0b178618ad6211742d3fe80f4fb802fcd
MD5 378277de757ae29155e6c9ec8a23a5f9
BLAKE2b-256 5d547557d5b3db983aa57691726d0432e7715c21de91287f2fc3325d969233c5

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page