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
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 383 384 385 | |
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
500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 | |
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. |
Source code in src/popoto/models/encoding.py
hydrate_decoded_row(model_class, row, *, fields_only=False, eager=False)
¶
Build a model instance (or projection dict) from a decoded backend
row -- the shape a non-Redis backend's load/select returns, field
name to Python value plus "_id" (#759 M1b).
The counterpart of :func:decode_popoto_model_hashmap for values that
arrive already decoded: no msgpack, and no quarantine (a typed column
cannot hold undecodable bytes). Like :func:_create_lazy_model it skips
__init__'s validation of values that were valid when saved, and it
records identity provenance the same way: the instance's _redis_key
is the key the row was read from (_pk), never a recomputation.
Fields absent from the row (a projection, or a column the table gained
after the row was written) take their declared default, matching
Model.__init__ (#380).
eager mirrors the Redis read paths that decode through __init__
(Query.get, get_many): each Relationship key string is
resolved to its instance. Lazy reads (filter/all) keep the key
string, as they do on Redis (#759 M1.1). A capped ListField value is
wrapped in its CappedListProxy either way, as __init__ and
_load_capped_list_fields do.