gpdatacached 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/CHANGELOG.md +35 -0
  2. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/PKG-INFO +5 -5
  3. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/README.md +3 -3
  4. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/README.zh-CN.md +3 -3
  5. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/en/api.md +17 -5
  6. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/en/extensibility.md +3 -2
  7. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/en/lifecycle.md +2 -2
  8. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/en/overview.md +2 -2
  9. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/en/pandas-support.md +3 -3
  10. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/en/performance-guide.md +2 -2
  11. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/en/pydantic-support.md +1 -1
  12. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/zh/api.md +16 -4
  13. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/zh/extensibility.md +3 -2
  14. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/zh/lifecycle.md +3 -3
  15. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/zh/overview.md +2 -2
  16. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/zh/pandas-support.md +5 -5
  17. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/zh/performance-guide.md +2 -2
  18. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/zh/pydantic-support.md +1 -1
  19. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/__init__.py +4 -1
  20. gpdatacached-0.2.1/src/gpdatacached/builtins/decimal_object.py +39 -0
  21. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/registry.py +11 -0
  22. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/extensions/pandas_support.py +1 -1
  23. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/keys.py +1 -1
  24. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/.gitignore +0 -0
  25. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/LICENSE +0 -0
  26. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/en/index.md +0 -0
  27. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/en/locking.md +0 -0
  28. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/zh/index.md +0 -0
  29. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/docs/zh/locking.md +0 -0
  30. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/pyproject.toml +0 -0
  31. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/__init__.py +0 -0
  32. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/bool_object.py +0 -0
  33. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/datetime_object.py +0 -0
  34. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/dict_object.py +0 -0
  35. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/float_object.py +0 -0
  36. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/int_object.py +0 -0
  37. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/list_object.py +0 -0
  38. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/none_object.py +0 -0
  39. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/set_object.py +0 -0
  40. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/str_object.py +0 -0
  41. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/builtins/tuple_object.py +0 -0
  42. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/codec.py +0 -0
  43. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/config.py +0 -0
  44. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/container.py +0 -0
  45. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/data_cache.py +0 -0
  46. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/domain.py +0 -0
  47. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/exceptions.py +0 -0
  48. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/extensions/__init__.py +0 -0
  49. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/extensions/pydantic_support.py +0 -0
  50. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/gc.py +0 -0
  51. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/lifecycle.py +0 -0
  52. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/locking.py +0 -0
  53. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/meta.py +0 -0
  54. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/namespace.py +0 -0
  55. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/object.py +0 -0
  56. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/refset.py +0 -0
  57. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/scalar.py +0 -0
  58. {gpdatacached-0.2.0 → gpdatacached-0.2.1}/src/gpdatacached/types.py +0 -0
@@ -8,6 +8,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  GitHub Releases (with download artifacts) are published at
9
9
  <https://github.com/LinnetCodes/gpdatacached/releases>.
10
10
 
11
+ ## [0.2.1] — 2026-10-12
12
+
13
+ ### Added
14
+
15
+ - **`Decimal` builtin scalar type** — `decimal.Decimal` is auto-registered on
16
+ `import gpdatacached` as `scalar:decimal`; `DecimalObject` and `DecimalCodec`
17
+ are exported from the package root. Encoded losslessly as
18
+ `decimal:{str(value)}`: coefficient/exponent (significance) are preserved
19
+ exactly, extreme exponents use scientific notation, and non-finite values
20
+ (`NaN`, `sNaN`, `±Infinity`) plus `-0` roundtrip as-is — the same policy as
21
+ `float`'s `nan`/`inf`. Works everywhere a registered scalar does: standalone
22
+ objects, container elements, dict keys, set members, tuple elements, and
23
+ pandas object-dtype columns/index labels. Malformed payloads raise
24
+ `ValueError` on decode. Note the documented cross-type-equality caveat:
25
+ natively-equal values of different numeric types (`1` vs `Decimal(1)`) are
26
+ distinct GPDC dict keys/set members, as `1` vs `1.0` already were.
27
+
28
+ ### Documentation
29
+
30
+ - Full docs-vs-code synchronization pass across `docs/` (English + zh-CN
31
+ mirrors) and both READMEs: README now lists `Decimal` among the supported
32
+ scalar types and its Quick Start lifecycle example is runnable as written
33
+ (`update_cache_mode_and_ttl` instead of mode-restricted `expire_in`);
34
+ `api.md` documents `remaining_ttl()` and the exact `ObjectGoneError`
35
+ surface; the extensibility codec diagram and the overview layer-dependency
36
+ edge were corrected to match the code (`GPDCContainerCodec` is a sibling
37
+ root of `GPDCCodec`, and the extensions depend on the type-system layer,
38
+ not the builtins); performance-guide RTT attributions were updated; the
39
+ zh-CN mirrors gained the translated §7 ownership heading (with a durable
40
+ explicit anchor) and several translation fixes. `keys.py`'s `make_ref_key`
41
+ docstring and the pandas `UnsupportedElementTypeError` guidance message
42
+ were corrected to match reality (hash, not set; `decimal` included). The
43
+ README status badge now reads Beta, aligned with the packaging
44
+ classifier `Development Status :: 4 - Beta`.
45
+
11
46
  ## [0.2.0] — 2026-07-26
12
47
 
13
48
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: gpdatacached
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Redis-based cross-process data sharing and caching library
5
5
  Project-URL: Homepage, https://github.com/linnetcodes/gpdatacached
6
6
  Project-URL: Documentation, https://linnetcodes.github.io/gpdatacached
@@ -41,7 +41,7 @@ Description-Content-Type: text/markdown
41
41
 
42
42
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
43
43
  [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/)
