Extensibility#
This document explains how gpdatacached's type system works and how to add support for your own types — both scalar and container — by writing a codec, an object class, and registering them. The built-in scalar types (int, str, …), container types (list, dict, set), and the optional extensions (pydantic, pandas) are all built on exactly the same public mechanism described here.
Do you need to read this? For the large majority of users and use cases, GPDC's built-in types — together with the bundled Pydantic and Pandas extensions — are already enough. If you do need a custom type, a scalar type or a flat (non-nested) container type will cover most real-world extension needs, and both are relatively straightforward to write. Reach for the full machinery described here only when you are extending a genuinely complex type (something on the order of
pandas.Series/pandas.DataFrame). In that case, read this document carefully and study the Lifecycle, Locking, and Performance Guide documents as well — getting a complex type right requires a fairly complete mental model of GPDC; otherwise it is easy to introduce subtle bugs.
1. The Type System at a Glance#
Every Python value that GPDC stores is described by a TypeEntry — an immutable record linking four things:
graph LR
TE["TypeEntry"]
PT["python_type<br/>(matched on encode)"]
GT["gpdc_type<br/>e.g. 'scalar:int'<br/>(matched on decode)"]
OC["object_class<br/>(GPDCDataObject subclass)"]
CC["codec_class<br/>(GPDCCodec subclass)"]
TE --- PT
TE --- GT
TE --- OC
TE --- CC
All entries live in one global, thread-safe registry: GPDCTypeRegistry. Both lookups go through it:
- Encoding (
ns["x"] = value) →lookup_by_python_type(value)finds the entry whosepython_typematchesvalue. - Decoding (reading back) → the stored GPDC type string selects the entry via
lookup_by_gpdc_type.
Two value families exist, with different contracts:
| Family | Stored as | Codec base | Object base | Examples |
|---|---|---|---|---|
| Scalar | A single encoded string inside the object's metadata hash | GPDCScalarCodec |
GPDCScalarObject |
int, str, datetime, tuple, None |
| Container | Element data in a dedicated Redis key (raw:) |
GPDCContainerCodec |
GPDCContainerObject |
list, dict, set |
Note:
tupleis registered as a scalar (a "composite scalar" stored as one JSON string), not as a container. Whether something is a scalar or a container is a design choice you make when registering — see §4.
2. The Registration Triple#
Registering a type always means pairing four things via one call:
from gpdatacached import GPDCTypeRegistry
GPDCTypeRegistry.register(
python_type=MyType, # the Python class(es) this entry matches
gpdc_type="scalar:mytype", # unique GPDC type string (a "family:name" pair)
object_class=MyObject, # GPDCScalarObject / GPDCContainerObject subclass
codec_class=MyCodec, # GPDCScalarCodec / GPDCContainerCodec subclass
priority=0, # tiebreaker for ambiguous Python types (higher wins)
)
The four fields map directly onto a TypeEntry. Two invariants matter:
object_class.TYPEmust equalgpdc_type. The object class identifies itself by itsTYPEclass attribute, and several internal paths (especially decoding) cross-check the two.gpdc_typemust be globally unique. Re-registering the samegpdc_typereplaces the previous entry (idempotent-style), but two different Python types must not share onegpdc_type.
The TypeEntry also exposes a derived boolean is_container (true when object_class is a subclass of GPDCContainerObject); the codec/object bases you pick determine it.
3. Type Resolution: encode vs decode#
graph TD
U["ns['x'] = value"] --> LOOK["GPDCTypeRegistry.lookup_by_python_type(value)"]
LOOK -->|iterates by descending priority| MATCH["first isinstance match wins"]
MATCH --> ENC["codec.encode_value(value)<br/>or codec.create_raw(...)"]
ENC --> STORE["Redis: metadata.type = gpdc_type,<br/>metadata.value = encoded payload / raw key"]
STORE --> READ["ns['x']"]
READ --> LOOK2["GPDCTypeRegistry.lookup_by_gpdc_type(meta.type)"]
LOOK2 --> OBJ["object_class(name, namespace)"]
OBJ --> DEC[".value → codec.decode_value<br/>or codec.read_raw(...)"]
The priority rule#
The registry iterates entries sorted by descending priority and returns the first one whose python_type matches value via isinstance. This matters whenever your custom type subclasses a type that is already registered. The built-ins rely on it: bool is registered at priority=10 and int at priority=0, so a True value matches bool first (otherwise it would be misrouted to int, since bool subclasses int). See §8.
4. The Codec Contract#
Three abstract base classes form the codec hierarchy:
graph TD
C0["GPDCCodec (ABC)"]
C1["GPDCScalarCodec"]
C2["GPDCContainerCodec (ABC)"]
C0 -->|encode_value / decode_value| C1
C0 -->|create_raw / read_raw / delete_raw| C2
GPDCCodec — the root ABC#
class GPDCCodec(ABC):
@classmethod
@abstractmethod
def encode_value(cls, value: object) -> str: ...
@classmethod
@abstractmethod
def decode_value(cls, encoded: str) -> object: ...
You never subclass this directly — pick one of the two specialised bases below.
GPDCScalarCodec — for scalars#
class GPDCScalarCodec(GPDCCodec):
@classmethod
def _parse_encoded(cls, encoded: str) -> tuple[str, str]:
# Split "type_prefix:payload" → (prefix, payload). Raises ValueError if malformed.
...
A scalar codec implements encode_value / decode_value. The convention is that the encoded form starts with a type prefix followed by a colon: "int:42", "str:hello", "color:10,20,30". The shared _parse_encoded helper splits this for you on decode.
The encoded string lives directly in the object's metadata hash under the value field. No raw key is used.
GPDCContainerCodec — for containers#
class GPDCContainerCodec(ABC):
@classmethod
@abstractmethod
def create_raw(cls, namespace, raw_key: str, value: object,
owner_name: str, cache_mode: str, ttl: int) -> None: ...
@classmethod
@abstractmethod
def read_raw(cls, namespace, raw_key: str) -> object: ...
@classmethod
@abstractmethod
def delete_raw(cls, namespace, raw_key: str) -> None: ...
A container codec does not use encode_value/decode_value. Instead it owns a dedicated Redis key (raw_key) and knows how to lay the value out in Redis and reconstruct it:
| Method | When called | Your job |
|---|---|---|
create_raw(namespace, raw_key, value, owner_name, cache_mode, ttl) |
On first store / full overwrite | Write value's elements into raw_key (LIST/HASH/SET/…). Use the encode helpers for elements. |
read_raw(namespace, raw_key) -> object |
On .value reads |
Reconstruct and return a plain Python object from Redis. |
delete_raw(namespace, raw_key) |
Before re-creating, and on teardown | Delete the raw key. |
The namespace argument gives you namespace._redis() (the Redis client) and namespace.domain.name / namespace.name for key construction. owner_name, cache_mode, ttl are forwarded so that nested-element encoding (see §10) can apply the right policy.
5. The Object Class Contract#
Object classes are thin wrappers that bind a Redis key to a Python-facing API. Three ABCs again:
graph TD
O0["GPDCDataObject (ABC)"]
O1["GPDCScalarObject"]
O2["GPDCContainerObject (ABC)"]
O0 --> O1
O0 --> O2
GPDCDataObject — the root ABC#
Provides identity (name, namespace, domain, uri, canonical_name), metadata access (meta, cache_mode, ttl, created_at, updated_at), lifecycle methods (expire_in, persist, …), and locking (lock(), try_lock()). You inherit all of this for free.
GPDCScalarObject — for scalars#
Subclasses set one class attribute and inherit the value getter/setter:
.valuegetter loads metadata, looks up the codec viaTYPE, and callsdecode_value..valuesetter type-checks against the registeredpython_type, encodes viaencode_value, writes the metadata, and refreshes TTL whencache_mode == "sliding".
You almost never override anything else.
GPDCContainerObject — for containers#
Subclasses mix in a collections.abc interface (MutableSequence, MutableMapping, or MutableSet) and implement its abstract methods on top of self.raw_key. You must provide:
- A
TYPE = "collection:mytype"class attribute. - A
typeproperty returningcls.TYPE. - A
valueproperty (decode viaread_raw) and avaluesetter that callsself._reject_value_replacement()— containers are mutated through their explicit methods, not reassigned. - All abstract methods of the mixed-in ABC (
__getitem__,__setitem__,__delitem__,__len__,__iter__,insert, …). - A
deepcopy()method.
You inherit _touch(), ref-tracking helpers (_release_element_ref, _data_owner_name, referrers()), and the encode/decode helpers — these are the building blocks for §11.
6. The Four-Step Extension Recipe#
Every extension, no matter how complex, boils down to four steps:
- Pick the family. Scalar = store-one-value; container = store-elements-in-Redis.
- Write the codec (
GPDCScalarCodecorGPDCContainerCodec). - Write the object class (
GPDCScalarObjectorGPDCContainerObject), withTYPEmatching the GPDC type you will register. - Register with
GPDCTypeRegistry.register(...), choosing aprioritythat beats any registered superclass.
The next sections walk through both families with complete, runnable examples.
7. Worked Example: A Custom Scalar Type (Color)#
Goal: cache an RGB Color value. It is a small, immutable, atomic value — a perfect fit for scalar mode.
Step 1 — the Python type#
Step 2 — the codec#
A scalar codec encodes to a prefix:payload string and decodes back. The prefix must match the part of the GPDC type after scalar:.
from gpdatacached import GPDCScalarCodec
class ColorCodec(GPDCScalarCodec):
@classmethod
def encode_value(cls, value: object) -> str:
# Always validate the input type explicitly — don't rely on duck typing.
if not isinstance(value, Color):
raise TypeError(f"ColorCodec cannot encode {type(value).__name__!r}")
# Convention: "prefix:payload". Keep the payload Redis-hash-safe (no stray colons
# that would confuse _parse_encoded, which splits on the FIRST colon).
return f"color:{value.r},{value.g},{value.b}"
@classmethod
def decode_value(cls, encoded: str) -> object:
# _parse_encoded returns (prefix, payload) and raises ValueError on malformed input.
prefix, payload = cls._parse_encoded(encoded)
if prefix != "color":
raise ValueError(f"ColorCodec cannot decode type {prefix!r}")
try:
r, g, b = (int(x) for x in payload.split(","))
except ValueError as e:
raise ValueError(f"Invalid color payload: {payload!r}") from e
return Color(r, g, b)
Two things to notice:
- Explicit type check on encode. The codec may be called with anything; rejecting early gives clear errors.
_parse_encodedsplits on the first colon. If your payload contains colons (e.g. an ISO datetime), that is fine — only the first colon is split.
Step 3 — the object class#
For scalars, this is a one-liner:
from gpdatacached import GPDCScalarObject
class ColorObject(GPDCScalarObject):
TYPE = "scalar:color" # MUST match the gpdc_type you register
Step 4 — register and use#
from gpdatacached import GPDCTypeRegistry, GPDCDomain, GPDCDomainConfig
GPDCTypeRegistry.register(
python_type=Color,
gpdc_type="scalar:color",
object_class=ColorObject,
codec_class=ColorCodec,
priority=0,
)
domain = GPDCDomain("app", GPDCDomainConfig(redis_host="127.0.0.1", redis_port=6379))
ns = domain.ensure_namespace("ui")
ns["background"] = Color(10, 20, 30)
assert ns["background"] == Color(10, 20, 30) # reads back as a native Color
# It composes with everything else for free.
ns["palette"] = [Color(255, 0, 0), Color(0, 255, 0)] # nested in a list
ns["theme"] = {"bg": Color(0, 0, 0)} # nested in a dict
ns.get_object("background").update_cache_mode_and_ttl("ttl", 60) # lifecycle works
The codec is also invoked automatically when your type appears as a list/dict element or a tuple member — you do not need to teach the containers about Color.
8. Priority in Practice: Subclass Relationships#
The registry matches via isinstance, sorted by descending priority. This is critical whenever your type subclasses an already-registered type.
Status subclasses int, and int is already registered at priority=0. If you register Status at priority=0 too, the registry order is unstable and a Status.ACTIVE value may match the plain int entry first — getting stored as "int:1" and losing the enum. The fix is to register at a higher priority:
GPDCTypeRegistry.register(
python_type=Status,
gpdc_type="scalar:status",
object_class=StatusObject,
codec_class=StatusCodec,
priority=10, # beats int (priority 0) — checked first
)
Rule of thumb: if your type isinstance(x, SomeRegisteredType) is True, give yours a higher priority than that type.
9. Worked Example: A Custom Container Type (Counter)#
Goal: cache a collections.Counter (a multiset). This is a container — it has a variable number of members and we want to mutate it in place. We will back it with a Redis HASH: {encoded_member: count_string}.
Step 1 — the Python type#
Counter is a dict subclass — so we will need priority > 0 to be matched before dict (see §8).
Step 2 — the codec#
from gpdatacached import GPDCContainerCodec
from gpdatacached.types import encode_key, decode_key
class CounterCodec(GPDCContainerCodec):
"""Stores a Counter as a Redis HASH mapping encoded_member → count string."""
@classmethod
def create_raw(cls, namespace, raw_key, value, owner_name, cache_mode, ttl) -> None:
if not value:
return
# Members are hashable scalars — encode_key normalises each to its
# "prefix:payload" form so the hash field names are unambiguous.
mapping = {encode_key(member): str(count) for member, count in value.items()}
namespace._redis().hset(raw_key, mapping=mapping)
@classmethod
def read_raw(cls, namespace, raw_key) -> Counter:
data = namespace._redis().hgetall(raw_key)
if not data:
return Counter()
return Counter({decode_key(k): int(v) for k, v in data.items()})
@classmethod
def delete_raw(cls, namespace, raw_key) -> None:
namespace._redis().delete(raw_key)
Notes:
- Members are scalars, so we use
encode_key/decode_key(the key-encoding helpers). For elements that might themselves be containers, you would useencode_element/decode_elementinstead — see §10. - Counts are stored as plain decimal strings. They are scalars, but we encode them inline rather than registering
int-as-count, because the count's meaning is local to this container. create_rawis a no-op whenvalueis empty — GPDC leaves the raw key absent, andread_rawreturns an emptyCounter.
Step 3 — the object class#
We mix in MutableMapping so users get keys(), values(), items(), get(), setdefault(), update(), and pop() for free. We implement the four abstract methods directly on self.raw_key.
import collections.abc
from collections import Counter
from gpdatacached import GPDCContainerObject
from gpdatacached.types import encode_key, decode_key
class CounterObject(GPDCContainerObject, collections.abc.MutableMapping):
TYPE = "collection:counter"
@property
def type(self) -> str:
return self.TYPE
@property
def value(self) -> Counter:
return CounterCodec.read_raw(self._namespace, self.raw_key)
@value.setter
def value(self, new_value):
# Containers cannot be reassigned — mutate via __setitem__ etc. instead.
self._reject_value_replacement()
def __len__(self) -> int:
return self._redis().hlen(self.raw_key)
def __getitem__(self, key):
encoded = encode_key(key)
raw = self._redis().hget(self.raw_key, encoded)
if raw is None:
raise KeyError(key)
return int(raw)
def __setitem__(self, key, count):
if not isinstance(count, int) or count < 0:
raise ValueError("Counter values must be non-negative ints")
encoded = encode_key(key)
r = self._redis()
if count == 0:
r.hdel(self.raw_key, encoded)
else:
r.hset(self.raw_key, encoded, str(count))
self._touch() # refresh sliding TTL + updated_at (see §11)
def __delitem__(self, key):
encoded = encode_key(key)
if not self._redis().hdel(self.raw_key, encoded):
raise KeyError(key)
self._touch()
def __iter__(self):
for encoded in self._redis().hkeys(self.raw_key):
yield decode_key(encoded)
def deepcopy(self) -> Counter:
return Counter(self.value)
Step 4 — register and use#
from gpdatacached import GPDCTypeRegistry, GPDCDomain, GPDCDomainConfig
GPDCTypeRegistry.register(
python_type=Counter,
gpdc_type="collection:counter",
object_class=CounterObject,
codec_class=CounterCodec,
priority=10, # Counter subclasses dict → must outrank dict (priority 0)
)
domain = GPDCDomain("counterapp", GPDCDomainConfig(redis_host="127.0.0.1", redis_port=6379))
ns = domain.ensure_namespace("words")
ns["word_counts"] = Counter({"hello": 3, "world": 1})
counts = ns["word_counts"] # → CounterObject (live)
counts["hello"] += 1 # mutate in place, hits Redis directly
assert counts["hello"] == 4
del counts["world"]
assert "world" not in counts
Because we mixed in MutableMapping, you also get counts.update(...), counts.get(k, default), counts.pop(k), and counts.most_common() (the last comes from Counter once you materialise via .value).
10. Element Encoding: Scalar Members vs Nested Containers#
The Counter example uses encode_key / decode_key because its members are hashable scalars. If your container can hold nested containers as elements (like the built-in list / dict), you must instead use encode_element / decode_element, which emit a ref:{obj_key} pointer for any container value and lazily materialise it as an anonymous GPDC object.
| Helper | When to use | Output |
|---|---|---|
encode_key(value) / decode_key(encoded) |
Hashable scalar elements (dict keys, set members, our Counter's members) | Always a prefix:payload string; rejects containers and the empty tuple |
encode_element(value, namespace, owner_name, policy) / decode_element(encoded, namespace) |
Elements that may be either scalar or container | A scalar-encoded string, or a ref:{obj_key} pointer that resolves to a live GPDCContainerObject |
For example, the built-in DictCodec uses encode_key for keys (always scalar) but encode_element for values (may be a nested list/dict/set). The built-in ListCodec uses encode_element for every element. When you remove or overwrite an element that turned out to be a ref: pointer, you must call self._release_element_ref(old_encoded) so the anonymous child's referrer count is decremented (and the child cascade-deleted when its last referrer goes) — see §11.
When create_raw encodes multiple sibling elements in one call, wrap the encode loop in an encode_session context manager. It provides cycle detection and atomic rollback so that a failure mid-encode deletes every anonymous object created during the call, leaving Redis untouched:
from gpdatacached.types import encode_element, encode_session
@classmethod
def create_raw(cls, namespace, raw_key, value, owner_name, cache_mode, ttl):
from gpdatacached.lifecycle import policy_from_defaults
policy = policy_from_defaults(cache_mode, ttl)
encoded_values = []
with encode_session(namespace): # cycle detection + rollback
encoded_values = [encode_element(v, namespace, owner_name, policy) for v in value]
if encoded_values:
namespace._redis().rpush(raw_key, *encoded_values)
For full reference, study
src/gpdatacached/builtins/list_object.pyanddict_object.py— they show the complete encode/decode/release pattern for nested-container containers.⚠️ Performance note. Each nested container becomes its own anonymous object with its own keys, ref tracking, and TTL. Deep nesting compounds this cost on store, read, sliding refresh, and cascade delete. See Performance Guide §4 for guidance on when to flatten.
11. Mutation Hooks Every Container Must Call#
When you implement a container object, every method that mutates Redis must honour three hooks. Skipping any of them produces subtle bugs (stale TTL, leaked anonymous children, wrong update timestamps).
a) Call self._touch() after every mutation#
_touch() (defined on GPDCContainerObject) updates the updated_at and item_count metadata, and — if the object is self-owned and in sliding mode — refreshes the TTL across the owned subtree. If the object is owned by a parent, it propagates the refresh up the owner chain instead.
def __setitem__(self, key, count):
...
r.hset(self.raw_key, encoded, str(count))
self._touch() # ← always
b) Call self._release_element_ref(old_encoded) when removing a ref: element#
If your container supports nested container elements (via encode_element), removing or overwriting one must decrement the anonymous child's referrer count:
def __setitem__(self, key, value):
encoded_key = encode_key(key)
old = r.hget(self.raw_key, encoded_key)
encoded_val = encode_element(value, self._namespace, self._data_owner_name(), policy)
r.hset(self.raw_key, encoded_key, encoded_val)
self._release_element_ref(old) # ← releases the previous value's ref, if it was a ref
self._touch()
_release_element_ref is a no-op for non-ref: encoded values, so it is always safe to call.
c) Use encode_session for multi-element encodes#
See §10. Single-element mutations (append, __setitem__ with one value) do not need it — the encode path opens a fallback session internally.
Read Lifecycle Management for the full ownership and ref-counting model that these hooks participate in.
12. Registration Checklist & Gotchas#
Before shipping an extension, run through this list:
- [ ]
object_class.TYPE == gpdc_type(exactly). - [ ]
gpdc_typeis globally unique — use a namespace prefix if unsure (scalar:mymodule_color). - [ ]
python_typeis the exact class users will pass; if it subclasses a registered type,priorityis higher than that type's. - [ ] Scalar codecs always type-check in
encode_valueand validate the prefix indecode_value. - [ ] Container codecs implement all three of
create_raw,read_raw,delete_raw. - [ ] Container objects implement every abstract method of the mixed-in ABC, plus
value(getter + setter that calls_reject_value_replacement),type, anddeepcopy. - [ ] Every mutation calls
self._touch(). - [ ] Removing/overwriting a possibly-
ref:element callsself._release_element_ref(old). - [ ] Multi-element encodes are wrapped in
encode_session. - [ ]
register()is called once at process startup, before any read or write that involves your type.
Gotchas:
- Forgetting
priorityfor a subclass. Your type will silently be encoded by the superclass's codec and decoded as the superclass. Always check: does my typeisinstanceto any registered type? If yes, raise the priority. - A
prefix:containing a colon._parse_encodedsplits on the first colon. Payloads may contain colons freely; prefixes must not. - Reassigning
.valueon a container. The setter must call_reject_value_replacement(). Containers change shape through their explicit methods only. - Registering after first use. The registry is process-global and mutable, but objects already stored in Redis under an old
gpdc_typewill not migrate. Register at startup. - Container element type restrictions.
encode_keyrejects containers and the empty tuple (unhashable / ambiguous).encode_elementaccepts scalars and containers but raisesCyclicReferenceErroron self-references.
13. Built-in Extensions#
Two optional extensions ship with the library and are written on exactly the public API described here:
| Extension | Family | Pattern | Notes |
|---|---|---|---|
pydantic_support |
Scalar and container | Per-model registration: thin object subclass + PydanticModelScalarCodec (scalar mode) or PydanticModelContainerCodec + BaseModelContainerObject (container mode) |
Lets you cache pydantic BaseModel records with optional field-level access. |
pandas_support |
Container | One register() call registers both Series and DataFrame |
Caches pandas structures as live objects with selective online accessors. |
Reading their source (src/gpdatacached/extensions/) is the best way to see the full codec/object pattern applied to non-trivial types.
Further Reading#
- Overview — where the type system sits in the overall architecture (§3, §7).
- API Reference — full signatures of
GPDCTypeRegistry, the codec ABCs, and the object ABCs. - Lifecycle Management — the ownership and ref-counting model your container's mutation hooks participate in.
- Pydantic Support · Pandas Support — worked examples of non-trivial extensions.