Skip to content

Commit 274b1b2

Browse files
feat(codecs): explicit context argument, additive to key
`encode`/`decode` overloaded a single `key` dict with two unrelated things: primary key values, and connection context (`_schema`, `_table`, `_field`, `_config`). The underscore convention separating them is unenforced, and `_config` -- functionally required for correct store resolution in any multi-connection process -- arrived as an optional dict key a codec author had to remember to read out and thread through by hand. Forgetting did not fail loudly: it fell back to the global config and resolved a different store silently. That happened twice independently, in dj-figpack-codecs#6 and dj-canvasxpress-codecs#3, both following the SchemaCodec docstring. Adds `context` as a separate keyword carrying schema, table, field and config, leaving `key` to mean what it means everywhere else in DataJoint. Nothing breaks: - DataJoint passes `context` only to codecs whose signature declares it, reusing the introspection already used for `store_name`. A codec written before this keeps its old signature and is called exactly as before. - The underscore keys stay in `key` and are still populated, so a codec reading `key["_config"]` directly keeps working. - `_extract_context(key)` still accepts one argument. It warns only when it has to fall back to underscore keys, so a codec passing context is quiet and one that never needed context is never nagged. Verified against all four third-party codecs in the ecosystem (dj-figpack, dj-canvasxpress, dj-zarr, dj-photon): every one declares the old signature, so none is passed `context` and none needs changing. `Codec._codec_config(key, context)` replaces the hand-rolled `(key or {}).get("_config")` at eleven sites across the built-ins, preferring context and falling back to the legacy key. The breaking half of #1550 -- `key` reverting to primary-key-only and `config` becoming a required parameter on `_build_path`/`_get_backend` -- is deliberately not done here. It belongs in 2.4, after the deprecation window this opens.
1 parent f8f1fb9 commit 274b1b2

10 files changed

Lines changed: 272 additions & 54 deletions

File tree

‎src/datajoint/builtin_codecs/attach.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ def get_dtype(self, is_store: bool) -> str:
5050
"""Return bytes for in-table, <hash> for in-store storage."""
5151
return "<hash>" if is_store else "bytes"
5252

53-
def encode(self, value: Any, *, key: dict | None = None, store_name: str | None = None) -> bytes:
53+
def encode(
54+
self, value: Any, *, key: dict | None = None, context: dict | None = None, store_name: str | None = None
55+
) -> bytes:
5456
"""
5557
Read file and encode as filename + contents.
5658
@@ -80,7 +82,7 @@ def encode(self, value: Any, *, key: dict | None = None, store_name: str | None
8082
contents = path.read_bytes()
8183
return filename.encode("utf-8") + b"\x00" + contents
8284

83-
def decode(self, stored: bytes, *, key: dict | None = None) -> str:
85+
def decode(self, stored: bytes, *, key: dict | None = None, context: dict | None = None) -> str:
8486
"""
8587
Extract file to download path and return local path.
8688
@@ -104,7 +106,7 @@ def decode(self, stored: bytes, *, key: dict | None = None) -> str:
104106
contents = stored[null_pos + 1 :]
105107

106108
# Write to download path
107-
config = (key or {}).get("_config")
109+
config = self._codec_config(key, context)
108110
if config is None:
109111
from ..settings import config # type: ignore[assignment]
110112
assert config is not None

‎src/datajoint/builtin_codecs/filepath.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ def get_dtype(self, is_store: bool) -> str:
7474
)
7575
return "json"
7676

77-
def encode(self, value: Any, *, key: dict | None = None, store_name: str | None = None) -> dict:
77+
def encode(
78+
self, value: Any, *, key: dict | None = None, context: dict | None = None, store_name: str | None = None
79+
) -> dict:
7880
"""
7981
Store path reference as JSON metadata.
8082
@@ -104,7 +106,7 @@ def encode(self, value: Any, *, key: dict | None = None, store_name: str | None
104106

105107
from ..hash_registry import get_store_backend
106108

107-
config = (key or {}).get("_config")
109+
config = self._codec_config(key, context)
108110
if config is None:
109111
from ..settings import config # type: ignore[assignment]
110112
assert config is not None
@@ -168,7 +170,7 @@ def encode(self, value: Any, *, key: dict | None = None, store_name: str | None
168170
"timestamp": datetime.now(timezone.utc).isoformat(),
169171
}
170172

171-
def decode(self, stored: dict, *, key: dict | None = None) -> Any:
173+
def decode(self, stored: dict, *, key: dict | None = None, context: dict | None = None) -> Any:
172174
"""
173175
Create ObjectRef handle for lazy access.
174176
@@ -187,7 +189,7 @@ def decode(self, stored: dict, *, key: dict | None = None) -> Any:
187189
from ..objectref import ObjectRef
188190
from ..hash_registry import get_store_backend
189191

