Skip to content

Commit e642bf0

Browse files
authored
Clear annotation_locals after collection (#776)
1 parent eed75f4 commit e642bf0

3 files changed

Lines changed: 43 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and
1010
- {pull}`766` moves runtime profiling persistence from SQLite to a JSON snapshot plus
1111
append-only journal in `.pytask/`, keeping runtime data resilient to crashes and
1212
compacted on normal build exits.
13+
- {pull}`776` clears decoration-time `annotation_locals` snapshots after collection so
14+
task functions remain picklable in process-based parallel backends.
1315

1416
## 0.5.8 - 2025-12-30
1517

src/_pytask/collect.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,23 @@ def pytask_collect(session: Session) -> bool:
9090
session=session, reports=session.collection_reports, tasks=session.tasks
9191
)
9292

93+
_clear_annotation_locals(session.tasks)
94+
9395
return True
9496

9597

98+
def _clear_annotation_locals(tasks: list[PTask]) -> None:
99+
"""Drop decoration-time locals snapshots once collection finishes.
100+
101+
The snapshot is only needed to evaluate deferred annotations while collecting
102+
dependencies/products. Keeping it afterwards can retain non-picklable objects (for
103+
example locks) and break parallel backends that cloudpickle task functions.
104+
"""
105+
for task in tasks:
106+
if isinstance(task.function, TaskFunction):
107+
task.function.pytask_meta.annotation_locals = None
108+
109+
96110
def _collect_from_paths(session: Session) -> None:
97111
"""Collect tasks from paths.
98112

tests/test_collect.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import warnings
66
from pathlib import Path
77

8+
import cloudpickle
89
import pytest
910

1011
from _pytask.collect import _find_shortest_uniquely_identifiable_name_for_tasks
@@ -404,6 +405,32 @@ def task_example() -> 'Annotated[str, OUTPUT]':
404405
assert tmp_path.joinpath("out.txt").exists()
405406

406407

408+
def test_annotation_locals_are_cleared_after_collection_to_allow_pickling(tmp_path):
409+
source = """
410+
import threading
411+
412+
from pytask import task
413+
414+
lock = threading.RLock()
415+
416+
for i in range(2):
417+
@task
418+
def task_example():
419+
return None
420+
"""
421+
tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source))
422+
423+
session = build(paths=tmp_path, dry_run=True)
424+
assert session.exit_code == ExitCode.OK
425+
assert len(session.tasks) == 2
426+
427+
for collected_task in session.tasks:
428+
meta = getattr(collected_task.function, "pytask_meta", None)
429+
assert meta is not None
430+
assert meta.annotation_locals is None
431+
cloudpickle.dumps(collected_task.function)
432+
433+
407434
def test_collect_string_product_raises_error_with_annotation(runner, tmp_path):
408435
"""The string is not converted to a path."""
409436
source = """

0 commit comments

Comments
 (0)