popoto.models.encoding¶
popoto.models.encoding
¶
Serialization and deserialization of Popoto model instances using msgpack.
Custom types (Decimal, tuple, set, datetime, date, time, DataFrame) are encoded with tagged dicts so they round-trip through msgpack faithfully.
This module bridges Python's rich type system with Redis's binary storage using MessagePack as the serialization format. MessagePack was chosen over JSON for its compactness and speed, and over pickle for safety and cross-language compatibility.
Design Philosophy
Redis stores all values as binary strings, but Popoto models use rich Python types (Decimal, datetime, pandas DataFrames, etc.). This module provides a type-preserving serialization system that encodes these types into a format MessagePack can handle, then decodes them back to their original Python types.
The encoding strategy uses sentinel keys (e.g., "Decimal", "datetime") embedded in dictionaries to identify special types during decoding. This allows the decoder to distinguish between a regular dict and an encoded Decimal without requiring schema information.
Architecture
- TYPE_ENCODER_DECODERS: Registry mapping Python types to their encode/decode functions. Extensible for new types.
- encode_popoto_model_obj(): Entry point for serializing a Model instance to a Redis hash (dict of field_name -> packed_value).
- decode_popoto_model_hashmap(): Entry point for deserializing a Redis hash back into a Model instance.
Integration
Called by Model.save() to persist objects and by Query/DB_key to reconstruct objects from Redis. The encoding is transparent to model users.
Example
Automatic during save¶
person = Person(name="Alice", birthday=datetime.date(1990, 1, 15)) person.save() # Internally calls encode_popoto_model_obj
Automatic during query¶
person = Person.query.get(name="Alice") # Internally calls decode_popoto_model_hashmap
EncoderDecoder = namedtuple('EncoderDecoder', 'key, encoder, decoder')
module-attribute
¶
A named tuple defining how to serialize and deserialize a specific Python type.
Attributes:
| Name | Type | Description |
|---|---|---|
key |
A unique sentinel string (e.g., "Decimal") used to identify this type in serialized data. Appears as a key in the encoded dict. |
|
encoder |
A callable that transforms a Python object into a dict with the sentinel key and an "as_encodable" key containing the serializable form. |
|
decoder |
A callable that reconstructs the original Python object from the encoded dict. |
DECODERS_BY_KEYSTRING = {(encoder_decoder.key): (encoder_decoder.decoder) for encoder_decoder in (TYPE_ENCODER_DECODERS.values())}
module-attribute
¶
Lookup table for fast decoder resolution during deserialization.
Maps sentinel key strings (e.g., "Decimal") directly to decoder functions, avoiding the need to iterate through TYPE_ENCODER_DECODERS during decode. This is a performance optimization for the hot path of object reconstruction.
decode_custom_types(obj)
¶
Msgpack object-hook that restores tagged dicts to their Python types.
This is the counterpart to the type-specific encoders in TYPE_ENCODER_DECODERS. When MessagePack deserializes data, custom types come back as plain dicts with sentinel keys. This function detects those sentinel keys and applies the appropriate decoder to reconstruct the original Python type.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obj
|
Any value returned from msgpack.unpackb(). If it's a dict with "as_encodable" and a recognized sentinel key, it will be decoded. Otherwise, returned unchanged. |
required |
Returns:
| Type | Description |
|---|---|
|
The decoded Python object (Decimal, datetime, etc.) if obj was an |
|
|
encoded custom type, otherwise obj unchanged. |
Design Note
The "as_encodable" check is a fast-path optimization. Most dicts in user data won't have this key, so we can skip the sentinel key scan for the common case.
Source code in src/popoto/models/encoding.py
encode_popoto_model_obj(obj)
¶
Encode a model instance into a dict of {field_name_bytes: msgpack_bytes}.
Transforms all field values on a model into a dictionary suitable for Redis HSET operations. Each field name becomes a UTF-8 encoded key, and each value is MessagePack-serialized (with custom type handling).
Relationship fields are stored as the related instance's redis_key.
Custom types (Decimal, datetime, etc.) use tagged-dict encoding.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obj
|
Model
|
A Popoto Model instance to serialize. Must have _meta.fields populated by the metaclass. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
A dict mapping bytes (field names) to bytes (packed values), ready |
dict
|
for direct use with Redis HSET/HMSET commands. |
Raises:
| Type | Description |
|---|---|
ModelException
|
If a Relationship field contains a value that isn't an instance of the expected related model. |
Encoding Strategy
- Relationship fields: Store the related object's db_key (Redis key string), not the full object. This enables lazy loading and avoids circular serialization.
- Custom types (Decimal, datetime, etc.): Use TYPE_ENCODER_DECODERS to wrap the value with a sentinel key for later type reconstruction.
- All other types: Direct MessagePack serialization (handles None, str, int, float, bool, list, dict natively).
Integration
Called by Model.save() as part of the persistence pipeline. The returned dict is passed directly to Redis via HSET.
Note
NumPy array support is enabled via msgpack_numpy patching, allowing fields to store numpy arrays efficiently.
Source code in src/popoto/models/encoding.py
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 | |
decode_popoto_model_hashmap(model_class, redis_hash, fields_only=False, lazy=False, source_redis_key=None)
¶
Decode a Redis hash into a model instance (or a raw fields dict).
The inverse of encode_popoto_model_obj(). Takes raw Redis hash data (bytes keys and MessagePack-encoded values) and reconstructs either a fully-instantiated Model object or a plain dictionary of field values.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_class
|
Model
|
The Model subclass to instantiate. |
required |
redis_hash
|
dict
|
Mapping of |
required |
fields_only
|
If |
False
|
|
lazy
|
If |
False
|
|
source_redis_key
|
The key this hash was actually read from. When given
it becomes the instance's |
None
|
Returns:
| Type | Description |
|---|---|
Model
|
A model instance, a dict (when fields_only), or |
Model
|
is empty. |
Decoding Process
- Each value is unpacked via msgpack.unpackb()
- decode_custom_types() checks for sentinel keys and reconstructs special types (Decimal, datetime, etc.)
- Field names are decoded from bytes to strings (unless fields_only)
- The resulting dict is passed to model_class() to create the instance
Integration
Called by: - DB_key.get() for single-object retrieval - Query iteration for bulk object loading - Query.values() for projection queries (with fields_only=True)
Note
Relationship fields are stored as Redis key strings, not full objects. The Model's getattribute handles lazy loading of related objects when accessed.
Identity provenance (#537/#538):
Without source_redis_key an instance's _redis_key is recomputed
from its decoded KeyField values, so the instance's idea of where it
came from follows the decode. That is the precise mechanism by which
row duplication was silent: when a decode change shifts a KeyField's
rendering, _saved_field_values and the attribute still agree (both
hold the decoded value) so save()'s KeyMutationError guard sees no
change, and the recomputed _redis_key already matches the new
derivation so save()'s obsolete-key branch never fires either.
Two independent safety nets, both blinded by the same recomputation;
the row is written to a second hash and the original is orphaned with
no exception and no log line.
Passing the real source key converts that silent duplication into
``save()``'s existing *rename* path. It also makes the datetime-key
migration lazily self-healing: a pre-migration row that happens to be
re-saved before the operator runs the migration moves itself.
Source code in src/popoto/models/encoding.py
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 | |
decode_lazy_field(value_bytes)
¶
Decode a single msgpack-encoded field value.
Called by Model.getattribute when accessing a lazily-loaded field for the first time.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value_bytes
|
bytes
|
Raw msgpack bytes from Redis. |
required |
Returns:
| Type | Description |
|---|---|
|
The decoded Python value with custom types restored. |