gpdatacached 0.2.1__tar.gz → 0.3.0__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.1 → gpdatacached-0.3.0}/.gitignore +3 -0
  2. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/CHANGELOG.md +53 -0
  3. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/PKG-INFO +2 -2
  4. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/en/api.md +47 -12
  5. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/zh/api.md +23 -6
  6. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/pyproject.toml +1 -1
  7. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/__init__.py +12 -2
  8. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/data_cache.py +114 -28
  9. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/domain.py +16 -0
  10. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/keys.py +29 -0
  11. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/LICENSE +0 -0
  12. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/README.md +0 -0
  13. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/README.zh-CN.md +0 -0
  14. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/en/extensibility.md +0 -0
  15. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/en/index.md +0 -0
  16. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/en/lifecycle.md +0 -0
  17. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/en/locking.md +0 -0
  18. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/en/overview.md +0 -0
  19. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/en/pandas-support.md +0 -0
  20. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/en/performance-guide.md +0 -0
  21. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/en/pydantic-support.md +0 -0
  22. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/zh/extensibility.md +0 -0
  23. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/zh/index.md +0 -0
  24. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/zh/lifecycle.md +0 -0
  25. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/zh/locking.md +0 -0
  26. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/zh/overview.md +0 -0
  27. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/zh/pandas-support.md +0 -0
  28. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/zh/performance-guide.md +0 -0
  29. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/docs/zh/pydantic-support.md +0 -0
  30. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/__init__.py +0 -0
  31. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/bool_object.py +0 -0
  32. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/datetime_object.py +0 -0
  33. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/decimal_object.py +0 -0
  34. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/dict_object.py +0 -0
  35. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/float_object.py +0 -0
  36. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/int_object.py +0 -0
  37. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/list_object.py +0 -0
  38. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/none_object.py +0 -0
  39. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/registry.py +0 -0
  40. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/set_object.py +0 -0
  41. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/str_object.py +0 -0
  42. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/builtins/tuple_object.py +0 -0
  43. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/codec.py +0 -0
  44. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/config.py +0 -0
  45. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/container.py +0 -0
  46. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/exceptions.py +0 -0
  47. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/extensions/__init__.py +0 -0
  48. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/extensions/pandas_support.py +0 -0
  49. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/extensions/pydantic_support.py +0 -0
  50. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/gc.py +0 -0
  51. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/lifecycle.py +0 -0
  52. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/locking.py +0 -0
  53. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/meta.py +0 -0
  54. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/namespace.py +0 -0
  55. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/object.py +0 -0
  56. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/refset.py +0 -0
  57. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/scalar.py +0 -0
  58. {gpdatacached-0.2.1 → gpdatacached-0.3.0}/src/gpdatacached/types.py +0 -0
@@ -192,6 +192,9 @@ __marimo__/
192
192
  draft/
193
193
  dev_docs/
194
194
 
195
+ # Agent demo scripts (runnable demos for review/verification workflows)
196
+ demos/
197
+
195
198
  # user progress
196
199
  PROGRESS.md
197
200
  progress.md
@@ -8,6 +8,52 @@ 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.3.0] — 2026-10-16
12
+
13
+ ### Added
14
+
15
+ - **Dotted domain names via the `@` filename escape (`GPDataCache`)** —
16
+ domain names may now contain `.` end-to-end through the config-file path.
17
+ gpconfig addresses configs by dot-separated paths, so a `.` in a config
18
+ filename is consumed as a path separator and the file becomes
19
+ unaddressable; the new convention writes each `.` in a domain name as `@`
20
+ in the filename (chosen for its internet-address connotation — the natural
21
+ spelling when a domain mirrors an internet domain):
22
+ `domain_name@com.yaml` configures the domain `"domain_name.com"`, and
23
+ `cache.get_domain("domain_name.com")`, `name in cache`, and
24
+ `gpdc://domain_name.com/{ns}/{obj}[...]` URIs resolve it transparently.
25
+ The mapping is injective because `@` is not a legal character in GPDC
26
+ names. `GPDataCache.get_domain` now validates the requested name against
27
+ the GPDC domain-name rules (`ValueError` otherwise; URI resolution reports
28
+ the same case as `ObjectNotFoundByURIError`, and `has_uri` stays aligned).
29
+ `GPDCDomain`'s name resolution restores `@` → `.` for names derived from
30
+ a config-file stem (before the name-mismatch check), so gpconfig's
31
+ object factory path also constructs correctly-named domains:
32
+ `folder.get_object("domain_name@com")` yields the domain
33
+ `"domain_name.com"`, identical to `GPDataCache.get_domain(
34
+ "domain_name.com")`. An explicit `name=` argument containing `@` is
35
+ still rejected. The escape helpers (`DOMAIN_DOT_ESCAPE`,
36
+ `escape_domain_name`, `unescape_domain_name`) are exported from the
37
+ package root. **No gpconfig-side change is
38
+ needed or wanted** (owner ruling 2026-10-16): gpconfig keeps
39
+ `config.name` as the raw stem, preserving the
40
+ `config.name == addressable path component` invariant; a gpconfig-side
41
+ request (fail-fast validation of config filenames containing `.` at manager
42
+ init, plus a docs note) is recorded in
43
+ `dev_docs/issue-gpconfig-at-escape-in-config-names.md`); that request
44
+ has since landed in gpconfig 0.4.0.
45
+ - **Startup warning for unaddressable config filenames** — `GPDataCache`
46
+ construction logs one warning per config file whose name contains a real
47
+ `.` (listed by `list_configs()` but never loadable by gpconfig's
48
+ dot-notation), suggesting the `@` spelling.
49
+
50
+ ### Fixed
51
+
52
+ - `GPDataCache.__contains__` and `get_domain` no longer disagree for
53
+ config files whose stem contains `.` (previously `in` returned `True`
54
+ while `get_domain` raised `ConfigNotFoundError`; such files are now
55
+ warned about at construction and looked up only through valid names).
56
+
11
57
  ## [0.2.1] — 2026-10-12