190-
config = (key or {}).get("_config")
192+
config = self._codec_config(key, context)
191193
store_name = stored.get("store")
192194
backend = get_store_backend(store_name, config=config)
193195
return ObjectRef.from_json(stored, backend=backend)

‎src/datajoint/builtin_codecs/hash.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def get_dtype(self, is_store: bool) -> str:
5757
raise DataJointError("<hash> requires @ (in-store storage only)")
5858
return "json"
5959

60-
def encode(self, value: bytes, *, key: dict | None = None, store_name: str | None = None) -> dict:
60+
def encode(
61+
self, value: bytes, *, key: dict | None = None, context: dict | None = None, store_name: str | None = None
62+
) -> dict:
6163
"""
6264
Store content and return metadata.
6365
@@ -78,10 +80,10 @@ def encode(self, value: bytes, *, key: dict | None = None, store_name: str | Non
7880
from ..hash_registry import put_hash
7981

8082
schema_name = (key or {}).get("_schema", "unknown")
81-
config = (key or {}).get("_config")
83+
config = self._codec_config(key, context)
8284
return put_hash(value, schema_name=schema_name, store_name=store_name, config=config)
8385

84-
def decode(self, stored: dict, *, key: dict | None = None) -> bytes:
86+
def decode(self, stored: dict, *, key: dict | None = None, context: dict | None = None) -> bytes:
8587
"""
8688
Retrieve content using stored metadata.
8789
@@ -99,7 +101,7 @@ def decode(self, stored: dict, *, key: dict | None = None) -> bytes:
99101
"""
100102
from ..hash_registry import get_hash
101103

102-
config = (key or {}).get("_config")
104+
config = self._codec_config(key, context)
103105
return get_hash(stored, config=config)
104106

105107
def validate(self, value: Any) -> None:

‎src/datajoint/builtin_codecs/npy.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ def encode(
312312
value: Any,
313313
*,
314314
key: dict | None = None,
315+
context: dict | None = None,
315316
store_name: str | None = None,
316317
) -> dict:
317318
"""
@@ -337,8 +338,8 @@ def encode(
337338
import numpy as np
338339

339340
# Extract context using inherited helper
340-
schema, table, field, primary_key = self._extract_context(key)
341-
config = (key or {}).get("_config")
341+
schema, table, field, primary_key = self._extract_context(key, context)
342+
config = self._codec_config(key, context)
342343

343344
# Build schema-addressed storage path
344345
path, _ = self._build_path(schema, table, field, primary_key, ext=".npy", store_name=store_name, config=config)
@@ -360,7 +361,7 @@ def encode(
360361
"shape": list(value.shape),
361362
}
362363

363-
def decode(self, stored: dict, *, key: dict | None = None) -> NpyRef:
364+
def decode(self, stored: dict, *, key: dict | None = None, context: dict | None = None) -> NpyRef:
364365
"""
365366
Create lazy NpyRef from stored metadata.
366367
@@ -376,6 +377,6 @@ def decode(self, stored: dict, *, key: dict | None = None) -> NpyRef:
376377
NpyRef
377378
Lazy array reference with metadata access and numpy integration.
378379
"""
379-
config = (key or {}).get("_config")
380+
config = self._codec_config(key, context)
380381
backend = self._get_backend(stored.get("store"), config=config)
381382
return NpyRef(stored, backend)

‎src/datajoint/builtin_codecs/object.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def encode(
8181
value: Any,
8282
*,
8383
key: dict | None = None,
84+
context: dict | None = None,
8485
store_name: str | None = None,
8586
) -> dict:
8687
"""
@@ -105,8 +106,8 @@ def encode(
105106
from pathlib import Path
106107

107108
# Extract context using inherited helper
108-
schema, table, field, primary_key = self._extract_context(key)
109-
config = (key or {}).get("_config")
109+
schema, table, field, primary_key = self._extract_context(key, context)
110+
config = self._codec_config(key, context)
110111

111112
# Check for pre-computed metadata (from staged insert)
112113
if isinstance(value, dict) and "path" in value:
@@ -177,7 +178,7 @@ def encode(
177178

178179
return metadata
179180

180-
def decode(self, stored: dict, *, key: dict | None = None) -> Any:
181+
def decode(self, stored: dict, *, key: dict | None = None, context: dict | None = None) -> Any:
181182
"""
182183
Create ObjectRef handle for lazy access.
183184
@@ -195,7 +196,7 @@ def decode(self, stored: dict, *, key: dict | None = None) -> Any:
195196
"""
196197
from ..objectref import ObjectRef
197198

198-
config = (key or {}).get("_config")
199+
config = self._codec_config(key, context)
199200
backend = self._get_backend(stored.get("store"), config=config)
200201
return ObjectRef.from_json(stored, backend=backend)
201202

‎src/datajoint/builtin_codecs/schema.py‎

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from __future__ import annotations
66

7+
import warnings
8+
79
from ..codecs import Codec
810
from ..errors import DataJointError
911

@@ -24,14 +26,22 @@ class SchemaCodec(Codec, register=False):
2426
- ``validate()``: Validate input values
2527
2628
Helper Methods:
27-
- ``_extract_context()``: Parse key dict into schema/table/field/pk
29+
- ``_extract_context()``: Parse key/context into schema/table/field/pk
30+
- ``_codec_config()``: Read the calling connection's config
2831
- ``_build_path()``: Construct storage path from context
2932
- ``_get_backend()``: Get storage backend by name
3033
31-
Both helpers take a ``config`` and fall back to the global ``dj.config``
32-
without one. Read it off ``key["_config"]`` and pass it through, as below: it
33-
is the calling connection's config, and in a process holding connections for
34-
several users the global one belongs to none of them.
34+
``_build_path`` and ``_get_backend`` take a ``config`` and fall back to the
35+
global ``dj.config`` without one. Always pass the calling connection's
36+
config: in a process holding connections for several users, the global one
37+
belongs to none of them, and the fallback resolves a different store
38+
silently rather than raising.
39+
40+
Since 2.3.4 that config arrives in an explicit ``context`` argument rather
41+
than hidden among the primary key values. Accept ``context=None`` in
42+
``encode``/``decode`` and pass it to the helpers. The old underscore keys in
43+
``key`` still work and are still populated, with a ``DeprecationWarning``
44+
when ``_extract_context`` has to fall back to them; they are removed in 2.4.
3545
3646
Comparison with Hash-addressed:
3747
- **Schema-addressed** (this): Path from schema structure, no dedup
@@ -42,9 +52,9 @@ class SchemaCodec(Codec, register=False):
4252
class MyCodec(SchemaCodec):
4353
name = "my"
4454
45-
def encode(self, value, *, key=None, store_name=None):
46-
schema, table, field, pk = self._extract_context(key)
47-
config = (key or {}).get("_config")
55+
def encode(self, value, *, key=None, context=None, store_name=None):
56+
schema, table, field, pk = self._extract_context(key, context)
57+
config = self._codec_config(key, context)
4858
path, _ = self._build_path(
4959
schema, table, field, pk, ext=".dat",
5060
store_name=store_name, config=config,
@@ -53,8 +63,8 @@ def encode(self, value, *, key=None, store_name=None):
5363
backend.put_buffer(serialize(value), path)
5464
return {"path": path, "store": store_name, ...}
5565
56-
def decode(self, stored, *, key=None):
57-
config = (key or {}).get("_config")
66+
def decode(self, stored, *, key=None, context=None):
67+
config = self._codec_config(key, context)
5868
backend = self._get_backend(stored.get("store"), config=config)
5969
return MyRef(stored, backend)
6070
@@ -88,25 +98,41 @@ def get_dtype(self, is_store: bool) -> str:
8898
raise DataJointError(f"<{self.name}> requires @ (store only)")
8999
return "json"
90100

91-
def _extract_context(self, key: dict | None) -> tuple[str, str, str, dict]:
101+
def _extract_context(self, key: dict | None, context: dict | None = None) -> tuple[str, str, str, dict]:
92102
"""
93-
Extract schema, table, field, and primary key from context dict.
103+
Extract schema, table, field, and primary key.
94104
95105
Parameters
96106
----------
97107
key : dict or None
98-
Context dict with ``_schema``, ``_table``, ``_field``,
99-
and primary key values.
108+
Primary key values. Before 2.3.4 this also carried connection
109+
context under ``_schema``, ``_table``, ``_field`` and ``_config``;
110+
those keys are still populated and still read, with a
111+
``DeprecationWarning``, when ``context`` is not supplied.
112+
context : dict or None
113+
Connection context with ``schema``, ``table``, ``field`` and
114+
``config``. Pass the ``context`` argument your ``encode``/``decode``
115+
received.
100116
101117
Returns
102118
-------
103119
tuple[str, str, str, dict]
104120
``(schema, table, field, primary_key)``
105121
"""
106122
key = dict(key) if key else {}
107-
schema = key.pop("_schema", "unknown")
108-
table = key.pop("_table", "unknown")
109-
field = key.pop("_field", "data")
123+
if context is None and any(k.startswith("_") for k in key):
124+
warnings.warn(
125+
"Reading connection context from the `key` dict is deprecated and will "
126+
"be removed in DataJoint 2.4. Accept a `context` argument in encode()/decode() "
127+
"and pass it to _extract_context(key, context). See "
128+
"https://github.com/datajoint/datajoint-python/issues/1550",
129+
DeprecationWarning,
130+
stacklevel=2,
131+
)
132+
context = context or {}
133+
schema = context.get("schema", key.pop("_schema", "unknown"))
134+
table = context.get("table", key.pop("_table", "unknown"))
135+
field = context.get("field", key.pop("_field", "data"))
110136
primary_key = {k: v for k, v in key.items() if not k.startswith("_")}
111137
return schema, table, field, primary_key
112138

‎src/datajoint/codecs.py‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class MyTable(dj.Manual):
3838

3939
from __future__ import annotations
4040

41+
import inspect
4142
import json
4243
import logging
4344
from abc import ABC, abstractmethod
@@ -176,6 +177,13 @@ def encode(self, value: Any, *, key: dict | None = None, store_name: str | None
176177
-------
177178
any
178179
Value in the format expected by the dtype.
180+
181+
Notes
182+
-----
183+
Implementations may also accept ``context`` (a dict carrying ``schema``,
184+
``table``, ``field`` and ``config``); DataJoint passes it only to codecs
185+
whose signature declares it, so adding it is optional and omitting it
186+
keeps a codec working unchanged. See :meth:`_codec_config`.
179187
"""
180188
...
181189

@@ -195,9 +203,41 @@ def decode(self, stored: Any, *, key: dict | None = None) -> Any:
195203
-------
196204
any
197205
The reconstructed Python object.
206+
207+
Notes
208+
-----
209+
Implementations may also accept ``context``; see :meth:`encode`.
198210
"""
199211
...
200212

213+
@staticmethod
214+
def _codec_config(key: dict | None = None, context: dict | None = None):
215+
"""
216+
Return the calling connection's config, or None.
217+
218+
Always thread the result into ``_build_path`` and ``_get_backend``. Those
219+
helpers fall back to the global ``dj.config`` without it, which in a
220+
process holding connections for several users belongs to none of them and
221+
resolves a different store silently rather than raising.
222+
223+
Prefers ``context["config"]``. Falls back to ``key["_config"]``, the
224+
pre-2.3.4 location, which DataJoint still populates.
225+
226+
Parameters
227+
----------
228+
key : dict, optional
229+
The ``key`` argument the codec received.
230+
context : dict, optional
231+
The ``context`` argument the codec received, if it declares one.
232+
233+
Returns
234+
-------
235+
Config or None
236+
"""
237+
if context and context.get("config") is not None:
238+
return context["config"]
239+
return (key or {}).get("_config")
240+
201241
def validate(self, value: Any) -> None:
202242
"""
203243
Validate a value before encoding.
@@ -617,14 +657,21 @@ def decode_attribute(attr, data, squeeze: bool = False, connection=None):
617657
elif final_dtype.lower() == "binary(16)":
618658
data = uuid_module.UUID(bytes=data)
619659

620-
# Build decode key with config if connection is available
660+
# Build decode key with config if connection is available. The
661+
# underscore key stays for codecs written against it; `context` carries
662+
# the same config to codecs that declare one -- see #1550.
621663
decode_key = None
664+
decode_context = None
622665
if connection is not None:
623666
decode_key = {"_config": connection._config}
667+
decode_context = {"config": connection._config}
624668

625669
# Apply decoders in reverse order: innermost first, then outermost
626670
for codec in reversed(type_chain):
627-
data = codec.decode(data, key=decode_key)
671+
if "context" in inspect.signature(codec.decode).parameters:
672+
data = codec.decode(data, key=decode_key, context=decode_context)
673+
else:
674+
data = codec.decode(data, key=decode_key)
628675

629676
# Squeeze arrays if requested
630677
if squeeze and isinstance(data, np.ndarray):

0 commit comments

Comments
 (0)