Skip to content

Commit 74f72ad

Browse files
feat(codecs): explicit context argument, additive to key (#1556)
`encode()` and `decode()` take a `context` argument carrying `schema`, `table`, `field` and `config`, so `key` means what it means everywhere else in DataJoint: the primary key. Passing the calling connection's `config` is what matters. Without it, `_build_path` and `_get_backend` fall back to the global `dj.config`, which in a process holding connections for several users belongs to none of them and resolves a different store silently rather than raising. Nothing breaks. DataJoint passes `context` only to codecs whose signature declares it, the pre-2.3.4 underscore keys are still populated in `key`, and `_extract_context(key)` still accepts one argument. Codecs written before 2.3.4 keep working unchanged; `key` reverting to primary-key-only, and `config` becoming required, wait for 2.4. Closes #1550.
1 parent d0a3730 commit 74f72ad

13 files changed

Lines changed: 380 additions & 70 deletions

File tree

‎src/datajoint/builtin_codecs/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ class GraphCodec(dj.Codec):
2929
def get_dtype(self, is_store: bool) -> str:
3030
return "<blob>" # Compose with blob for serialization
3131
32-
def encode(self, graph, *, key=None, store_name=None):
32+
def encode(self, graph, *, key=None, context=None, store_name=None):
3333
# Convert graph to a serializable format
3434
return {
3535
'nodes': list(graph.nodes(data=True)),
3636
'edges': list(graph.edges(data=True)),
3737
}
3838
39-
def decode(self, stored, *, key=None):
39+
def decode(self, stored, *, key=None, context=None):
4040
# Reconstruct graph from stored format
4141
G = nx.Graph()
4242
G.add_nodes_from(stored['nodes'])

‎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: 15 additions & 6 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
@@ -66,7 +68,11 @@ def encode(self, value: bytes, *, key: dict | None = None, store_name: str | Non
6668
value : bytes
6769
Raw bytes to store.
6870
key : dict, optional
69-
Context dict with ``_schema`` for path isolation.
71+
Primary key values. Before 2.3.4 this also carried the schema name
72+
under ``_schema``; that is still read when ``context`` omits it.
73+
context : dict, optional
74+
Connection context. ``schema`` isolates the stored path and
75+
``config`` resolves the store.
7076
store_name : str, optional
7177
Store to use. If None, uses default store.
7278
@@ -77,11 +83,14 @@ def encode(self, value: bytes, *, key: dict | None = None, store_name: str | Non
7783
"""
7884
from ..hash_registry import put_hash
7985

80-
schema_name = (key or {}).get("_schema", "unknown")
81-
config = (key or {}).get("_config")
86+
# Same precedence as `_extract_context`. Reading `key` alone would store
87+
# under "unknown" for any caller that passes `context` with a key holding
88+
# only primary-key values -- which is every caller once 2.4 lands.
89+
schema_name = (context or {}).get("schema", (key or {}).get("_schema", "unknown"))
90+
config = self._codec_config(key, context)
8291
return put_hash(value, schema_name=schema_name, store_name=store_name, config=config)
8392

84-
def decode(self, stored: dict, *, key: dict | None = None) -> bytes:
93+
def decode(self, stored: dict, *, key: dict | None = None, context: dict | None = None) -> bytes:
8594
"""
8695
Retrieve content using stored metadata.
8796
@@ -99,7 +108,7 @@ def decode(self, stored: dict, *, key: dict | None = None) -> bytes:
99108
"""
100109
from ..hash_registry import get_hash
101110

102-
config = (key or {}).get("_config")
111+
config = self._codec_config(key, context)
103112
return get_hash(stored, config=config)
104113

105114
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: 48 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,27 @@ 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. **Declare ``context=None`` in
42+
``encode`` and ``decode`` and pass it to the helpers**, as the example below
43+
does.
44+
45+
The old underscore keys in ``key`` still work and are still populated, so a
46+
codec written before 2.3.4 keeps running. They are removed in 2.4.
47+
``_extract_context`` warns when it falls back to them; ``_codec_config`` and
48+
a direct ``key["_config"]`` do not, so the warning is a prompt rather than a
49+
complete inventory of what still reads them.
3550
3651
Comparison with Hash-addressed:
3752
- **Schema-addressed** (this): Path from schema structure, no dedup
@@ -42,9 +57,9 @@ class SchemaCodec(Codec, register=False):
4257
class MyCodec(SchemaCodec):
4358
name = "my"
4459
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")
60+
def encode(self, value, *, key=None, context=None, store_name=None):
61+
schema, table, field, pk = self._extract_context(key, context)
62+
config = self._codec_config(key, context)
4863
path, _ = self._build_path(
4964
schema, table, field, pk, ext=".dat",
5065
store_name=store_name, config=config,
@@ -53,8 +68,8 @@ def encode(self, value, *, key=None, store_name=None):
5368
backend.put_buffer(serialize(value), path)
5469
return {"path": path, "store": store_name, ...}
5570
56-
def decode(self, stored, *, key=None):
57-
config = (key or {}).get("_config")
71+
def decode(self, stored, *, key=None, context=None):
72+
config = self._codec_config(key, context)
5873
backend = self._get_backend(stored.get("store"), config=config)
5974
return MyRef(stored, backend)
6075
@@ -88,25 +103,41 @@ def get_dtype(self, is_store: bool) -> str:
88103
raise DataJointError(f"<{self.name}> requires @ (store only)")
89104
return "json"
90105

91-
def _extract_context(self, key: dict | None) -> tuple[str, str, str, dict]:
106+
def _extract_context(self, key: dict | None, context: dict | None = None) -> tuple[str, str, str, dict]:
92107
"""
93-
Extract schema, table, field, and primary key from context dict.
108+
Extract schema, table, field, and primary key.
94109
95110
Parameters
96111
----------
97112
key : dict or None
98-
Context dict with ``_schema``, ``_table``, ``_field``,
99-
and primary key values.
113+
Primary key values. Before 2.3.4 this also carried connection
114+
context under ``_schema``, ``_table``, ``_field`` and ``_config``;
115+
those keys are still populated and still read, with a
116+
``DeprecationWarning``, when ``context`` is not supplied.
117+
context : dict or None
118+
Connection context with ``schema``, ``table``, ``field`` and
119+
``config``. Pass the ``context`` argument your ``encode``/``decode``
120+
received.
100121
101122
Returns
102123
-------
103124
tuple[str, str, str, dict]
104125
``(schema, table, field, primary_key)``
105126
"""
106127
key = dict(key) if key else {}
107-
schema = key.pop("_schema", "unknown")
108-
table = key.pop("_table", "unknown")
109-
field = key.pop("_field", "data")
128+
if context is None and any(k.startswith("_") for k in key):
129+
warnings.warn(
130+
"Reading connection context from the `key` dict is deprecated and will "
131+
"be removed in DataJoint 2.4. Accept a `context` argument in encode()/decode() "
132+
"and pass it to _extract_context(key, context). See "
133+
"https://github.com/datajoint/datajoint-python/issues/1550",
134+
DeprecationWarning,
135+
stacklevel=2,
136+
)
137+
context = context or {}
138+
schema = context.get("schema", key.pop("_schema", "unknown"))
139+
table = context.get("table", key.pop("_table", "unknown"))
140+
field = context.get("field", key.pop("_field", "data"))
110141
primary_key = {k: v for k, v in key.items() if not k.startswith("_")}
111142
return schema, table, field, primary_key
112143

0 commit comments

Comments
 (0)