Immutable snapshots

rmote.immutable freezes a data tree into an independent, read-only snapshot. It has no dependency on collectors or cache backends and transfers as an ordinary module dependency when used by remote tools.

from copy import deepcopy

from rmote.immutable import freeze

source = {"services": [{"name": "api", "ports": [8080]}]}
snapshot = freeze(source)
source["services"][0]["ports"].append(8081)
assert snapshot["services"][0]["ports"] == (8080,)
assert deepcopy(snapshot) is snapshot
assert freeze(snapshot) is snapshot

updated = snapshot.updated({"revision": 2})
assert updated["services"] is snapshot["services"]
assert "revision" not in snapshot

Input

Snapshot

Mapping

DeepMappingProxy

Builtin list or tuple

Tuple of frozen values

Builtin set or frozenset

Frozenset of frozen values

Dataclass instance

DeepMappingProxy of field names to frozen values

Builtin str, bytes, int, float, complex, bool, range, None

Original immutable value

Dataclass conversion is a projection of fields, including init=False fields. It does not retain the model’s type, methods, or attribute access. For example, snapshot["network"]["interfaces"] uses mapping access. It is not a transparent replacement for a NetworkInfo instance or a typed FactsData snapshot. Cache stores values as supplied; it does not invoke freeze automatically. Call freeze explicitly before caching when this representation is wanted.

Unknown objects, iterators and scalar subclasses raise TypeError; they are not silently exposed as mutable leaves. Mapping keys must be builtin immutable scalars or tuples/frozensets composed of supported immutable keys. Set members must remain hashable after freezing; a dataclass projected to a mapping cannot be a set member. Cycles raise ValueError. Shared references in one input graph remain shared in the resulting snapshot.

Initial freezing takes time and memory proportional to the visited input. freeze() reuses an existing DeepMappingProxy without traversing it, and both copy.copy() and copy.deepcopy() return that snapshot unchanged. Raw tuples and frozensets still need inspection because they may contain mutable objects. updated(mapping) shallowly copies the root mapping and freezes only replacement branches. Old snapshots and unchanged nested branches remain valid and shared.

Pickle preserves the read-only representation, but serialization still traverses the data and a remote process necessarily receives its own objects. JSON encoders accept a snapshot, because it is a dict subclass. Source data must not be mutated concurrently during freezing. This is a read-only Python API, not a sandbox against code deliberately bypassing it through reflection.

DeepMappingProxy is a dict subclass, so reading one key, testing one key and copying the whole mapping run at the speed of dict. Every method that would change the content refuses with TypeError: __setitem__, __delitem__, update, setdefault, pop, popitem, clear, |= and fromkeys. copy() gives the snapshot itself, as copy.copy() does. The combine snapshot | other gives a new plain dictionary and leaves the snapshot unchanged. A read-only mapping cannot refuse dict.__setitem__ called explicitly on it; the contract is the API, not the storage.

final class rmote.immutable.DeepMappingProxy(values)[source]

Bases: dict[K, Scalar | DeepMappingProxy[Any] | tuple[FrozenValue, …] | frozenset[FrozenValue]]

Read-only mapping owning an independent recursively frozen snapshot.

Keys must be immutable builtin scalars or tuples/frozensets of such keys. Repeated references within input share one frozen value. copy.copy and copy.deepcopy return this object, and freeze(snapshot) is snapshot. Pickle and ordinary module-tool transfer preserve the read-only API.

The class is a dict subclass, so a read of a key and a copy of the whole mapping run at the speed of dict. Every method that would change the content refuses, and the type refuses an indexed assignment as well. Immutability stays an API contract: dict.__setitem__ called explicitly on a snapshot still changes it, as every other reflection does.

>>> source = {"items": [{"count": 1}]}
>>> snapshot = DeepMappingProxy(source)
>>> source["items"][0]["count"] = 2
>>> snapshot["items"][0]["count"]
1
clear()[source]

Remove all items from the dict.

Return type:

NoReturn

copy()[source]

Give this snapshot, because a copy of frozen data has no purpose.

Return type:

DeepMappingProxy[TypeVar(K)]

classmethod fromkeys(*args, **kwargs)[source]

Create a new dictionary with keys from iterable and values set to value.

Return type:

NoReturn

pop(*args, **kwargs)[source]

If the key is not found, return the default if given; otherwise, raise a KeyError.

Return type:

NoReturn

popitem()[source]

Remove and return a (key, value) pair as a 2-tuple.

Pairs are returned in LIFO (last-in, first-out) order. Raises KeyError if the dict is empty.

Return type:

NoReturn

setdefault(*args, **kwargs)[source]

Insert key with a value of default if key is not in the dictionary.

Return the value for key if key is in the dictionary, else default.

Return type:

NoReturn

update(*args, **kwargs)[source]

If E is present and has a .keys() method, then does: for k in E.keys(): D[k] = E[k] If E is present and lacks a .keys() method, then does: for k, v in E: D[k] = v In either case, this is followed by: for k in F: D[k] = F[k]

Return type:

NoReturn

updated(values)[source]

Replace complete branches in a new snapshot, sharing unchanged ones.

Only the supplied values are frozen. The outer mapping is shallowly copied; nested unchanged mappings, tuples and frozensets are reused. The current snapshot is never changed. An empty update returns self.

Return type:

DeepMappingProxy[TypeVar(K)]

rmote.immutable.freeze(value)[source]
Overloads:
  • value (DeepMappingProxy[K]) → DeepMappingProxy[K]

  • value (Mapping[K, object]) → DeepMappingProxy[K]

  • value (S) → S

  • value (list[Any] | tuple[object, …]) → tuple[FrozenValue, …]

  • value (set[Any] | frozenset[Any]) → frozenset[FrozenValue]

  • value (object) → FrozenValue

Freeze a data graph once, rejecting cycles and unsupported objects.

Already frozen mappings are reused without walking them. Ordinary tuples and frozensets are inspected, since they can contain mutable objects. Shared references are preserved within one call. Dataclass fields (including init=False fields) are captured without carrying class identity or methods. No arbitrary objects, iterators, or user-defined scalar subclasses are silently retained as potentially mutable leaves.