12
58
 
13
59
  ### Added
@@ -25,6 +71,13 @@ GitHub Releases (with download artifacts) are published at
25
71
  natively-equal values of different numeric types (`1` vs `Decimal(1)`) are
26
72
  distinct GPDC dict keys/set members, as `1` vs `1.0` already were.
27
73
 
74
+ ### Changed
75
+
76
+ - Require `gpconfig>=0.4.0` (was `>=0.3.0`, a version never published).
77
+ gpconfig 0.4.0 ships fail-fast validation of config filenames containing
78
+ `.` plus the `@`-convention documentation; gpdatacached's own
79
+ construction-time warning remains as defense for it.
80
+
28
81
  ### Documentation
29
82
 
30
83
  - Full docs-vs-code synchronization pass across `docs/` (English + zh-CN
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gpdatacached
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Requires-Python: >=3.10
23
- Requires-Dist: gpconfig>=0.3.0
23
+ Requires-Dist: gpconfig>=0.4.0
24
24
  Requires-Dist: pydantic>=2.0
25
25
  Requires-Dist: redis>=5.0
26
26
  Provides-Extra: dev
@@ -110,6 +110,7 @@ folder = mgr.get_config("gpdc") # -> GPConfigFolder; sample layouts in te
110
110
  with GPDataCache(folder) as cache:
111
111
  "core_domain" in cache # True iff a config exists (no Redis)
112
112
  domain = cache.get_domain("core_domain") # lazy construct + cache
113
+ site = cache.get_domain("domain_name.com") # dotted name -> domain_name@com.yaml
113
114
 
114
115
  # 3. Resolve objects by GPDC URI — including anonymous nested objects.
115
116
  ns = domain.get_namespace("core_app")
@@ -214,7 +215,7 @@ class GPDCNamespace(name, domain, *, config=None) # MutableMapping
214
215
 
215
216
  A logical isolation space inside a `GPDCDomain`. Implements the full `MutableMapping` protocol backed by Redis.
216
217
 
217
- **Naming rule:** names may use letters, digits, `.` and `_`; names wrapped in `__xxx__` are reserved. Anonymous objects (internal names starting with `~`) are hidden from the public mapping API. Common characters that are **rejected** because they fall outside the allow-list (and, for `:`, collide with the Redis key separator): hyphen `-`, spaces, and `:` — use `_` or `.` instead.
218
+ **Naming rule:** names may use letters, digits, `.` and `_`; names wrapped in `__xxx__` are reserved. Anonymous objects (internal names starting with `~`) are hidden from the public mapping API. Common characters that are **rejected** because they fall outside the allow-list (and, for `:`, collide with the Redis key separator): hyphen `-`, spaces, and `:` — use `_` or `.` instead. This rule applies to **object and namespace names**, and to **domain names** (a `.` in a domain name is legal everywhere in GPDC — but when the domain is driven by a config file through `GPDataCache`, the `.` must be spelled `@` in the filename; see the [`GPDataCache` `@` escape](#gpdatacache)).
218
219
 
219
220
  **Properties**
220
221
 
@@ -296,15 +297,34 @@ recommended entry point when your domains are driven by config files rather
296
297
  than constructed ad-hoc in code.
297
298
 
298
299
  **Construction.** Built from a `GPConfigFolder`. On construction it stores the
299
- folder reference only — **no domain is created**. Domains are constructed
300
- lazily (via gpconfig's object factory `folder.get_object(name)`) on first
301
- [`get_domain`](#gpdatacache) access, and cached in an internal dict. The cache
302
- is a snapshot: config files edited after construction are **not** re-read;
303
- rebuild the `GPDataCache` to pick up changes.
300
+ folder reference only — **no domain is created** (it does scan the folder once
301
+ to warn about unaddressable config filenames; see below). Domains are
302
+ constructed lazily on first [`get_domain`](#gpdatacache) access, and cached in
303
+ an internal dict. The cache is a snapshot: config files edited after
304
+ construction are **not** re-read; rebuild the `GPDataCache` to pick up changes.
304
305
 
305
306
  `GPDataCache` does **not** create domains lacking a config file — for ad-hoc
306
307
  domains use [`GPDCDomain`](#gpdcdomain) directly.
307
308
 
309
+ > **Dots in domain names — the `@` escape.** gpconfig addresses configs by
310
+ > dot-separated paths, so a `.` in a config filename is consumed as a path
311
+ > separator and the file becomes unaddressable. To use a dotted domain name —
312
+ > the natural shape when a domain mirrors an internet domain, e.g.
313
+ > `domain_name.com` — write each `.` as `@` in the config filename:
314
+ > `domain_name@com.yaml` configures the domain `"domain_name.com"`, and
315
+ > `cache.get_domain("domain_name.com")` /
316
+ > `cache.get_object_by_uri("gpdc://domain_name.com/ns/obj")` resolve it
317
+ > transparently. The mapping is unambiguous because `@` is not a legal
318
+ > character in GPDC names. `GPDCDomain` itself performs the `@` → `.`
319
+ > restore for names derived from a config-file stem, so gpconfig's object
320
+ > factory path also works: `folder.get_object("domain_name@com")`
321
+ > constructs a domain named `domain_name.com`, identical to
322
+ > `cache.get_domain("domain_name.com")`. An explicit `name=` argument
323
+ > containing `@` is still rejected — the escape exists for filenames;
324
+ > in code, write the `.` directly. Config files whose names contain a real `.` are
325
+ > listed by the folder but can never be loaded; `GPDataCache` logs a warning
326
+ > about them once at construction.
327
+
308
328
  > **Registration side effect.** `import gpdatacached` registers `GPDCDomain`
309
329
  > and `GPDCDomainConfig` with gpconfig's object factory (idempotent), which is
310
330
  > what makes `folder.get_object(name)` able to construct a `GPDCDomain`. No
@@ -312,7 +332,7 @@ domains use [`GPDCDomain`](#gpdcdomain) directly.
312
332
 
313
333
  **Dunder protocols**
314
334
 
315
- - `name in cache` → `True` iff a config named `name` exists in the folder. This is a pure config-snapshot check: it does **not** touch Redis and does not construct (or require) a `GPDCDomain` instance — the answer depends only on whether the config file is present, not on whether `get_domain(name)` has been called. The domain instance is created lazily on first `get_domain(name)` access.
335
+ - `name in cache` → `True` iff a config for `name` exists in the folder (dotted names look up their `@`-escaped filename). This is a pure config-snapshot check: it does **not** touch Redis and does not construct (or require) a `GPDCDomain` instance — the answer depends only on whether the config file is present, not on whether `get_domain(name)` has been called. Names that are not legal GPDC domain names (e.g. containing `@`) return `False`. The domain instance is created lazily on first `get_domain(name)` access.
316
336
  - `with cache as c` / `__exit__` → closes every cached domain on exit; `close()` is idempotent.
317
337
 
318
338
  > **`close()` behaviour — the membership exception.** After `cache.close()`, the resource-touching methods (`get_domain`, `get_object_by_uri`, `has_uri`) raise `RuntimeError("GPDataCache is closed")`. The `name in cache` membership check is the **deliberate exception**: because it only reads the config snapshot and touches no resources, it keeps working after `close()`. This is intentional — "does a config for this domain exist?" is meaningful regardless of whether the cache is open.
@@ -321,7 +341,7 @@ domains use [`GPDCDomain`](#gpdcdomain) directly.
321
341
 
322
342
  | Method | Description |
323
343
  |---|---|
324
- | `get_domain(name)` | Return the cached `GPDCDomain` for `name`, constructing it lazily on first access. Propagates gpconfig's `ConfigNotFoundError` if no config exists for `name`. Raises `RuntimeError` after `close()`. |
344
+ | `get_domain(name)` | Return the cached `GPDCDomain` for `name`, constructing it lazily on first access. Dotted names resolve to their `@`-escaped config file (see above). Raises `ValueError` if `name` is not a legal domain name; propagates gpconfig's `ConfigNotFoundError` if no config exists for `name`. Raises `RuntimeError` after `close()`. |
325
345
  | [`get_object_by_uri(uri)`](#get_object_by_uri) | Resolve a GPDC URI to a `GPDCDataObject`. Raises `RuntimeError` after `close()`. |
326
346
  | `has_uri(uri)` | Return `True` iff `get_object_by_uri(uri)` would succeed. Raises `RuntimeError` after `close()`. |
327
347
  | `close()` | Close every cached domain. Idempotent. |
@@ -370,9 +390,10 @@ For example, given `ns["cfg"] = {"port": 8080}`, `gpdc://d/ns/cfg/port` raises
370
390
 
371
391
  **Errors.** Every failure mode raises [`ObjectNotFoundByURIError`](#objectnotfoundbyurierror)
372
392
  (carries `.uri` and `.reason`): malformed URI (missing `gpdc://`, fewer than
373
- three segments, empty segment), unknown domain, missing namespace, missing root
374
- object, a path segment that fails (`KeyError`/`IndexError`/`TypeError`), and a
375
- scalar terminal.
393
+ three segments, empty segment), an invalid domain/namespace/object name (each
394
+ locating segment is validated up front), unknown domain, missing namespace,
395
+ missing root object, a path segment that fails
396
+ (`KeyError`/`IndexError`/`TypeError`), and a scalar terminal.
376
397
 
377
398
  **Example.** Given `ns["cfg"] = {"server": {"host": "x"}, "tags": ["a","b"]}`:
378
399
 
@@ -395,7 +416,7 @@ class ObjectNotFoundByURIError(GPDCError)
395
416
  Raised by [`GPDataCache.get_object_by_uri`](#get_object_by_uri) when a GPDC URI
396
417
  cannot be resolved to a `GPDCDataObject`. Covers every failure mode: malformed
397
418
  URI syntax, unknown domain (no config), missing namespace, missing root object,
398
- a path segment that fails, and a scalar terminal. Carries `.uri` (the URI
419
+ an invalid domain/namespace/object name, a path segment that fails, and a scalar terminal. Carries `.uri` (the URI
399
420
  string) and `.reason` (a short explanation).
400
421
 
401
422
  `has_uri` swallows only this exception; infrastructure errors (e.g. Redis
@@ -761,6 +782,20 @@ def validate_object_name(name: str) -> None
761
782
 
762
783
  Validate that `name` contains only letters, digits, `.` or `_` and is not a reserved `__xxx__` name. Raises `ValueError` otherwise. (Equivalent validators exist internally for domain and namespace names.)
763
784
 
785
+ ### Domain-name `@` escape helpers
786
+
787
+ ```python
788
+ DOMAIN_DOT_ESCAPE: str # "@"
789
+
790
+ def escape_domain_name(name: str) -> str
791
+ # "domain_name.com" -> "domain_name@com" ('.' -> '@')
792
+
793
+ def unescape_domain_name(stem: str) -> str
794
+ # "domain_name@com" -> "domain_name.com" ('@' -> '.')
795
+ ```
796
+
797
+ Helpers for the [`GPDataCache` `@` escape convention](#gpdatacache): gpconfig addresses configs by dot-separated paths, so a `.` in a domain config filename is unaddressable — each `.` in a domain name is written as `@` in the filename instead. The mapping is injective because `@` is not a legal character in GPDC names. `unescape_domain_name` is safe to apply to any stem-derived name for the same reason. You rarely need these directly — `GPDataCache` (lookup escaping) and `GPDCDomain` (load-time restore) apply them automatically.
798
+
764
799
  ---
765
800
 
766
801
  ## Exceptions
@@ -107,6 +107,7 @@ folder = mgr.get_config("gpdc") # -> GPConfigFolder;示例布局见 tes
107
107
  with GPDataCache(folder) as cache:
108
108
  "core_domain" in cache # 存在对应配置时为 True(不触碰 Redis)
109
109
  domain = cache.get_domain("core_domain") # 惰性构造 + 缓存
110
+ site = cache.get_domain("domain_name.com") # 带点名字 -> domain_name@com.yaml
110
111
 
111
112
  # 3. 通过 GPDC URI 解析对象 —— 包括匿名嵌套对象。
112
113
  ns = domain.get_namespace("core_app")
@@ -209,7 +210,7 @@ class GPDCNamespace(name, domain, *, config=None) # MutableMapping
209
210
 
210
211
  `GPDCDomain` 内部的逻辑隔离空间,完整实现了由 Redis 支撑的 `MutableMapping` 协议。
211
212
 
212
- **命名规则:** 名称只能使用字母、数字、`.` 和 `_`;形如 `__xxx__` 的名称被保留。匿名对象(内部名称以 `~` 开头)不会出现在公开的映射 API 中。常见的**被拒绝**字符(不在允许清单内;且 `:` 还会与 Redis key 分隔符冲突):连字符 `-`、空格、`:` —— 请改用 `_` 或 `.`。
213
+ **命名规则:** 名称只能使用字母、数字、`.` 和 `_`;形如 `__xxx__` 的名称被保留。匿名对象(内部名称以 `~` 开头)不会出现在公开的映射 API 中。常见的**被拒绝**字符(不在允许清单内;且 `:` 还会与 Redis key 分隔符冲突):连字符 `-`、空格、`:` —— 请改用 `_` 或 `.`。该规则适用于**对象名与 namespace 名**,也适用于 **domain 名**(domain 名中的 `.` 在 GPDC 内部处处合法 —— 但当 domain 由配置文件经 `GPDataCache` 驱动时,文件名中的 `.` 必须写作 `@`,见 [`GPDataCache` 的 `@` 转义](#gpdatacache))。
213
214
 
214
215
  **属性**
215
216
 
@@ -286,15 +287,17 @@ class GPDataCache(folder: gpconfig.GPConfigFolder) # 上下文管理器
286
287
 
287
288
  一个门面(facade),负责管理从 [`gpconfig`](https://github.com/linnetcodes/gpconfig) 的 `GPConfigFolder` 中发现的 `GPDCDomain` 实例,并将 GPDC URI 解析为 [`GPDCDataObject`](#gpdcdataobject) 实例。当你的 domain 由配置文件驱动、而非在代码中临时构造时,它是推荐的入口。
288
289
 
289
- **构造。** 基于 `GPConfigFolder` 构造。构造时只会保存该文件夹的引用 —— **不会创建任何 domain**。Domain 在首次通过 [`get_domain`](#gpdatacache) 访问时惰性构造(经由 gpconfig 的对象工厂 `folder.get_object(name)`),并缓存在一个内部 dict 中。该缓存是一个快照:构造之后被编辑的配置文件**不会**被重新读取;要应用变更需重建 `GPDataCache`。
290
+ **构造。** 基于 `GPConfigFolder` 构造。构造时只会保存该文件夹的引用 —— **不会创建任何 domain**(但会扫描一次文件夹,对无法寻址的配置文件名发出告警,见下)。Domain 在首次通过 [`get_domain`](#gpdatacache) 访问时惰性构造,并缓存在一个内部 dict 中。该缓存是一个快照:构造之后被编辑的配置文件**不会**被重新读取;要应用变更需重建 `GPDataCache`。
290
291
 
291
292
  `GPDataCache` **不会**创建缺少配置文件的 domain —— 临时 domain 请直接使用 [`GPDCDomain`](#gpdcdomain)。
292
293
 
294
+ > **Domain 名中的点号 —— `@` 转义。** gpconfig 以点分路径寻址配置,因此配置文件名中的 `.` 会被当作路径分隔符消费,该文件将无法被寻址。要使用带点的 domain 名 —— 当一个 domain 对应一个互联网域名时的自然形态,例如 `domain_name.com` —— 请在配置文件名中将每个 `.` 写作 `@`:`domain_name@com.yaml` 配置 domain `"domain_name.com"`,`cache.get_domain("domain_name.com")` / `cache.get_object_by_uri("gpdc://domain_name.com/ns/obj")` 会透明解析。该映射是无歧义的,因为 `@` 不是 GPDC 名称的合法字符。`GPDCDomain` 自身会对来自配置文件 stem 的名字做 `@` → `.` 还原,因此 gpconfig 的对象工厂路径同样可用:`folder.get_object("domain_name@com")` 构造出名为 `domain_name.com` 的 domain,与 `cache.get_domain("domain_name.com")` 完全一致。显式 `name=` 参数含 `@` 仍会被拒绝 —— 转义只服务于文件名,代码里直接写 `.` 即可。名字中含真实 `.` 的配置文件会被文件夹列出但永远无法加载;`GPDataCache` 在构造时会对这类文件记录一次告警。
295
+
293
296
  > **注册副作用。** `import gpdatacached` 会将 `GPDCDomain` 和 `GPDCDomainConfig` 注册到 gpconfig 的对象工厂(幂等),这正是 `folder.get_object(name)` 能够构造 `GPDCDomain` 的原因。无需额外设置。
294
297
 
295
298
  **支持的协议方法**
296
299
 
297
- - `name in cache` → 文件夹中存在名为 `name` 的配置时为 `True`。这是纯配置快照检查:**不**触碰 Redis,也不构造(或要求存在)`GPDCDomain` 实例 —— 答案只取决于配置文件是否存在,与是否调用过 `get_domain(name)` 无关。domain 实例在首次 `get_domain(name)` 访问时惰性创建。
300
+ - `name in cache` → 文件夹中存在 `name` 的配置时为 `True`(带点的名字会查找其 `@` 转义后的文件名)。这是纯配置快照检查:**不**触碰 Redis,也不构造(或要求存在)`GPDCDomain` 实例 —— 答案只取决于配置文件是否存在,与是否调用过 `get_domain(name)` 无关。domain 实例在首次 `get_domain(name)` 访问时惰性创建。
298
301
  - `with cache as c` / `__exit__` → 退出时关闭所有已缓存的 domain;`close()` 是幂等的。
299
302
 
300
303
  > **`close()` 后的行为 —— 成员检查的例外。** `cache.close()` 之后,触碰资源的方法(`get_domain`、`get_object_by_uri`、`has_uri`)会抛 `RuntimeError("GPDataCache is closed")`。`name in cache` 成员检查是**故意的例外**:因为它只读配置快照、不触碰任何资源,所以在 `close()` 后仍然可用。这是有意为之 —— "这个 domain 是否有配置?" 这个问题与 cache 是否打开无关。
@@ -303,7 +306,7 @@ class GPDataCache(folder: gpconfig.GPConfigFolder) # 上下文管理器
303
306
 
304
307
  | 方法 | 说明 |
305
308
  |---|---|
306
- | `get_domain(name)` | 返回 `name` 对应的已缓存 `GPDCDomain`,首次访问时惰性构造。若 `name` 无对应配置,则抛出 gpconfig 的 `ConfigNotFoundError`。`close()` 后抛 `RuntimeError`。 |
309
+ | `get_domain(name)` | 返回 `name` 对应的已缓存 `GPDCDomain`,首次访问时惰性构造。带点的名字会解析到其 `@` 转义后的配置文件(见上)。`name` 不是合法 domain 名时抛 `ValueError`;无对应配置时抛出 gpconfig 的 `ConfigNotFoundError`。`close()` 后抛 `RuntimeError`。 |
307
310
  | [`get_object_by_uri(uri)`](#get_object_by_uri) | 将 GPDC URI 解析为 `GPDCDataObject`。`close()` 后抛 `RuntimeError`。 |
308
311
  | `has_uri(uri)` | 当 `get_object_by_uri(uri)` 会成功时返回 `True`。`close()` 后抛 `RuntimeError`。 |
309
312
  | `close()` | 关闭所有已缓存的 domain。幂等。 |
@@ -337,7 +340,7 @@ gpdc://{domain}/{namespace}/{name}[/{path_segment}...]
337
340
 
338
341
  **标量终结点会被拒绝。** 嵌套在容器内部的标量以内联值形式存储(而非作为被引用对象),因此深入到一个标量元素得到的是原生 Python 值。由于 `get_object_by_uri` 只返回对象,终止于标量的路径会抛出 `ObjectNotFoundByURIError`。例如,给定 `ns["cfg"] = {"port": 8080}`,`gpdc://d/ns/cfg/port` 会抛错(值 `8080` 是标量,而非数据对象)。
339
342
 
340
- **错误。** 每一种失败情况都会抛出 [`ObjectNotFoundByURIError`](#objectnotfoundbyurierror)(携带 `.uri` 和 `.reason`):URI 格式错误(缺少 `gpdc://`、分段少于三段、存在空分段)、未知的 domain、缺少 namespace、缺少根对象、某个路径分段失败(`KeyError`/`IndexError`/`TypeError`),以及标量终结点。
343
+ **错误。** 每一种失败情况都会抛出 [`ObjectNotFoundByURIError`](#objectnotfoundbyurierror)(携带 `.uri` 和 `.reason`):URI 格式错误(缺少 `gpdc://`、分段少于三段、存在空分段)、非法的 domain/namespace/object 名(三个定位分段都会预先校验)、未知的 domain、缺少 namespace、缺少根对象、某个路径分段失败(`KeyError`/`IndexError`/`TypeError`),以及标量终结点。
341
344
 
342
345
  **示例。** 给定 `ns["cfg"] = {"server": {"host": "x"}, "tags": ["a","b"]}`:
343
346
 
@@ -357,7 +360,7 @@ gpdc://{domain}/{namespace}/{name}[/{path_segment}...]
357
360
  class ObjectNotFoundByURIError(GPDCError)
358
361
  ```
359
362
 
360
- 当 GPDC URI 无法解析为 `GPDCDataObject` 时,由 [`GPDataCache.get_object_by_uri`](#get_object_by_uri) 抛出。涵盖所有失败情况:URI 语法格式错误、未知的 domain(无配置)、缺少 namespace、缺少根对象、某个路径分段失败,以及标量终结点。携带 `.uri`(URI 字符串)和 `.reason`(简短说明)。
363
+ 当 GPDC URI 无法解析为 `GPDCDataObject` 时,由 [`GPDataCache.get_object_by_uri`](#get_object_by_uri) 抛出。涵盖所有失败情况:URI 语法格式错误、非法的 domain/namespace/object 名、未知的 domain(无配置)、缺少 namespace、缺少根对象、某个路径分段失败,以及标量终结点。携带 `.uri`(URI 字符串)和 `.reason`(简短说明)。
361
364
 
362
365
  `has_uri` 只会吞掉这个异常;基础设施错误(如 Redis 连接失败)会继续向上传播。
363
366
 
@@ -719,6 +722,20 @@ def validate_object_name(name: str) -> None
719
722
 
720
723
  校验 `name` 仅包含字母、数字、`.` 或 `_`,且不是被保留的 `__xxx__` 名称。否则抛出 `ValueError`。(内部还存在等价的 domain 名称与 namespace 名称校验器。)
721
724
 
725
+ ### Domain 名 `@` 转义辅助函数
726
+
727
+ ```python
728
+ DOMAIN_DOT_ESCAPE: str # "@"
729
+
730
+ def escape_domain_name(name: str) -> str
731
+ # "domain_name.com" -> "domain_name@com" ('.' -> '@')
732
+
733
+ def unescape_domain_name(stem: str) -> str
734
+ # "domain_name@com" -> "domain_name.com" ('@' -> '.')
735
+ ```
736
+
737
+ [`GPDataCache` `@` 转义约定](#gpdatacache)的辅助函数:gpconfig 以点分路径寻址配置,因此 domain 配置文件名中的 `.` 无法被寻址 —— domain 名中的每个 `.` 在文件名中写作 `@`。由于 `@` 不是 GPDC 名称的合法字符,该映射是单射的;同理 `unescape_domain_name` 可安全地作用于任何 stem 来源的名字。通常无需直接使用 —— `GPDataCache`(寻址时转义)与 `GPDCDomain`(加载时还原)会自动应用。
738
+
722
739
  ---
723
740
 
724
741
  ## 异常
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Topic :: Software Development :: Libraries :: Python Modules",
22
22
  ]
23
23
  dependencies = [
24
- "gpconfig>=0.3.0",
24
+ "gpconfig>=0.4.0",
25
25
  "pydantic>=2.0",
26
26
  "redis>=5.0",
27
27
  ]
@@ -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.1"
5
+ __version__ = "0.3.0"
6
6
 
7
7
  from .codec import GPDCCodec, GPDCContainerCodec, GPDCScalarCodec
8
8
  from .config import GPDCDomainConfig, GPDCNamespaceConfig
@@ -17,7 +17,14 @@ from .exceptions import (
17
17
  ObjectNotFoundByURIError,
18
18
  )
19
19
  from .domain import GPDCDomain, reset_domain_config
20
- from .keys import make_uri, uri_to_redis_key, validate_object_name
20
+ from .keys import (
21
+ DOMAIN_DOT_ESCAPE,
22
+ escape_domain_name,
23
+ make_uri,
24
+ unescape_domain_name,
25
+ uri_to_redis_key,
26
+ validate_object_name,
27
+ )
21
28
  from .namespace import GPDCNamespace
22
29
  from .types import GPDCTypeRegistry
23
30
  from .object import GPDCDataObject
@@ -91,6 +98,9 @@ __all__ = [
91
98
  "GPDCContainerCodec",
92
99
  "GPDCScalarCodec",
93
100
  "GPDCTypeRegistry",
101
+ "DOMAIN_DOT_ESCAPE",
102
+ "escape_domain_name",
103
+ "unescape_domain_name",
94
104
  "validate_object_name",
95
105
  "make_uri",
96
106
  "uri_to_redis_key",
@@ -8,10 +8,18 @@ from typing import TYPE_CHECKING
8
8
  from gpconfig import ConfigNotFoundError
9
9
 
10
10
  from .exceptions import ObjectNotFoundByURIError
11
+ from .keys import (
12
+ DOMAIN_DOT_ESCAPE,
13
+ escape_domain_name,
14
+ validate_domain_name,
15
+ validate_namespace_name,
16
+ validate_object_name,
17
+ )
11
18
 
12
19
  if TYPE_CHECKING:
13
20
  from gpconfig import GPConfigFolder
14
21
 
22
+ from .config import GPDCDomainConfig
15
23
  from .domain import GPDCDomain
16
24
  from .object import GPDCDataObject
17
25
 
@@ -21,9 +29,11 @@ logger = logging.getLogger(__name__)
21
29
  class GPDataCache:
22
30
  """Manage GPDCDomain instances from a GPConfigFolder and resolve GPDC URIs.
23
31
 
24
- Domains are constructed lazily via gpconfig's object factory
25
- (``folder.get_object(name)``) and cached. Construction happens on first
26
- ``get_domain`` access; ``__init__`` does not create any domain.
32
+ Domain configs are loaded lazily on first ``get_domain`` access and
33
+ cached; ``__init__`` does not create any domain. Domain names may contain
34
+ '.' — gpconfig cannot address dotted filenames, so a '.' in a domain name
35
+ is written as '@' in its config file (``domain_name@com.yaml`` configures
36
+ domain ``"domain_name.com"``; see DOMAIN_DOT_ESCAPE).
27
37
 
28
38
  The cache is a snapshot: config files edited after construction are NOT
29
39
  re-read. Rebuild the GPDataCache to pick up changes.
@@ -38,29 +48,97 @@ class GPDataCache:
38
48
  self._folder = folder
39
49
  self._domains: dict[str, GPDCDomain] = {}
40
50
  self._closed = False
51
+ self._warn_dotted_config_stems()
52
+
53
+ def _warn_dotted_config_stems(self) -> None:
54
+ """Warn once about config stems containing '.' — they are unaddressable.
55
+
56
+ gpconfig's dot-notation consumes '.' as a path separator, so such a
57
+ file is listed by ``list_configs()`` but can never be loaded by name.
58
+ The '@' escape (DOMAIN_DOT_ESCAPE) is the supported way to spell a
59
+ dotted domain name in a filename.
60
+ """
61
+ try:
62
+ names = self._folder.list_configs()
63
+ except Exception:
64
+ # __init__ must stay side-effect-free relative to the folder; a
65
+ # broken listing will surface on first real use instead.
66
+ return
67
+ for name in names:
68
+ if "." in name:
69
+ logger.warning(
70
+ "Config %r in config folder %r contains '.' and cannot be addressed "
71
+ "by gpconfig's dot-notation; rename it to %r ('.' in a domain name "
72
+ "is written as '%s' in the config filename).",
73
+ name,
74
+ self._folder.path,
75
+ escape_domain_name(name),
76
+ DOMAIN_DOT_ESCAPE,
77
+ )
41
78
 
42
79
  def __contains__(self, domain_name: object) -> bool:
43
80
  """Return True iff ``domain_name`` has a config in this folder.
44
81
 
45
82
  Pure config-snapshot check via ``folder.list_configs()``: does not
46
- touch Redis and does not construct a GPDCDomain.
83
+ touch Redis and does not construct a GPDCDomain. The name is validated
84
+ and '@'-escaped first, so ``"domain_name.com" in cache`` matches the
85
+ config file ``domain_name@com.yaml``; names that are not legal GPDC
86
+ domain names (including any containing '@') return False.
47
87
  """
48
88
  if not isinstance(domain_name, str):
49
89
  return False
50
- return domain_name in self._folder.list_configs()
90
+ try:
91
+ validate_domain_name(domain_name)
92
+ except ValueError:
93
+ return False
94
+ return escape_domain_name(domain_name) in self._folder.list_configs()
51
95
 
52
96
  def get_domain(self, domain_name: str) -> "GPDCDomain":
53
97
  """Return the cached GPDCDomain for ``domain_name``, creating it lazily.
54
98
 
55
- Propagates gpconfig's ``ConfigNotFoundError`` if the domain has no
56
- config file in this folder. GPDataCache does not create domains
57
- without a config (use GPDCDomain directly for ad-hoc domains).
99
+ ``domain_name`` is validated against the GPDC name rules and
100
+ '@'-escaped for the config lookup (see DOMAIN_DOT_ESCAPE), so a
101
+ dotted domain name resolves to its '@'-escaped config file. The
102
+ config is loaded as GPDCDomainConfig and constructed directly —
103
+ GPDCDomain's own name resolution restores the '@'-escaped stem
104
+ (``domain_name@com``) to the real dotted name
105
+ (``domain_name.com``), so the same factory-built path via
106
+ ``folder.get_object("domain_name@com")`` yields an identically
107
+ named domain.
108
+
109
+ Raises ValueError for names that are not legal GPDC domain names,
110
+ and gpconfig's ``ConfigNotFoundError`` if the domain has no config
111
+ file in this folder. GPDataCache does not create domains without a
112
+ config (use GPDCDomain directly for ad-hoc domains).
58
113
  """
59
114
  self._check_open()
115
+ validate_domain_name(domain_name)
60
116
  if domain_name not in self._domains:
61
- self._domains[domain_name] = self._folder.get_object(domain_name)
117
+ config = self._load_domain_config(domain_name)
118
+ # Imported lazily to keep this module decoupled from the
119
+ # package's import order (same rationale as _traverse).
120
+ from .domain import GPDCDomain
121
+
122
+ self._domains[domain_name] = GPDCDomain(config)
62
123
  return self._domains[domain_name]
63
124
 
125
+ def _load_domain_config(self, domain_name: str) -> "GPDCDomainConfig":
126
+ """Load the domain config for ``domain_name`` (via its '@'-escaped stem).
127
+
128
+ The config keeps its raw stem in ``config.name``; GPDCDomain's name
129
+ resolution performs the '@' -> '.' restore. Raises
130
+ ConfigNotFoundError when no config answers to the escaped name, or
131
+ when something other than a domain config does (e.g. a subfolder
132
+ shadows the config file).
133
+ """
134
+ from .config import GPDCDomainConfig
135
+
136
+ escaped = escape_domain_name(domain_name)
137
+ config = self._folder.get_config(escaped, config_cls=GPDCDomainConfig)
138
+ if not isinstance(config, GPDCDomainConfig):
139
+ raise ConfigNotFoundError(escaped, f"resolves to {type(config).__name__}, not a GPDCDomainConfig")
140
+ return config
141
+
64
142
  def get_object_by_uri(self, uri: str) -> "GPDCDataObject":
65
143
  """Resolve a GPDC URI to a GPDCDataObject.
66
144
 
@@ -72,8 +150,9 @@ class GPDataCache:
72
150
  terminates on a scalar value raises ObjectNotFoundByURIError.
73
151
 
74
152
  Raises ObjectNotFoundByURIError for every failure mode: malformed
75
- URI, missing domain config, missing namespace, missing root object,
76
- broken path, scalar terminal.
153
+ URI, invalid domain/namespace/object name, missing domain config,
154
+ missing namespace, missing root object, broken path, scalar
155
+ terminal.
77
156
  """
78
157
  self._check_open()
79
158
  domain_name, ns_name, obj_name, path_parts = self._parse_uri(uri)
@@ -88,17 +167,13 @@ class GPDataCache:
88
167
  try:
89
168
  ns = domain.get_namespace(ns_name)
90
169
  except KeyError as e:
91
- raise ObjectNotFoundByURIError(
92
- uri, f"namespace {ns_name!r} not found in domain {domain_name!r}"
93
- ) from e
170
+ raise ObjectNotFoundByURIError(uri, f"namespace {ns_name!r} not found in domain {domain_name!r}") from e
94
171
 
95
172
  # Root object: get_object returns the object itself (not a value).
96
173
  try:
97
174
  root = ns.get_object(obj_name)
98
175
  except KeyError as e:
99
- raise ObjectNotFoundByURIError(
100
- uri, f"object {obj_name!r} not found in namespace {ns_name!r}"
101
- ) from e
176
+ raise ObjectNotFoundByURIError(uri, f"object {obj_name!r} not found in namespace {ns_name!r}") from e
102
177
 
103
178
  # _traverse descends path_parts via __getitem__ (int-coercing ListObject
104
179
  # segments) and enforces the object-only terminal contract; with no
@@ -125,15 +200,30 @@ class GPDataCache:
125
200
  """Split a GPDC URI into (domain, namespace, name, path_parts)."""
126
201
  if not uri.startswith("gpdc://"):
127
202
  raise ObjectNotFoundByURIError(uri, "URI must start with 'gpdc://'")
128
- rest = uri[len("gpdc://"):]
203
+ rest = uri[len("gpdc://") :]
129
204
  parts = rest.split("/")
130
205
  if len(parts) < 3:
131
- raise ObjectNotFoundByURIError(
132
- uri, "URI must have at least domain/namespace/name segments"
133
- )
206
+ raise ObjectNotFoundByURIError(uri, "URI must have at least domain/namespace/name segments")
134
207
  if any(p == "" for p in parts):
135
208
  raise ObjectNotFoundByURIError(uri, "URI contains an empty segment")
136
209
  domain, ns_name, obj_name = parts[0], parts[1], parts[2]
210
+ # Validate all three locating segments up front so every URI failure
211
+ # mode surfaces as ObjectNotFoundByURIError: a ValueError from any
212
+ # downstream validator (e.g. namespace.get_object validates object
213
+ # names before its existence check) must not leak through
214
+ # get_object_by_uri, and has_uri relies on that contract. Path
215
+ # segments are NOT validated here — they are dict keys / list
216
+ # indices, and invalid ones fail traversal with KeyError/IndexError,
217
+ # which _traverse already wraps.
218
+ for label, segment, validator in (
219
+ ("domain", domain, validate_domain_name),
220
+ ("namespace", ns_name, validate_namespace_name),
221
+ ("object", obj_name, validate_object_name),
222
+ ):
223
+ try:
224
+ validator(segment)
225
+ except ValueError as e:
226
+ raise ObjectNotFoundByURIError(uri, f"invalid {label} name {segment!r}: {e}") from e
137
227
  path_parts = parts[3:]
138
228
  return domain, ns_name, obj_name, path_parts
139
229
 
@@ -164,16 +254,14 @@ class GPDataCache:
164
254
  if not hasattr(current, "__getitem__"):
165
255
  raise ObjectNotFoundByURIError(
166
256
  uri,
167
- f"path segment {part!r} cannot descend: "
168
- f"{current.canonical_uri} does not support subscript access",
257
+ f"path segment {part!r} cannot descend: {current.canonical_uri} does not support subscript access",
169
258
  )
170
259
  # Type-based key coercion (decision D9).
171
260
  if isinstance(current, ListObject):
172
261
  if not part.isdigit():
173
262
  raise ObjectNotFoundByURIError(
174
263
  uri,
175
- f"path segment {part!r} invalid for ListObject: "
176
- f"must be a non-negative integer",
264
+ f"path segment {part!r} invalid for ListObject: must be a non-negative integer",
177
265
  )
178
266
  key = int(part)
179
267
  else:
@@ -186,9 +274,7 @@ class GPDataCache:
186
274
  ) from e
187
275
  # Terminal check: must be a GPDCDataObject, not a scalar value.
188
276
  if not isinstance(current, GPDCDataObject):
189
- raise ObjectNotFoundByURIError(
190
- uri, "terminal is a scalar value; get_object_by_uri returns objects only"
191
- )
277
+ raise ObjectNotFoundByURIError(uri, "terminal is a scalar value; get_object_by_uri returns objects only")
192
278
  return current
193
279
 
194
280
  def close(self) -> None:
@@ -15,6 +15,7 @@ from .keys import (
15
15
  make_domain_meta_key,
16
16
  make_domain_namespaces_key,
17
17
  make_namespace_meta_key,
18
+ unescape_domain_name,
18
19
  validate_domain_name,
19
20
  )
20
21
 
@@ -52,7 +53,22 @@ class GPDCDomain(GPConfigurable):
52
53
  # loaded from a file (== filename stem); it is "" for a programmatically
53
54
  # constructed config. Prefer config.name, fall back to the explicit name
54
55
  # argument, and reject a conflict between the two.
56
+ #
57
+ # A '@' in config.name is the filename escape for '.' (see
58
+ # keys.DOMAIN_DOT_ESCAPE): gpconfig's dot-notation cannot address
59
+ # dotted filenames, so domain_name@com.yaml spells the domain
60
+ # "domain_name.com". Restore it here — before the mismatch check and
61
+ # validation — so both gpconfig's object factory
62
+ # (folder.get_object("domain_name@com")) and GPDataCache construct the
63
+ # identically-named domain. The restore applies to any non-empty
64
+ # config.name (which for a file-loaded config IS the stem; a
65
+ # programmatically assigned name containing '@' is restored the same
66
+ # way — '@' cannot occur in a legal domain name, so this never alters
67
+ # an already-valid name). An explicit name= argument containing '@' is
68
+ # NOT restored and is rejected loudly by validate_domain_name below.
55
69
  config_name = config.name
70
+ if config_name:
71
+ config_name = unescape_domain_name(config_name)
56
72
  if name is not None and config_name:
57
73
  if name != config_name:
58
74
  raise ValueError(
@@ -112,6 +112,35 @@ def validate_namespace_name(name: str) -> None:
112
112
  _validate_name(name, "namespace name")
113
113
 
114
114
 
115
+ DOMAIN_DOT_ESCAPE = "@"
116
+ """Escape for '.' in domain config filenames (internet-domain adaptation).
117
+
118
+ gpconfig addresses configs by dot-separated paths, so a '.' in a config file
119
+ stem is consumed as a path separator and the file becomes unaddressable
120
+ (listed by ``list_configs`` but never found). A '@' in the stem instead
121
+ stands for a literal '.' in the domain name: ``domain_name@com.yaml``
122
+ configures the domain ``"domain_name.com"``. The '@' was chosen for its
123
+ internet-address connotation (user@host); the mapping is injective because
124
+ '@' is not a legal character in GPDC names, so two distinct domain names can
125
+ never escape to the same filename.
126
+ """
127
+
128
+
129
+ def escape_domain_name(name: str) -> str:
130
+ """Return the config-file form of a domain name ('.' -> '@')."""
131
+ return name.replace(".", DOMAIN_DOT_ESCAPE)
132
+
133
+
134
+ def unescape_domain_name(stem: str) -> str:
135
+ """Return the domain name for a config-file stem ('@' -> '.').
136
+
137
+ Inverse of :func:`escape_domain_name`. Safe to apply unconditionally to
138
+ a stem-derived name: '@' cannot occur in a legal GPDC domain name, so
139
+ this never alters an already-valid name.
140
+ """
141
+ return stem.replace(DOMAIN_DOT_ESCAPE, ".")
142
+
143
+
115
144
  def collect_ref_names_from_raw(r, raw_key: str, raw_type: str) -> list[str]:
116
145
  """Scan a container's raw Redis data and return the names of all referenced objects.
117
146
 
File without changes
File without changes