File tree Expand file tree Collapse file tree
Expand file tree Collapse file tree Original file line number Diff line number Diff 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
Original file line number Diff line number Diff 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+
96110def _collect_from_paths (session : Session ) -> None :
97111 """Collect tasks from paths.
98112
Original file line number Diff line number Diff line change 55import warnings
66from pathlib import Path
77
8+ import cloudpickle
89import pytest
910
1011from _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+
407434def test_collect_string_product_raises_error_with_annotation (runner , tmp_path ):
408435 """The string is not converted to a path."""
409436 source = """
You can’t perform that action at this time.
0 commit comments