Skip to content

Commit 171f5f3

Browse files
Merge branch 'master' into fix/cascade-multi-part-master
2 parents 179c39b + d9106a6 commit 171f5f3

20 files changed

Lines changed: 1189 additions & 971 deletions

.github/workflows/test.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ on:
2727
jobs:
2828
test:
2929
runs-on: ubuntu-latest
30+
strategy:
31+
fail-fast: false
32+
matrix:
33+
# Exercise both ends of the supported range (requires-python
34+
# >=3.10,<3.15). The version-pinned pixi environments are defined in
35+
# pyproject.toml under [tool.pixi.environments].
36+
environment: [test-py310, test-py314]
37+
name: test (${{ matrix.environment }})
3038
steps:
3139
- uses: actions/checkout@v4
3240

@@ -35,9 +43,10 @@ jobs:
3543
with:
3644
cache: true
3745
locked: false
46+
environments: ${{ matrix.environment }}
3847

3948
- name: Run tests
40-
run: pixi run -e test test-cov
49+
run: pixi run -e ${{ matrix.environment }} test-cov
4150

4251
# Unit tests run without containers (faster feedback)
4352
unit-tests:

pyproject.toml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ dependencies = [
1919
"packaging",
2020
]
2121

22-
requires-python = ">=3.10,<3.14"
22+
requires-python = ">=3.10,<3.15"
2323
authors = [
2424
{name = "Dimitri Yatsenko", email = "dimitri@datajoint.com"},
2525
{name = "Thinh Nguyen", email = "thinh@datajoint.com"},
@@ -57,6 +57,11 @@ keywords = [
5757
# https://pypi.org/classifiers/
5858
classifiers = [
5959
"Programming Language :: Python",
60+
"Programming Language :: Python :: 3.10",
61+
"Programming Language :: Python :: 3.11",
62+
"Programming Language :: Python :: 3.12",
63+
"Programming Language :: Python :: 3.13",
64+
"Programming Language :: Python :: 3.14",
6065
"Development Status :: 5 - Production/Stable",
6166
"Intended Audience :: Science/Research",
6267
"Intended Audience :: Healthcare Industry",
@@ -250,10 +255,22 @@ datajoint = { path = ".", editable = true, extras = ["test"] }
250255
[tool.pixi.feature.dev.pypi-dependencies]
251256
datajoint = { path = ".", editable = true, extras = ["dev", "test"] }
252257

258+
# Pin the interpreter for the min/max supported versions so CI can exercise
259+
# both ends of the requires-python range (see .github/workflows/test.yaml).
260+
[tool.pixi.feature.py310.dependencies]
261+
python = "3.10.*"
262+
263+
[tool.pixi.feature.py314.dependencies]
264+
python = "3.14.*"
265+
253266
[tool.pixi.environments]
254267
default = { solve-group = "default" }
255268
dev = { features = ["dev"], solve-group = "default" }
256269
test = { features = ["test"], solve-group = "default" }
270+
# Version-pinned test environments (each solves independently — a solve-group
271+
# cannot span different Python versions).
272+
test-py310 = { features = ["test", "py310"] }
273+
test-py314 = { features = ["test", "py314"] }
257274

258275
[tool.pixi.tasks]
259276
# Tests use testcontainers - no manual setup required
@@ -265,7 +282,7 @@ services-down = "docker compose down"
265282
test-external = { cmd = "DJ_USE_EXTERNAL_CONTAINERS=1 pytest tests/", depends-on = ["services-up"] }
266283

267284
[tool.pixi.dependencies]
268-
python = ">=3.10,<3.14"
285+
python = ">=3.10,<3.15"
269286
graphviz = ">=13.1.2,<14"
270287

271288
[tool.pixi.activation]

src/datajoint/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ def FreeTable(conn_or_name, full_table_name: str | None = None) -> _FreeTable:
284284
"diagram": (".diagram", None), # Return the module itself
285285
# cli imports click
286286
"cli": (".cli", "cli"),
287-
# gc — exposed lazily so `dj.gc.scan(...)` works as documented in gc.py
287+
# gc — exposed lazily so `dj.gc.GarbageCollector(...)` works as documented in gc.py
288288
# and in the user docs (how-to/garbage-collection.md).
289289
"gc": (".gc", None), # Return the module itself
290290
}

src/datajoint/autopopulate.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@
2222

2323
logger = logging.getLogger(__name__.split(".")[0])
2424

25+
# Parallel populate hands the live table and its open connection to workers by
26+
# process inheritance, so it requires the `fork` start method. Python 3.14
27+
# changed the default from `fork` to `forkserver`, which pickles the payload
28+
# instead — that neither carries a live DB connection nor the table's internal
29+
# state, and it deadlocks. Pin to `fork` where available (all POSIX platforms);
30+
# fall back to the platform default elsewhere.
31+
_MP_START_METHOD = "fork" if "fork" in mp.get_all_start_methods() else None
32+
2533

2634
# --- helper functions for multiprocessing --
2735

@@ -30,7 +38,8 @@ def _initialize_populate(table: Table, jobs: Job | None, populate_kwargs: dict[s
3038
"""
3139
Initialize a worker process for multiprocessing.
3240
33-
Saves the unpickled table to the current process and reconnects to database.
41+
Stores the inherited table on the worker process and reconnects to database
42+
(the parent closes its connection before forking; each worker reopens one).
3443
3544
Parameters
3645
----------
@@ -476,7 +485,9 @@ def _populate_direct(
476485
if hasattr(self.connection._conn, "ctx"):
477486
del self.connection._conn.ctx
478487
with (
479-
mp.Pool(processes, _initialize_populate, (self, None, populate_kwargs)) as pool,
488+
mp.get_context(_MP_START_METHOD).Pool(
489+
processes, _initialize_populate, (self, None, populate_kwargs)
490+
) as pool,
480491
tqdm(desc="Processes: ", total=nkeys) if display_progress else contextlib.nullcontext() as progress_bar,
481492
):
482493
for status in pool.imap(_call_populate1, keys, chunksize=1):
@@ -572,7 +583,9 @@ def handler(signum, frame):
572583
if hasattr(self.connection._conn, "ctx"):
573584
del self.connection._conn.ctx # SSLContext is not pickleable
574585
with (
575-
mp.Pool(processes, _initialize_populate, (self, self.jobs, populate_kwargs)) as pool,
586+
mp.get_context(_MP_START_METHOD).Pool(
587+
processes, _initialize_populate, (self, self.jobs, populate_kwargs)
588+
) as pool,
576589
tqdm(desc="Processes: ", total=nkeys)
577590
if display_progress
578591
else contextlib.nullcontext() as progress_bar,
@@ -866,8 +879,6 @@ def _update_job_metadata(self, key, start_time, duration, version):
866879

867880
pk_condition = make_condition(self, key, set())
868881
self.connection.query(
869-
f"UPDATE {self.full_table_name} SET "
870-
"_job_start_time=%s, _job_duration=%s, _job_version=%s "
871-
f"WHERE {pk_condition}",
882+
f"UPDATE {self.full_table_name} SET _job_start_time=%s, _job_duration=%s, _job_version=%s WHERE {pk_condition}",
872883
args=(start_time, duration, version[:64] if version else ""),
873884
)

src/datajoint/builtin_codecs/hash.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class HashCodec(Codec):
2121
The database column stores JSON metadata: ``{hash, store, size}``.
2222
Duplicate content is automatically deduplicated across all tables.
2323
24-
Deletion: Requires garbage collection via ``dj.gc.collect()``.
24+
Deletion: Requires garbage collection via ``dj.gc.GarbageCollector``.
2525
2626
External only - requires @ modifier.
2727

src/datajoint/builtin_codecs/npy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ class Recording(dj.Manual):
272272
- Path: ``{schema}/{table}/{pk}/{attribute}.npy``
273273
- Database column: JSON with ``{path, store, dtype, shape}``
274274
275-
Deletion: Requires garbage collection via ``dj.gc.collect()``.
275+
Deletion: Requires garbage collection via ``dj.gc.GarbageCollector``.
276276
277277
See Also
278278
--------

src/datajoint/builtin_codecs/object.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def make(self, key):
5353
5454
{store_root}/{schema}/{table}/{pk}/{field}/
5555
56-
Deletion: Requires garbage collection via ``dj.gc.collect()``.
56+
Deletion: Requires garbage collection via ``dj.gc.GarbageCollector``.
5757
5858
Comparison with hash-addressed::
5959

src/datajoint/builtin_codecs/schema.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def _build_path(
114114
Build schema-addressed storage path.
115115
116116
Constructs a path that mirrors the database schema structure:
117-
``{schema}/{table}/{pk_values}/{field}{ext}``
117+
``{schema_prefix}/{schema}/{table}/{pk_values}/{field}{ext}``
118118
119119
Supports partitioning if configured in the store.
120120
@@ -150,6 +150,7 @@ def _build_path(
150150
spec = config.get_store_spec(store_name)
151151
partition_pattern = spec.get("partition_pattern")
152152
token_length = spec.get("token_length", 8)
153+
schema_prefix = spec["schema_prefix"] # always present: settings applies the default
153154

154155
return build_object_path(
155156
schema=schema,
@@ -159,6 +160,7 @@ def _build_path(
159160
ext=ext,
160161
partition_pattern=partition_pattern,
161162
token_length=token_length,
163+
schema_prefix=schema_prefix,
162164
)
163165

164166
def _get_backend(self, store_name: str | None = None, config=None):

src/datajoint/codecs.py

Lines changed: 40 additions & 0 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 json
4142
import logging
4243
from abc import ABC, abstractmethod
4344
from typing import Any
@@ -219,6 +220,45 @@ def validate(self, value: Any) -> None:
219220
"""
220221
pass
221222

223+
def referenced_paths(self, stored: Any) -> list[tuple[str, str | None]]:
224+
"""
225+
Return the external store paths this stored value references.
226+
227+
Garbage collection and delete-time cleanup call this on the *stored*
228+
(encoded) representation of a column value — the JSON/dict already in
229+
the database — so discovery stays metadata-only (no download or decode).
230+
231+
The default recognizes DataJoint's standard external-storage metadata:
232+
a dict (or JSON string) carrying a ``path`` and optional ``store``. This
233+
covers hash-addressed (``<hash@>``/``<blob@>``/``<attach@>``) and
234+
schema-addressed (``<object@>``/``<npy@>`` and any :class:`SchemaCodec`
235+
subclass) storage with no extra work, and returns an empty list for
236+
values that are not externally stored (e.g. in-table ``<blob>``).
237+
238+
Override only if your codec stores external references in a non-standard
239+
shape. Returning the paths here is what lets garbage collection see a
240+
custom codec's files as *referenced* rather than orphaned (see #1469).
241+
242+
Parameters
243+
----------
244+
stored : any
245+
The stored (encoded) value as read from the database column.
246+
247+
Returns
248+
-------
249+
list[tuple[str, str | None]]
250+
``(path, store_name)`` for each external artifact referenced.
251+
"""
252+
value = stored
253+
if isinstance(value, str):
254+
try:
255+
value = json.loads(value)
256+
except (json.JSONDecodeError, TypeError, ValueError):
257+
return []
258+
if isinstance(value, dict) and "path" in value:
259+
return [(value["path"], value.get("store"))]
260+
return []
261+
222262
def __repr__(self) -> str:
223263
return f"<{self.__class__.__name__}(name={self.name!r})>"
224264

0 commit comments

Comments
 (0)