44
- [![Development Status](https://img.shields.io/badge/status-alpha-orange.svg)](https://pypi.org/project/gpdatacached/)
44
+ [![Development Status](https://img.shields.io/badge/status-beta-blue.svg)](https://pypi.org/project/gpdatacached/)
45
45
 
46
46
  A Redis-based **cross-process data sharing and caching** library for Python. Any process that can reach the same Redis instance can share live, mutable, typed data structures — scalars, lists, dicts, sets — with reference semantics, lifecycle control, and optional distributed locking.
47
47
 
@@ -53,7 +53,7 @@ A Redis-based **cross-process data sharing and caching** library for Python. Any
53
53
 
54
54
  Sharing mutable state across processes is painful. `multiprocessing.Manager` is single-process-bottlenecked; raw Redis forces you to (de)serialize everything by hand and gives you no type safety, no reference tracking, no TTL cascades. `gpdatacached` gives you a dict-like API on top of Redis with:
55
55
 
56
- - **Typed objects** — store `int`, `str`, `datetime`, `list`, `dict`, `set`, … and get the same type back.
56
+ - **Typed objects** — store `int`, `decimal.Decimal`, `str`, `datetime`, `list`, `dict`, `set`, … and get the same type back.
57
57
  - **Live containers** — `ns["tags"].append("x")` mutates Redis directly; no read-modify-write races on the whole structure.
58
58
  - **Reference sharing** — bind the same container under multiple names inside a namespace; the data is stored once.
59
59
  - **Lifecycle control** — per-object `permanent` / `ttl` / `sliding-window` cache modes, with cascade propagation to owned subtrees.
@@ -105,7 +105,7 @@ assert ns["tags"].value == ["b", "c", "d"]
105
105
 
106
106
  # Lifecycle control.
107
107
  ns["temp"] = "x"
108
- ns.get_object("temp").expire_in(60) # requires cache_mode="ttl"
108
+ ns.get_object("temp").update_cache_mode_and_ttl("ttl", 60) # permanent → ttl, expires in 60s
109
109
 
110
110
  domain.close()
111
111
  ```
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
6
  [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/)
7
- [![Development Status](https://img.shields.io/badge/status-alpha-orange.svg)](https://pypi.org/project/gpdatacached/)
7
+ [![Development Status](https://img.shields.io/badge/status-beta-blue.svg)](https://pypi.org/project/gpdatacached/)
8
8
 
9
9
  A Redis-based **cross-process data sharing and caching** library for Python. Any process that can reach the same Redis instance can share live, mutable, typed data structures — scalars, lists, dicts, sets — with reference semantics, lifecycle control, and optional distributed locking.
10
10
 
@@ -16,7 +16,7 @@ A Redis-based **cross-process data sharing and caching** library for Python. Any
16
16
 
17
17
  Sharing mutable state across processes is painful. `multiprocessing.Manager` is single-process-bottlenecked; raw Redis forces you to (de)serialize everything by hand and gives you no type safety, no reference tracking, no TTL cascades. `gpdatacached` gives you a dict-like API on top of Redis with:
18
18
 
19
- - **Typed objects** — store `int`, `str`, `datetime`, `list`, `dict`, `set`, … and get the same type back.
19
+ - **Typed objects** — store `int`, `decimal.Decimal`, `str`, `datetime`, `list`, `dict`, `set`, … and get the same type back.
20
20
  - **Live containers** — `ns["tags"].append("x")` mutates Redis directly; no read-modify-write races on the whole structure.
21
21
  - **Reference sharing** — bind the same container under multiple names inside a namespace; the data is stored once.
22
22
  - **Lifecycle control** — per-object `permanent` / `ttl` / `sliding-window` cache modes, with cascade propagation to owned subtrees.
@@ -68,7 +68,7 @@ assert ns["tags"].value == ["b", "c", "d"]
68
68
 
69
69
  # Lifecycle control.
70
70
  ns["temp"] = "x"
71
- ns.get_object("temp").expire_in(60) # requires cache_mode="ttl"
71
+ ns.get_object("temp").update_cache_mode_and_ttl("ttl", 60) # permanent → ttl, expires in 60s
72
72
 
73
73
  domain.close()
74
74
  ```
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
6
  [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/)
7
- [![Development Status](https://img.shields.io/badge/status-alpha-orange.svg)](https://pypi.org/project/gpdatacached/)
7
+ [![Development Status](https://img.shields.io/badge/status-beta-blue.svg)](https://pypi.org/project/gpdatacached/)
8
8
 
9
9
  一个基于 Redis 的**跨进程数据共享与缓存** Python 库。任何能够访问同一 Redis 实例的进程,都可以共享活跃的、可变的、带类型的数据结构 —— 标量、list、dict、set —— 并具备引用语义、生命周期控制以及可选的分布式锁。
10
10
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  跨进程共享可变状态一直很麻烦:`multiprocessing.Manager` 会形成单进程瓶颈;直接使用 Redis 又需要你手动序列化所有数据,且没有类型安全、没有引用追踪、没有 TTL 级联。`gpdatacached` 在 Redis 之上提供了一套类 dict 的 API,特性包括:
18
18
 
19
- - **带类型的对象** —— 存储 `int`、`str`、`datetime`、`list`、`dict`、`set` …,读取时得到相同类型。
19
+ - **带类型的对象** —— 存储 `int`、`decimal.Decimal`、`str`、`datetime`、`list`、`dict`、`set` …,读取时得到相同类型。
20
20
  - **活跃容器** —— `ns["tags"].append("x")` 直接修改 Redis,避免对整个结构的“读-改-写”竞态。
21
21
  - **引用共享** —— 在同一 namespace 内,同一个容器可绑定到多个名称;数据仅存储一份。
22
22
  - **生命周期控制** —— 每个对象可设置 `permanent` / `ttl` / `sliding`(滑动窗口)缓存模式,并将策略级联传播到所拥有的子树。
@@ -68,7 +68,7 @@ assert ns["tags"].value == ["b", "c", "d"]
68
68
 
69
69
  # 生命周期控制。
70
70
  ns["temp"] = "x"
71
- ns.get_object("temp").expire_in(60) # 要求 cache_mode="ttl"
71
+ ns.get_object("temp").update_cache_mode_and_ttl("ttl", 60) # permanent → ttl,60 秒后过期
72
72
 
73
73
  domain.close()
74
74
  ```
@@ -202,7 +202,7 @@ Configuration for a `GPDCDomain`. All fields are validated; lock fields must be
202
202
  | `gc_lock_ttl_seconds` | `int` | `30` | TTL for locks acquired by GC (must be `≥ 1`). |
203
203
  | `gc_lock_wait_seconds` | `int` | `5` | GC per-object try-lock timeout (must be `≥ 0`). |
204
204
 
205
- 👉 These four fields are consistent across every process that opens the same domain. See [Multi-Process Locking](./locking.md) for what each controls, the cost locks impose, and the GC contract.
205
+ 👉 These four fields must be kept consistent across every process that opens the same domain (mismatch raises `GPDCLockConfigInconsistentError` at domain construction). See [Multi-Process Locking](./locking.md) for what each controls, the cost locks impose, and the GC contract.
206
206
 
207
207
  ---
208
208
 
@@ -431,12 +431,14 @@ Abstract base class for every GPDC data object. Subclasses are split into [`GPDC
431
431
  |---|---|---|
432
432
  | `type` | `str` | GPDC type string, e.g. `"scalar:int"`, `"collection:list"`. |
433
433
  | `meta` | `ObjectMeta \| None` | Full metadata record; `None` if the object has vanished. |
434
- | `cache_mode` | `str` | Effective cache mode; raises `ObjectGoneError` if missing. |
434
+ | `cache_mode` | `str` | Effective cache mode. |
435
435
  | `ttl` | `int` | Declared TTL in seconds (`0` = permanent). |
436
436
  | `created_at` | `datetime \| None` | Creation timestamp. |
437
437
  | `updated_at` | `datetime \| None` | Last update timestamp. |
438
438
  | `value` | `object` | The Python value (abstract; subclasses implement). |
439
439
 
440
+ > The meta-backed properties (`cache_mode`, `ttl`, `created_at`, `updated_at`) each raise `ObjectGoneError` if the object's metadata no longer exists.
441
+
440
442
  **Lifecycle methods**
441
443
 
442
444
  > This is the API summary. For the full model — policy resolution, sliding refresh, ownership, anonymous/reference lifecycle, and pitfalls — see [Lifecycle Management](./lifecycle.md).
@@ -449,6 +451,7 @@ Abstract base class for every GPDC data object. Subclasses are split into [`GPDC
449
451
  | `persist()` | Switch to `cache_mode="permanent"` with `ttl=0`. Requires ownership. |
450
452
  | `update_cache_mode_and_ttl(cache_mode, ttl)` | Replace the full cache policy. Requires ownership. |
451
453
  | `refresh_ttl()` | Re-apply this object's own TTL to its keys and cascade-refresh its owned subtree (used by the sliding-window path). No-op for permanent objects. |
454
+ | `remaining_ttl()` | Remaining TTL in seconds on the canonical key, as reported by the Redis `TTL` command (`-1` = no expiry, `-2` = key missing). |
452
455
  | `take_ownership()` | Promote **the referenced object** to own itself (primary use case: a named reference to an anonymous nested object). On a reference to a named (already self-owned) object it is a correct no-op. The call itself must be made via a named reference handle. See [Lifecycle §7](./lifecycle.md#7-the-ownership-model). |
453
456
 
454
457
  **Locking methods** (opt-in, never called automatically by mutations)
@@ -480,7 +483,7 @@ Base class for every scalar object. A scalar stores a single encoded value in it
480
483
  - `value` getter decodes the stored value via the registered codec.
481
484
  - `value` setter validates the Python type against the registered codec, updates metadata, and refreshes TTL when `cache_mode="sliding"`.
482
485
 
483
- Built-in subclasses: [`IntObject`](#intobject), [`FloatObject`](#floatobject), [`StrObject`](#strobject), [`BoolObject`](#boolobject), [`DateTimeObject`](#datetimeobject), [`DateObject`](#dateobject), [`TimeObject`](#timeobject), [`NoneObject`](#noneobject), [`TupleObject`](#tupleobject).
486
+ Built-in subclasses: [`IntObject`](#intobject), [`FloatObject`](#floatobject), [`StrObject`](#strobject), [`BoolObject`](#boolobject), [`DateTimeObject`](#datetimeobject), [`DateObject`](#dateobject), [`TimeObject`](#timeobject), [`DecimalObject`](#decimalobject), [`NoneObject`](#noneobject), [`TupleObject`](#tupleobject).
484
487
 
485
488
  ---
486
489
 
@@ -526,6 +529,7 @@ All scalar classes inherit from [`GPDCScalarObject`](#gpdcscalarobject). Reading
526
529
  | [`DateTimeObject`](#datetimeobject) | `scalar:datetime` | `datetime.datetime` | `datetime:{isoformat}` |
527
530
  | [`DateObject`](#dateobject) | `scalar:date` | `datetime.date` (excludes `datetime`) | `date:{isoformat}` |
528
531
  | [`TimeObject`](#timeobject) | `scalar:time` | `datetime.time` | `time:{isoformat}` |
532
+ | [`DecimalObject`](#decimalobject) | `scalar:decimal` | `decimal.Decimal` | `decimal:{str(d)}` |
529
533
  | [`NoneObject`](#noneobject) | `scalar:none` | `NoneType` | `none:None` |
530
534
  | [`TupleObject`](#tupleobject) | `scalar:tuple` | `tuple` (scalar elements only) | `tuple:{json}` |
531
535
 
@@ -557,6 +561,14 @@ Wraps `datetime.date`. Note: a `datetime.datetime` is rejected by `DateCodec` ev
557
561
 
558
562
  Wraps `datetime.time`.
559
563
 
564
+ ### `DecimalObject`
565
+
566
+ Wraps `decimal.Decimal` (added in 0.2.1). Encoded as `decimal:{str(value)}`: the payload is `str(Decimal)`, which preserves the exact coefficient and exponent — `Decimal("1.10")` roundtrips as `1.10`, not `1.1`, and extreme exponents use scientific notation (`1E-27`). A malformed payload raises `ValueError` on decode.
567
+
568
+ - **Non-finite values** (`NaN`, `sNaN`, `±Infinity`) and `-0` are stored and restored as-is, matching the `float` codec's treatment of `nan`/`inf`. Comparison semantics follow the standard library: `NaN != NaN`, and comparing an `sNaN` with `==` raises `decimal.InvalidOperation`.
569
+ - **Exactness**: `str()` is context-independent — no rounding is applied on store or load. Construct from strings (`Decimal("0.1")`), not floats, if you mean the decimal literal; `Decimal(0.1)` faithfully stores the full binary expansion.
570
+ - **Cross-type equality**: GPDC matches dict keys and set members by their encoded wire form, so natively-equal values of different numeric types become distinct entries once stored — `d[1] = "a"` followed by `d[Decimal(1)] = "b"` yields two entries (the same already holds for `1` vs `1.0`). A native literal like `{1: "a", Decimal(1): "b"}` collapses to one key before GPDC ever sees it.
571
+
560
572
  ### `NoneObject`
561
573
 
562
574
  Wraps `None`. Used to represent the presence of a `None` value distinctly from a missing key.
@@ -654,7 +666,7 @@ Abstract base for scalar codecs. Provides the shared helper:
654
666
  |---|---|
655
667
  | `_parse_encoded(encoded) -> tuple[str, str]` | Split a `name:payload` encoded value into `(type_name, raw)`. Raises `ValueError` on empty or colon-less input. |
656
668
 
657
- Built-in subclasses: `BoolCodec`, `DateTimeCodec`, `DateCodec`, `DictCodec` *(see note)*, `FloatCodec`, `IntCodec`, `ListCodec` *(see note)*, `NoneCodec`, `SetCodec` *(see note)*, `StrCodec`, `TimeCodec`, `TupleCodec`.
669
+ Built-in subclasses: `BoolCodec`, `DateTimeCodec`, `DateCodec`, `DecimalCodec`, `DictCodec` *(see note)*, `FloatCodec`, `IntCodec`, `ListCodec` *(see note)*, `NoneCodec`, `SetCodec` *(see note)*, `StrCodec`, `TimeCodec`, `TupleCodec`.
658
670
 
659
671
  > Scalar codec classes for containers (`ListCodec`, `DictCodec`, `SetCodec`) are re-exported only for completeness — they actually inherit from [`GPDCContainerCodec`](#gpdccontainercodec).
660
672
 
@@ -761,7 +773,7 @@ All public exceptions descend from the public `GPDCError(Exception)` root. Use `
761
773
  class GPDCError(Exception)
762
774
  ```
763
775
 
764
- The root of the gpdatacached exception hierarchy. Every exception raised by this library is a subclass of `GPDCError`, so `except GPDCError` is the broad catch-all. Prefer catching the specific subclass when you need to distinguish failure modes.
776
+ The root of the gpdatacached exception hierarchy. Every gpdatacached-specific exception raised by this library is a subclass of `GPDCError`, so `except GPDCError` is the broad catch-all. (Plain stdlib errors — e.g. `TypeError` for unsupported types or an invalid argument order, `ValueError` for invalid payloads — are raised as-is.) Prefer catching the specific subclass when you need to distinguish failure modes.
765
777
 
766
778
  ### `GPDCLockError`
767
779
 
@@ -38,7 +38,7 @@ Two value families exist, with different contracts:
38
38
 
39
39
  ---
40
40
 
41
- ## 2. The Registration Triple
41
+ ## 2. The Registration Entry
42
42
 
43
43
  Registering a type always means pairing four things via one call:
44
44
 
@@ -94,9 +94,10 @@ graph TD
94
94
  C1["GPDCScalarCodec"]
95
95
  C2["GPDCContainerCodec (ABC)"]
96
96
  C0 -->|encode_value / decode_value| C1
97
- C0 -->|create_raw / read_raw / delete_raw| C2
98
97
  ```
99
98
 
99
+ Two **independent** roots: `GPDCCodec` (scalars) and `GPDCContainerCodec` (containers) are siblings — a container codec never implements `encode_value`/`decode_value`.
100
+
100
101
  ### `GPDCCodec` — the root ABC
101
102
 
102
103
  ```python
@@ -194,7 +194,7 @@ graph TD
194
194
 
195
195
  ---
196
196
 
197
- ## 7. The Ownership Model
197
+ ## 7. The Ownership Model { #7-the-ownership-model }
198
198
 
199
199
  Every object has an `owner` field in its metadata. Ownership determines who controls the object's lifecycle and is central to how anonymous objects work.
200
200
 
@@ -362,7 +362,7 @@ For reference, here is the complete set of user-facing lifecycle methods on `GPD
362
362
  | `take_ownership()` | Promote **the referenced object** to own itself (primary use case: a named reference to an anonymous nested object; a correct no-op on a reference to an already self-owned named object). |
363
363
  | `ttl` (property) | The declared TTL in seconds. |
364
364
  | `cache_mode` (property) | The current cache mode. |
365
- | `remaining_ttl()` | The actual remaining TTL reported by Redis (can be 0 or -1). |
365
+ | `remaining_ttl()` | The actual remaining TTL reported by Redis (0; -1 = no expiry; -2 = key already gone). |
366
366
  | `created_at` / `updated_at` (properties) | Timestamps from metadata. |
367
367
 
368
368
  Plus the domain-level `domain.run_gc(namespace=None)` for manual GC.
@@ -71,7 +71,7 @@ graph TD
71
71
  end
72
72
  subgraph L4["Layer 4 — Built-in types"]
73
73
  BREG["builtins.registry<br/>(auto-registered on import)"]
74
- SCALARS["scalar types<br/>int·float·str·bool·datetime·date·time·none·tuple"]
74
+ SCALARS["scalar types<br/>int·float·decimal·str·bool·datetime·date·time·none·tuple"]
75
75
  CONTAINERS["container types<br/>list·dict·set"]
76
76
  end
77
77
  subgraph L3["Layer 3 — Type system"]
@@ -92,7 +92,7 @@ graph TD
92
92
  EXC["exceptions"]
93
93
  end
94
94
  REDIS[("Redis")]
95
- L5 --> L4
95
+ L5 --> L3
96
96
  L4 --> L3
97
97
  L3 --> L2
98
98
  L3 --> L1
@@ -38,7 +38,7 @@ Two corollaries:
38
38
  |---|---|
39
39
  | **Row indexes** | `RangeIndex` (default int index, stored compactly) **and** any labelled index including `pd.MultiIndex` (stored one `collection:list` child per level). `.index_names` always returns a tuple. |
40
40
  | **DataFrame columns** | Single-level (scalar names) **or** `pd.MultiIndex` (tuple names). The row and column axes are independent and compose (a DataFrame may have both a row and a column MultiIndex). |
41
- | **Value dtypes** | `int64`/`Int64`, `float64`/`Float64`, `bool`/`boolean`, `str`/`string`, `object`, `datetime64` (naive & tz-aware), `category`. |
41
+ | **Value dtypes** | Integer / float / boolean dtypes of any width (e.g. `int64`/`Int64`, `float64`/`Float64`, `bool`/`boolean`), `str`/`string`, `object` (any scalar-encodable element — including `decimal.Decimal`, builtin since 0.2.1), `datetime64` (naive & tz-aware, any unit), `category`. |
42
42
  | **Mutation** | Row `extend` (Series & DataFrame); DataFrame column `__setitem__` / `__delitem__` (structural add/replace/drop). |
43
43
 
44
44
  Notes:
@@ -185,7 +185,7 @@ GPDC type `collection:pandas_dataframe`. Backed by a Redis hash with column valu
185
185
 
186
186
  | Method | Description |
187
187
  |---|---|
188
- | `extend(other)` | Append rows in place. Accepts a `pd.DataFrame`, a `dict` of lists, or a list of `dict` rows. For a genuine MultiIndex-row DataFrame (`L ≥ 2`), only a `DataFrame` with a matching MultiIndex is accepted (dict/list inputs synthesise positional labels a MultiIndex cannot supply → `TypeError`; use `.value`). Mutates Redis; returns `None`. |
188
+ | `extend(other)` | Append rows in place. Accepts a `pd.DataFrame`, a `dict` of lists, or a list of `dict` rows. For a genuine MultiIndex-row DataFrame (`L ≥ 2`), only a `DataFrame` with a matching MultiIndex is accepted (dict/list inputs synthesise positional labels a MultiIndex cannot supply → `TypeError`; use `.value`). Category columns reject values outside the stored categories (`ValueError`, raised before any Redis write). Mutates Redis; returns `None`. |
189
189
  | `deepcopy()` | Return a fully independent `pd.DataFrame` (`.value.copy(deep=True)`). |
190
190
 
191
191
  **Column-axis access (single-level columns)**
@@ -248,4 +248,4 @@ Raised when a DataFrame cannot be stored element-wise by `DataFrameObject`. The
248
248
  class UnsupportedElementTypeError(TypeError)
249
249
  ```
250
250
 
251
- Raised when a Series/DataFrame element is not encodable by a `GPDCScalarCodec` subclass — i.e. it is a container type (`list`/`dict`/`set`/container-registered pydantic model) or an unsupported type. Series/DataFrame elements must be scalar types encodable by a `GPDCScalarCodec` subclass. The message enumerates the allowed scalar types.
251
+ Raised when a Series/DataFrame element is not encodable by a `GPDCScalarCodec` subclass — i.e. it is a container type (`list`/`dict`/`set`/container-registered pydantic model) or an unsupported type. Series/DataFrame elements must be scalar types encodable by a `GPDCScalarCodec` subclass. The message enumerates the allowed scalar types. Allowed builtin scalars (0.2.1+): int/float/bool/str/datetime/date/time/decimal/tuple/None, plus any scalar-registered custom type; object-dtype columns and index labels may hold any of them — including `decimal.Decimal`.
@@ -36,7 +36,7 @@ A quick reference. "RTT" = Redis round-trip. N = number of elements; counts are
36
36
  | `ns[k]` (scalar read) | O(1), ~3 RTT | `_resolve_binding` (`TYPE` + `HGETALL`) **plus** the scalar `.value` getter re-loads meta (`HGETALL`). |
37
37
  | `ns[k] = [a, b, c]` (flat, scalar-element container) | O(N) local encode, **O(1)** data-write RTT | One `encode_session` + one bulk `create_raw` (single `RPUSH`/`HSET`/`SADD` carrying all elements) + `HGET` ×2 (namespace-default policy) + metadata `HSET` + TTL applies on the obj/raw/ref keys (≈ 8 RTT total, independent of N for scalar elements). |
38
38
  | `ns[k] = [[...], ...]` (nested container) | O(**nested-container count**) RTT + O(N) local | Each nested container becomes one anonymous object (meta `HSET` + its own `create_raw` + `HINCRBY` ref + TTL applies ≈ 5–6 RTT each). Depth does **not** multiply — it is the total node count that scales. |
39
- | `container.append(x)` / `container[k] = v` (scalar element, non-sliding) | O(1), ~5–6 RTT | `HGETALL` (policy) + data write (`RPUSH`/`HSET`) + `_touch` (`LLEN`/`HLEN` + `HGETALL` + `HSET`). **Add the sliding-subtree walk if `cache_mode="sliding"`** (§3); **add ~5–6 RTT if the element is itself a container** (anonymous-object creation). |
39
+ | `container.append(x)` / `container[k] = v` (scalar element, non-sliding) | O(1), ~5–6 RTT | `HGETALL` ×2 (liveness/policy + data-owner resolve) + data write (`RPUSH`/`HSET`) + `LLEN`/`HLEN` + metadata `HSET`. **Add the sliding-subtree walk if `cache_mode="sliding"`** (§3); **add ~5–6 RTT if the element is itself a container** (anonymous-object creation). |
40
40
  | `iter(ns)` / `len(ns)` | O(N), batched SCAN | A few RTT per ~100-key SCAN page (`len` delegates to `iter`). Don't put in a hot loop. |
41
41
  | `k in ns` | O(1), 1–4 RTT | Direct `TYPE` + `HGETALL` on the named key (1–2 RTT for owner bindings); references add a `GET` + another `TYPE` (up to 4 RTT for aliases). **Not** a SCAN. Does not scale with namespace size. |
42
42
  | `del ns[k]` (container owner) | O(owned subtree size) | `_teardown_owner_container` releases each nested ref (`HDEL`) and cascade-deletes orphaned anonymous children; ~1+ RTT per node. Raises `CanonicalObjectReferencedError` if live alias referrers exist — delete those first. |
@@ -48,7 +48,7 @@ A quick reference. "RTT" = Redis round-trip. N = number of elements; counts are
48
48
  | `.value` (full container read) | flat: 1 RTT + O(N) local; nested: +O(nested count) RTT | One bulk read (`LRANGE`/`HGETALL`/`SMEMBERS`); each `ref:` element adds one `HGETALL` + decode for its anonymous object. |
49
49
  | `name in cache` (`GPDataCache`) | **local, 0 RTT** | Pure config-snapshot check via `folder.list_configs()`. Does not touch Redis and does not construct a domain. The cheapest membership test in the library. |
50
50
  | `cache.get_domain(name)` | 1st call: domain construction (domain-meta read/verify); subsequent: **0 RTT** | First access constructs and caches the `GPDCDomain` via gpconfig's factory; later calls return the cached instance. Does not re-read config files. |
51
- | `cache.get_object_by_uri(uri)` | root: ~4–5 RTT; **+1 RTT per path segment** | `get_namespace` (`SISMEMBER` + namespace-meta `HGETALL`, ~2 RTT) + root resolve (`TYPE` + `HGETALL`, ~2–3 RTT). Each extra path segment is one `__getitem__` read (`LINDEX` for `ListObject`, `HGET` for `DictObject`). `cache.has_uri(uri)` has the same cost (it calls `get_object_by_uri` under a try/except). The domain is only constructed on the first access; later URIs in the same domain reuse the cached domain. |
51
+ | `cache.get_object_by_uri(uri)` | root: ~4–5 RTT; **+1–2 RTT per path segment** | `get_namespace` (`SISMEMBER` + namespace-meta `HGETALL` + idempotent `SADD`, ~3 RTT) + root resolve (`TYPE` + `HGETALL`, ~2–3 RTT). Each extra path segment is one `__getitem__` read (ListObject: `LLEN` + `LINDEX` — 2 RTT; DictObject: `HGET` — 1 RTT). `cache.has_uri(uri)` has the same cost (it calls `get_object_by_uri` under a try/except). The domain is only constructed on the first access; later URIs in the same domain reuse the cached domain. |
52
52
 
53
53
  ---
54
54
 
@@ -134,7 +134,7 @@ Stores the entire model as a single JSON-encoded Redis string. Reads return a fr
134
134
 
135
135
  The whole object is then stored as `{type_prefix}:{json}`, where `type_prefix` is the part of the GPDC type after `scalar:`.
136
136
 
137
- **Field type restriction.** Every field must be encodable by a `GPDCScalarCodec` subclass (int / float / bool / str / datetime / date / time / tuple / None / any scalar-registered custom type). Container field types (`list`, `dict`, `set`, container-registered pydantic models) raise `TypeError` at encode time — use container mode instead.
137
+ **Field type restriction.** Every field must be encodable by a `GPDCScalarCodec` subclass (int / float / bool / str / datetime / date / time / decimal / tuple / None / any scalar-registered custom type). Container field types (`list`, `dict`, `set`, container-registered pydantic models) raise `TypeError` at encode time — use container mode instead.
138
138
 
139
139
  ### `PydanticModelScalarCodec`
140
140
 
@@ -391,12 +391,14 @@ class GPDCDataObject(name, namespace, *, canonical_name=None) # ABC
391
391
  |---|---|---|
392
392
  | `type` | `str` | GPDC 类型字符串,如 `"scalar:int"`、`"collection:list"`。 |
393
393
  | `meta` | `ObjectMeta \| None` | 完整元信息记录;对象已消失时为 `None`。 |
394
- | `cache_mode` | `str` | 生效的缓存模式;不存在则抛 `ObjectGoneError`。 |
394
+ | `cache_mode` | `str` | 生效的缓存模式。 |
395
395
  | `ttl` | `int` | 声明的 TTL(秒),`0` 表示永久。 |
396
396
  | `created_at` | `datetime \| None` | 创建时间戳。 |
397
397
  | `updated_at` | `datetime \| None` | 最近更新时间戳。 |
398
398
  | `value` | `object` | Python 值(抽象;由子类实现)。 |
399
399
 
400
+ > 元信息支撑的属性(`cache_mode`、`ttl`、`created_at`、`updated_at`)在对象元信息已不存在时都会抛出 `ObjectGoneError`。
401
+
400
402
  **生命周期方法**
401
403
 
402
404
  > 本节是 API 摘要。完整的模型 —— 策略来源、滑动刷新、所有权、匿名/引用对象的生命周期与陷阱 —— 见[生命周期管理](./lifecycle.md)。
@@ -409,6 +411,7 @@ class GPDCDataObject(name, namespace, *, canonical_name=None) # ABC
409
411
  | `persist()` | 切换为 `cache_mode="permanent"` 且 `ttl=0`。需要 ownership。 |
410
412
  | `update_cache_mode_and_ttl(cache_mode, ttl)` | 替换整个缓存策略。需要 ownership。 |
411
413
  | `refresh_ttl()` | 将对象自身的 TTL 重新应用到其 key,并级联刷新其拥有的子树(用于滑动窗口刷新路径)。永久对象为空操作。 |
414
+ | `remaining_ttl()` | canonical key 上的剩余 TTL(秒),直接来自 Redis `TTL` 命令(`-1` = 无过期,`-2` = key 不存在)。 |
412
415
  | `take_ownership()` | 把**被引用对象**提升为自持(主要用例:指向匿名嵌套对象的命名引用)。对命名(已自持)对象的引用是正确的 no-op。调用本身必须经由命名引用句柄发起。见[生命周期 §7](./lifecycle.md#7-the-ownership-model)。 |
413
416
 
414
417
  **加锁方法**(可选使用,变更操作永远不会自动调用)
@@ -440,7 +443,7 @@ class GPDCScalarObject(name, namespace, *, canonical_name=None) # GPDCDataObje
440
443
  - `value` 的 getter 通过已注册的 codec 解码存储的值。
441
444
  - `value` 的 setter 会校验 Python 类型是否与已注册 codec 匹配,更新元信息,并在 `cache_mode="sliding"` 时刷新 TTL。
442
445
 
443
- 内建子类:[`IntObject`](#intobject)、[`FloatObject`](#floatobject)、[`StrObject`](#strobject)、[`BoolObject`](#boolobject)、[`DateTimeObject`](#datetimeobject)、[`DateObject`](#dateobject)、[`TimeObject`](#timeobject)、[`NoneObject`](#noneobject)、[`TupleObject`](#tupleobject)。
446
+ 内建子类:[`IntObject`](#intobject)、[`FloatObject`](#floatobject)、[`StrObject`](#strobject)、[`BoolObject`](#boolobject)、[`DateTimeObject`](#datetimeobject)、[`DateObject`](#dateobject)、[`TimeObject`](#timeobject)、[`DecimalObject`](#decimalobject)、[`NoneObject`](#noneobject)、[`TupleObject`](#tupleobject)。
444
447
 
445
448
  ---
446
449
 
@@ -486,6 +489,7 @@ class GPDCContainerObject(name, namespace, *, canonical_name=None) # GPDCDataO
486
489
  | [`DateTimeObject`](#datetimeobject) | `scalar:datetime` | `datetime.datetime` | `datetime:{isoformat}` |
487
490
  | [`DateObject`](#dateobject) | `scalar:date` | `datetime.date`(不含 `datetime`) | `date:{isoformat}` |
488
491
  | [`TimeObject`](#timeobject) | `scalar:time` | `datetime.time` | `time:{isoformat}` |
492
+ | [`DecimalObject`](#decimalobject) | `scalar:decimal` | `decimal.Decimal` | `decimal:{str(d)}` |
489
493
  | [`NoneObject`](#noneobject) | `scalar:none` | `NoneType` | `none:None` |
490
494
  | [`TupleObject`](#tupleobject) | `scalar:tuple` | `tuple`(仅标量元素) | `tuple:{json}` |
491
495
 
@@ -517,6 +521,14 @@ class GPDCContainerObject(name, namespace, *, canonical_name=None) # GPDCDataO
517
521
 
518
522
  封装 `datetime.time`。
519
523
 
524
+ ### `DecimalObject`
525
+
526
+ 封装 `decimal.Decimal`(0.2.1 新增)。编码为 `decimal:{str(value)}`:payload 为 `str(Decimal)`,精确保留系数与指数 —— `Decimal("1.10")` 往返后仍是 `1.10` 而非 `1.1`,极大/极小指数使用科学计数法(`1E-27`)。解码遇到非法 payload 抛出 `ValueError`。
527
+
528
+ - **非有限值**(`NaN`、`sNaN`、`±Infinity`)与 `-0` 原样存取,与 `float` codec 对 `nan`/`inf` 的处理一致。比较语义遵循标准库:`NaN != NaN`,对 `sNaN` 做 `==` 比较会抛出 `decimal.InvalidOperation`。
529
+ - **精确性**:`str()` 与上下文无关 —— 存取过程不做任何舍入。若想表达十进制字面量,请用字符串构造(`Decimal("0.1")`);`Decimal(0.1)` 会忠实存储完整的二进制展开。
530
+ - **跨类型相等性**:GPDC 按 wire 编码形式匹配字典键与集合成员,因此原生命中相等的不同数值类型在存储后是不同条目 —— 先 `d[1] = "a"` 再 `d[Decimal(1)] = "b"` 会得到两个条目(`1` 与 `1.0` 之间早就是如此)。而原生字面量 `{1: "a", Decimal(1): "b"}` 在 GPDC 接触之前就已坍缩为一个键。
531
+
520
532
  ### `NoneObject`
521
533
 
522
534
  封装 `None`。用于将“值为 `None`”与“键不存在”区分开。
@@ -614,7 +626,7 @@ class GPDCScalarCodec(GPDCCodec)
614
626
  |---|---|
615
627
  | `_parse_encoded(encoded) -> tuple[str, str]` | 将 `name:payload` 形式的编码值拆分为 `(type_name, raw)`。输入为空或不含冒号时抛 `ValueError`。 |
616
628
 
617
- 内建子类:`BoolCodec`、`DateTimeCodec`、`DateCodec`、`DictCodec` *(见下注)*、`FloatCodec`、`IntCodec`、`ListCodec` *(见下注)*、`NoneCodec`、`SetCodec` *(见下注)*、`StrCodec`、`TimeCodec`、`TupleCodec`。
629
+ 内建子类:`BoolCodec`、`DateTimeCodec`、`DateCodec`、`DecimalCodec`、`DictCodec` *(见下注)*、`FloatCodec`、`IntCodec`、`ListCodec` *(见下注)*、`NoneCodec`、`SetCodec` *(见下注)*、`StrCodec`、`TimeCodec`、`TupleCodec`。
618
630
 
619
631
  > 容器的标量 codec 类(`ListCodec`、`DictCodec`、`SetCodec`)被再次导出仅为完整性 —— 它们实际上继承自 [`GPDCContainerCodec`](#gpdccontainercodec)。
620
632
 
@@ -719,7 +731,7 @@ def validate_object_name(name: str) -> None
719
731
  class GPDCError(Exception)
720
732
  ```
721
733
 
722
- gpdatacached 异常层次的根基类。本库抛出的每一个异常都是 `GPDCError` 的子类,因此 `except GPDCError` 是最宽泛的全捕获。当你需要区分不同失败模式时,优先捕获具体的子类。
734
+ gpdatacached 异常层次的根基类。本库抛出的每一个自定义异常都是 `GPDCError` 的子类,因此 `except GPDCError` 是最宽泛的全捕获。(普通的标准库异常 —— 如类型不支持或参数顺序错误抛出的 `TypeError`、非法 payload 抛出的 `ValueError` —— 按原样抛出。)当你需要区分不同失败模式时,优先捕获具体的子类。
723
735
 
724
736
  ### `GPDCLockError`
725
737
 
@@ -38,7 +38,7 @@ graph LR
38
38
 
39
39
  ---
40
40
 
41
- ## 2. 注册三元组
41
+ ## 2. 注册条目
42
42
 
43
43
  注册一个类型,永远是通过一次调用把四样东西配对:
44
44
 
@@ -94,9 +94,10 @@ graph TD
94
94
  C1["GPDCScalarCodec"]
95
95
  C2["GPDCContainerCodec (ABC)"]
96
96
  C0 -->|encode_value / decode_value| C1
97
- C0 -->|create_raw / read_raw / delete_raw| C2
98
97
  ```
99
98
 
99
+ 两个**相互独立**的根:`GPDCCodec`(标量)与 `GPDCContainerCodec`(容器)是兄弟关系 —— 容器 codec 永远不实现 `encode_value`/`decode_value`。
100
+
100
101
  ### `GPDCCodec` —— 根 ABC
101
102
 
102
103
  ```python
@@ -194,7 +194,7 @@ graph TD
194
194
 
195
195
  ---
196
196
 
197
- ## 7. The Ownership Model
197
+ ## 7. 所有权模型 { #7-the-ownership-model }
198
198
 
199
199
  每个对象在元信息中有一个 `owner` 字段。所有权决定谁能控制对象的生命周期,也是匿名对象运作的核心。
200
200
 
@@ -307,7 +307,7 @@ ns["alias"] = ns["original"] # 两个名字现在都解析到同一份 canonic
307
307
  | 别名(`del ns["alias"]`) | 移除 `obj:alias` 字符串 + canonical owner 引用计数减一。若 canonical owner 是匿名的且这是其最后一个引用者,则级联删除该匿名对象。 |
308
308
  | 在存在别名时删除 canonical owner(`del ns["original"]`) | **抛出 `CanonicalObjectReferencedError`。** 你必须先删除别名。 |
309
309
 
310
- 这种保护是有意为之:静默删除其他名字仍指向的数据会是个陷阱。错误的 `.referrers` 字段会告诉你需要清理哪些绑定。
310
+ 这种保护是有意为之:静默删除其他名字仍指向的数据会是个陷阱。该异常的 `.referrers` 字段会告诉你需要清理哪些绑定。
311
311
 
312
312
  ### 引用绑定自身的生命周期
313
313
 
@@ -362,7 +362,7 @@ domain.run_gc("analytics") # 单个 namespace
362
362
  | `take_ownership()` | 把**被引用对象**提升为自持(主要用例:指向匿名嵌套对象的命名引用;对已自持的命名对象的引用是正确的 no-op)。 |
363
363
  | `ttl`(属性) | 声明的 TTL(秒)。 |
364
364
  | `cache_mode`(属性) | 当前缓存模式。 |
365
- | `remaining_ttl()` | Redis 报告的实际剩余 TTL(可为 0 或 -1)。 |
365
+ | `remaining_ttl()` | Redis 报告的实际剩余 TTL(0;-1 = 无过期;-2 = key 已不存在)。 |
366
366
  | `created_at` / `updated_at`(属性) | 元信息中的时间戳。 |
367
367
 
368
368
  外加 domain 级的 `domain.run_gc(namespace=None)`,用于手动触发 GC。
@@ -71,7 +71,7 @@ graph TD
71
71
  end
72
72
  subgraph L4["第 4 层 —— 内建类型"]
73
73
  BREG["builtins.registry<br/>(导入时自动注册)"]
74
- SCALARS["标量类型<br/>int·float·str·bool·datetime·date·time·none·tuple"]
74
+ SCALARS["标量类型<br/>int·float·decimal·str·bool·datetime·date·time·none·tuple"]
75
75
  CONTAINERS["容器类型<br/>list·dict·set"]
76
76
  end
77
77
  subgraph L3["第 3 层 —— 类型系统"]
@@ -92,7 +92,7 @@ graph TD
92
92
  EXC["exceptions"]
93
93
  end
94
94
  REDIS[("Redis")]
95
- L5 --> L4
95
+ L5 --> L3
96
96
  L4 --> L3
97
97
  L3 --> L2
98
98
  L3 --> L1
@@ -38,7 +38,7 @@ pip install "gpdatacached[pandas]"
38
38
  |---|---|
39
39
  | **行索引** | `RangeIndex`(默认整数索引,紧凑存储)**以及**任意带标签索引,包括 `pd.MultiIndex`(按每个 level 存一个 `collection:list` 子对象)。`.index_names` 始终返回元组。 |
40
40
  | **DataFrame 列** | 单层(标量名称)**或** `pd.MultiIndex`(元组名称)。行轴与列轴相互独立,可组合(DataFrame 可同时拥有行 MultiIndex 与列 MultiIndex)。 |
41
- | **值 dtype** | `int64`/`Int64`、`float64`/`Float64`、`bool`/`boolean`、`str`/`string`、`object`、`datetime64`(naive 与 tz-aware)、`category`。 |
41
+ | **值 dtype** | 任意宽度的整数/浮点/布尔 dtype(如 `int64`/`Int64`、`float64`/`Float64`、`bool`/`boolean`)、`str`/`string`、`object`(任何可标量编码的元素 —— 含 0.2.1 起内建的 `decimal.Decimal`)、`datetime64`(naive 与 tz-aware,任意单位)、`category`。 |
42
42
  | **变更** | 行 `extend`(Series 与 DataFrame);DataFrame 的列 `__setitem__` / `__delitem__`(结构性的新增/替换/删除)。 |
43
43
 
44
44
  说明:
@@ -114,7 +114,7 @@ domain.close()
114
114
  | `.loc[key]` | O(N),选择性 | 深度 k 的前缀标签扫描;仅解码匹配的行,但会读取所有索引 level —— **不要放进热循环** |
115
115
  | `df[col]` / `df[tuple]` | O(N),单列 | (DataFrame)单列,返回 `pd.Series` |
116
116
  | `df[level0]` / `df[list]` | O(N_cols) + O(匹配数·N) | 列轴选择(成本低;列数少) |
117
- | `.value` | **完整还原**(读取所有列 + 所有索引 level,重建整个 frame —— 尽管同属 `O(N)` 量级,但远比选择性 `.loc` 重的多) | 整个 `pd.Series` / `pd.DataFrame`;最终退路 |
117
+ | `.value` | **完整还原**(读取所有列 + 所有索引 level,重建整个 frame —— 尽管同属 `O(N)` 量级,但远比选择性 `.loc` 重得多) | 整个 `pd.Series` / `pd.DataFrame`;最终退路 |
118
118
 
119
119
  **决策规则:**
120
120
 
@@ -185,7 +185,7 @@ GPDC 类型 `collection:pandas_dataframe`。底层是一个 Redis hash:列值
185
185
 
186
186
  | 方法 | 说明 |
187
187
  |---|---|
188
- | `extend(other)` | 原地追加行。接受 `pd.DataFrame`、list 的 dict,或 dict 行的列表。对于真正的 MultiIndex 行 DataFrame(`L ≥ 2`),只接受带匹配 MultiIndex 的 `DataFrame`(dict/list 输入会合成 MultiIndex 无法提供的位置标签 → `TypeError`;请使用 `.value`)。修改 Redis;返回 `None`。 |
188
+ | `extend(other)` | 原地追加行。接受 `pd.DataFrame`、list 的 dict,或 dict 行的列表。对于真正的 MultiIndex 行 DataFrame(`L ≥ 2`),只接受带匹配 MultiIndex 的 `DataFrame`(dict/list 输入会合成 MultiIndex 无法提供的位置标签 → `TypeError`;请使用 `.value`)。category 列会拒绝不在既有分类中的值(`ValueError`,发生在任何 Redis 写入之前)。修改 Redis;返回 `None`。 |
189
189
  | `deepcopy()` | 返回完全独立的 `pd.DataFrame`(`.value.copy(deep=True)`)。 |
190
190
 
191
191
  **列轴访问(单层列)**
@@ -195,7 +195,7 @@ GPDC 类型 `collection:pandas_dataframe`。底层是一个 Redis hash:列值
195
195
  | `df[col]` | 选取单列,返回 `pd.Series`。 |
196
196
  | `df[list_of_keys]` | 由所选列组成的子 `pd.DataFrame`(即便只有一个 key 也始终返回子 DataFrame)。 |
197
197
  | `df[col] = values` | 新增或替换列。`len(values)` 必须等于当前行数。对空 DataFrame 通过新增列来引入行会被拒绝。 |
198
- | `del df[col]` | 删除列。共享的索引会被保留(即便删到最后一张列)。 |
198
+ | `del df[col]` | 删除列。共享的索引会被保留(即便删到最后一列)。 |
199
199
  | `col in df` | 列存在时为 `True`。 |
200
200
  | `iter(df)` | 遍历列名。 |
201
201
 
@@ -248,4 +248,4 @@ class UnsupportedDataFrameError(TypeError)
248
248
  class UnsupportedElementTypeError(TypeError)
249
249
  ```
250
250
 
251
- 当某个 Series/DataFrame 元素无法被 `GPDCScalarCodec` 子类编码时抛出 —— 即它是容器类型(`list`/`dict`/`set`/容器注册的 pydantic 模型)或不支持的类型。Series/DataFrame 元素必须是能被 `GPDCScalarCodec` 子类编码的标量类型。错误信息会列举允许的标量类型。
251
+ 当某个 Series/DataFrame 元素无法被 `GPDCScalarCodec` 子类编码时抛出 —— 即它是容器类型(`list`/`dict`/`set`/容器注册的 pydantic 模型)或不支持的类型。Series/DataFrame 元素必须是能被 `GPDCScalarCodec` 子类编码的标量类型。错误信息会列举允许的标量类型。允许的内建标量(0.2.1+):int/float/bool/str/datetime/date/time/decimal/tuple/None,外加任意标量注册的自定义类型;object 列与索引标签可持有其中任何一种 —— 包括 `decimal.Decimal`。
@@ -36,7 +36,7 @@ graph TD
36
36
  | `ns[k]`(标量读) | O(1),约 3 RTT | `_resolve_binding`(`TYPE` + `HGETALL`)**再加**标量 `.value` getter 重新加载 meta(`HGETALL`)。 |
37
37
  | `ns[k] = [a, b, c]`(扁平、元素为标量的容器) | O(N) 本地编码,数据写入 **O(1)** RTT | 一次 `encode_session` + 一次批量 `create_raw`(单条 `RPUSH`/`HSET`/`SADD` 携带全部元素)+ `HGET` ×2(namespace 默认策略)+ 元信息 `HSET` + 对 obj/raw/ref key 的 TTL 应用(共约 8 RTT,与 N 无关,对标量元素成立)。 |
38
38
  | `ns[k] = [[...], ...]`(嵌套容器) | O(**嵌套容器数量**) RTT + O(N) 本地 | 每个嵌套容器成为一个匿名对象(meta `HSET` + 自身 `create_raw` + `HINCRBY` 引用 + TTL 应用,每个约 5–6 RTT)。**深度不乘** —— 决定规模的是总节点数。 |
39
- | `container.append(x)` / `container[k] = v`(标量元素、非 sliding) | O(1),约 5–6 RTT | `HGETALL`(取策略)+ 数据写入(`RPUSH`/`HSET`)+ `_touch`(`LLEN`/`HLEN` + `HGETALL` + `HSET`)。**若 `cache_mode="sliding"` 再加子树遍历**(§3);**若元素本身是容器,再加约 5–6 RTT**(创建匿名对象)。 |
39
+ | `container.append(x)` / `container[k] = v`(标量元素、非 sliding) | O(1),约 5–6 RTT | `HGETALL` ×2(存活/策略检查 + data-owner 解析)+ 数据写入(`RPUSH`/`HSET`)+ `LLEN`/`HLEN` + 元信息 `HSET`。**若 `cache_mode="sliding"` 再加子树遍历**(§3);**若元素本身是容器,再加约 5–6 RTT**(创建匿名对象)。 |
40
40
  | `iter(ns)` / `len(ns)` | O(N),批量 SCAN | 每 ~100 个 key 一页若干 RTT(`len` 委托给 `iter`)。不要放进热循环。 |
41
41
  | `k in ns` | O(1),1–4 RTT | 直接对该具名 key 做 `TYPE` + `HGETALL`(owner 绑定 1–2 RTT);引用会再加一次 `GET` + 另一次 `TYPE`(别名最多 4 RTT)。**不是** SCAN。不随 namespace 大小而增长。 |
42
42
  | `del ns[k]`(容器 owner) | O(所属子树大小) | `_teardown_owner_container` 释放每个嵌套引用(`HDEL`)并级联删除成为孤儿的匿名子对象;每节点约 1+ RTT。若仍存在活跃的别名引用者则抛 `CanonicalObjectReferencedError` —— 先删别名。 |
@@ -48,7 +48,7 @@ graph TD
48
48
  | `.value`(完整容器读取) | 扁平:1 RTT + O(N) 本地;嵌套:+O(嵌套数) RTT | 一次批量读(`LRANGE`/`HGETALL`/`SMEMBERS`);每个 `ref:` 元素再加一次 `HGETALL` + 解码其匿名对象。 |
49
49
  | `name in cache`(`GPDataCache`) | **本地,0 RTT** | 纯配置快照检查,通过 `folder.list_configs()` 实现。不触碰 Redis,也不会构造 domain。是本库中最便宜的成员测试。 |
50
50
  | `cache.get_domain(name)` | 首次调用:domain 构造(读/校验 domain meta);后续:**0 RTT** | 首次访问时经由 gpconfig 的工厂构造并缓存 `GPDCDomain`;之后的调用返回已缓存实例。不会重新读取配置文件。 |
51
- | `cache.get_object_by_uri(uri)` | 根:约 4–5 RTT;**每条路径分段 +1 RTT** | `get_namespace`(`SISMEMBER` + namespace-meta `HGETALL`,约 2 RTT)+ 根对象解析(`TYPE` + `HGETALL`,约 2–3 RTT)。每条额外路径分段是一次 `__getitem__` 读取(`ListObject` 用 `LINDEX`,`DictObject` 用 `HGET`)。`cache.has_uri(uri)` 成本相同(它用 try/except 调用 `get_object_by_uri`)。domain 仅在首次访问时构造;同一 domain 之后的 URI 会复用已缓存的 domain。 |
51
+ | `cache.get_object_by_uri(uri)` | 根:约 4–5 RTT;**每条路径分段 +1–2 RTT** | `get_namespace`(`SISMEMBER` + namespace-meta `HGETALL` + 幂等 `SADD`,约 3 RTT)+ 根对象解析(`TYPE` + `HGETALL`,约 2–3 RTT)。每条额外路径分段是一次 `__getitem__` 读取(ListObject:`LLEN` + `LINDEX` 共 2 RTT;DictObject:`HGET` 1 RTT)。`cache.has_uri(uri)` 成本相同(它用 try/except 调用 `get_object_by_uri`)。domain 仅在首次访问时构造;同一 domain 之后的 URI 会复用已缓存的 domain。 |
52
52
 
53
53
  ---
54
54
 
@@ -134,7 +134,7 @@ domain.close()
134
134
 
135
135
  整个对象随后以 `{type_prefix}:{json}` 的形式存储,其中 `type_prefix` 是 GPDC 类型中 `scalar:` 之后的部分。
136
136
 
137
- **字段类型限制。** 每个字段都必须能被某个 `GPDCScalarCodec` 子类编码(int / float / bool / str / datetime / date / time / tuple / None / 任意标量注册的自定义类型)。容器字段类型(`list`、`dict`、`set`、容器注册的 pydantic 模型)在编码时会抛出 `TypeError` —— 请改用容器模式。
137
+ **字段类型限制。** 每个字段都必须能被某个 `GPDCScalarCodec` 子类编码(int / float / bool / str / datetime / date / time / decimal / tuple / None / 任意标量注册的自定义类型)。容器字段类型(`list`、`dict`、`set`、容器注册的 pydantic 模型)在编码时会抛出 `TypeError` —— 请改用容器模式。
138
138
 
139
139
  ### `PydanticModelScalarCodec`
140
140
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Software library version (SemVer), tracks feature releases and bugfixes.
4
4
  # Independent of GPDC_PROTOCOL_VERSION in domain.py.
5
- __version__ = "0.2.0"
5
+ __version__ = "0.2.1"
6
6
 
7
7
  from .codec import GPDCCodec, GPDCContainerCodec, GPDCScalarCodec
8
8
  from .config import GPDCDomainConfig, GPDCNamespaceConfig
@@ -26,6 +26,7 @@ from .container import GPDCContainerObject
26
26
  from .data_cache import GPDataCache
27
27
  from .builtins.bool_object import BoolObject
28
28
  from .builtins.datetime_object import DateObject, DateTimeObject, TimeObject
29
+ from .builtins.decimal_object import DecimalObject
29
30
  from .builtins.dict_object import DictObject
30
31
  from .builtins.float_object import FloatObject
31
32
  from .builtins.int_object import IntObject
@@ -45,6 +46,7 @@ _GPConfigManager.register_config_class(GPDCDomainConfig)
45
46
 
46
47
  from .builtins.bool_object import BoolCodec as BoolCodec # noqa: F401,E402
47
48
  from .builtins.datetime_object import DateCodec as DateCodec, DateTimeCodec as DateTimeCodec, TimeCodec as TimeCodec # noqa: F401,E402
49
+ from .builtins.decimal_object import DecimalCodec as DecimalCodec # noqa: F401,E402
48
50
  from .builtins.dict_object import DictCodec as DictCodec # noqa: F401,E402
49
51
  from .builtins.float_object import FloatCodec as FloatCodec # noqa: F401,E402
50
52
  from .builtins.int_object import IntCodec as IntCodec # noqa: F401,E402
@@ -78,6 +80,7 @@ __all__ = [
78
80
  "DateTimeObject",
79
81
  "DateObject",
80
82
  "TimeObject",
83
+ "DecimalObject",
81
84
  "NoneObject",
82
85
  "ListObject",
83
86
  "DictObject",
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from decimal import Decimal, InvalidOperation
4
+
5
+ from ..codec import GPDCScalarCodec
6
+ from ..scalar import GPDCScalarObject
7
+
8
+
9
+ class DecimalCodec(GPDCScalarCodec):
10
+ """Lossless decimal.Decimal codec: payload is ``str(value)``.
11
+
12
+ ``str(Decimal)`` preserves coefficient/exponent exactly (``Decimal("1.10")``
13
+ stays ``1.10``, extreme exponents use scientific notation), and
14
+ ``Decimal(raw)`` restores it byte-for-byte. Non-finite values (NaN, sNaN,
15
+ ±Infinity) and ``-0`` roundtrip too — same policy as float's nan/inf.
16
+ """
17
+
18
+ @classmethod
19
+ def encode_value(cls, value: object) -> str:
20
+ if not isinstance(value, Decimal):
21
+ raise TypeError(f"DecimalCodec cannot encode {type(value).__name__!r}")
22
+ return f"decimal:{value}"
23
+
24
+ @classmethod
25
+ def decode_value(cls, encoded: str) -> object:
26
+ type_name, raw = cls._parse_encoded(encoded)
27
+ if type_name != "decimal":
28
+ raise ValueError(f"DecimalCodec cannot decode type {type_name!r}")
29
+ try:
30
+ return Decimal(raw)
31
+ except InvalidOperation:
32
+ # Decimal("garbage") raises decimal.InvalidOperation (an
33
+ # ArithmeticError), not ValueError like int()/float() — normalise
34
+ # to the error type every other scalar codec raises.
35
+ raise ValueError(f"Invalid scalar value for type 'decimal': {raw!r}")
36
+
37
+
38
+ class DecimalObject(GPDCScalarObject):
39
+ TYPE = "scalar:decimal"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import datetime
4
+ from decimal import Decimal
4
5
 
5
6
  from ..types import GPDCTypeRegistry
6
7
  from .bool_object import BoolCodec, BoolObject
@@ -12,6 +13,7 @@ from .datetime_object import (
12
13
  TimeCodec,
13
14
  TimeObject,
14
15
  )
16
+ from .decimal_object import DecimalCodec, DecimalObject
15
17
  from .dict_object import DictCodec, DictObject
16
18
  from .float_object import FloatCodec, FloatObject
17
19
  from .int_object import IntCodec, IntObject
@@ -51,6 +53,15 @@ def _register_builtin_types() -> None:
51
53
  codec_class=FloatCodec,
52
54
  priority=0,
53
55
  )
56
+ # Decimal is not an int/float/str subclass, so the default priority
57
+ # cannot be shadowed by (or shadow) another builtin entry.
58
+ GPDCTypeRegistry.register(
59
+ python_type=Decimal,
60
+ gpdc_type="scalar:decimal",
61
+ object_class=DecimalObject,
62
+ codec_class=DecimalCodec,
63
+ priority=0,
64
+ )
54
65
  GPDCTypeRegistry.register(
55
66
  python_type=str,
56
67
  gpdc_type="scalar:str",
@@ -106,7 +106,7 @@ class UnsupportedElementTypeError(TypeError):
106
106
 
107
107
  _GUIDANCE = (
108
108
  "\nSeries/DataFrame elements must be scalar types encodable by a "
109
- "GPDCScalarCodec subclass (int/float/bool/str/datetime/date/time/tuple/None, or a "
109
+ "GPDCScalarCodec subclass (int/float/bool/str/datetime/date/time/decimal/tuple/None, or a "
110
110
  "scalar-registered custom type). Container types are not allowed as elements."
111
111
  )
112
112
 
@@ -37,7 +37,7 @@ def make_raw_key(domain: str, namespace: str, name: str) -> str:
37
37
 
38
38
 
39
39
  def make_ref_key(domain: str, namespace: str, name: str) -> str:
40
- """Return the Redis key for reference tracking (Redis Set)."""
40
+ """Return the Redis key for reference tracking (Redis hash)."""
41
41
  return f"gpdc:{domain}:{namespace}:ref:{name}"
42
42
 
43
43
 
File without changes
File without changes