Repository navigation
Stream generator nodes decorated with functools.wraps - #5798
Conversation
Signed-off-by: Charan Rathore <[email protected]>
Add test for wrapped generator function in async context Signed-off-by: Charan Rathore <[email protected]>
Added a test to ensure that a wrapped generator node streams chunks correctly without collapsing into a single save. Signed-off-by: Charan Rathore <[email protected]>
Clarify usage of generator nodes and their decorators in Kedro. Signed-off-by: Charan Rathore <[email protected]>
Stream outputs from generator nodes decorated with `functools.wraps` and reject these nodes when asynchronous loading and saving is enabled. Signed-off-by: Charan Rathore <[email protected]>
# Conflicts: # RELEASE.md
|
Sorry, this was closed by mistake, reopening. |
Signed-off-by: Merel Theisen <[email protected]>
merelcht
left a comment
There was a problem hiding this comment.
Thanks @charan-rathore for tackling this. The fix works for the reported case, but I have one blocking concern about a behaviour regression, plus a few smaller points inline.
| # business objects (e.g. ``mne.Epochs``) are passed to | ||
| # ``catalog.save`` unchanged. See kedro-org/kedro#5412. | ||
| if outputs and inspect.isgeneratorfunction(node.func): | ||
| if outputs and inspect.isgeneratorfunction(inspect.unwrap(node.func)): |
There was a problem hiding this comment.
This now decides whether to stream from the declared function, not from what the node actually returns, which silently changes behaviour for existing nodes. A @wraps decorator that consumes the generator (e.g. return list(f(*args, **kwargs))) worked before: the list was saved once. With this change the runner iterates the list and calls catalog.save once per element, which for datasets that overwrite on save keeps only the last element. The same applies to a @wraps(gen) wrapper returning a str, DataFrame or dict (the runner would iterate characters, column labels or keys). The documented caveat is the only safeguard.
Node._outputs_to_dictionary already uses inspect.isgenerator on the actual return value. Could we stream only when the unwrapped function is a generator function and the outputs are generators, and otherwise keep the old behaviour?
| def execute(self) -> Node: | ||
| if self.is_async and inspect.isgeneratorfunction(self.node.func): | ||
| if self.is_async and inspect.isgeneratorfunction( | ||
| inspect.unwrap(self.node.func) |
There was a problem hiding this comment.
The same unwrap logic is now duplicated at both call sites, and it has two gaps:
inspect.unwrapraisesValueError: wrapper loopfor cyclic__wrapped__chains, which would surface as an opaque error here wheremainwas fine.functools.partial(wrapped)is still not detected, becauseunwrapdoesn't followpartial.func.
A small private helper (e.g. _is_generator_node_func) that handles partial, unwrap and the cycle fallback would cover both call sites.
| assert fake_dataset.save.call_count == 10 | ||
| assert fake_dataset.save.call_args_list == expected | ||
|
|
||
| def test_wrapped_generator_node_streams_chunks(self, mocker, catalog): |
There was a problem hiding this comment.
Nice that this fails on main. Could we add a few more cases?
- A decorator that consumes the generator (
list(...)), asserting it is still saved once. This would have caught the regression above. partial(wrapped).- A class-based decorator using
update_wrapper. - A multi-output (tuple/dict) wrapped generator.
ParallelRunner, which picklesnode.func, so it needs a module-level decorated function rather than a local closure.
| kedro new --starter=pandas-iris --checkout=0.18.14 | ||
| ``` | ||
|
|
||
| A generator node wrapped by a decorator using `functools.wraps` is streamed in the same way as the original generator function. The decorator must return the generator rather than consuming it. Asynchronous data loading and saving does not support generator nodes, decorated or not. |
There was a problem hiding this comment.
This paragraph sits at the end of "Set up the project", straight after the legacy kedro new --starter=pandas-iris --checkout=0.18.14 block, so it reads as a non-sequitur and falls under the 0.18.14-only context. Could it move to the generator sections below, perhaps under its own heading such as "Use decorators with generator nodes"?
I'd also tighten the wording: "consuming" is jargon, and "does not support" doesn't say what happens. For example:
If you decorate a generator function with a decorator that uses `functools.wraps`, Kedro still streams its output to the catalog in the same way as the undecorated function. The decorator must return the generator produced by the wrapped function without iterating over it, for example by calling `list()` on it.
!!! note
Generator nodes, whether decorated or not, cannot be run with asynchronous data loading and saving. Kedro raises a `ValueError` if you use `--async` with a generator node.
merelcht
left a comment
There was a problem hiding this comment.
Requesting changes because of the behaviour regression described in my inline comment on kedro/runner/task.py:178: a @wraps decorator that consumes the generator would now be streamed element by element instead of saved once. The other inline comments (shared helper, extra tests, release note, docs placement) are smaller and can be addressed together.
Signed-off-by: Charan Rathore <[email protected]>
|
Thanks, you are right about the regression. Pushed 222664c.
I ran Prepared with AI assistance; Charan Rathore is directing and responsible for this PR. |
|
Gentle nudge - the unwrap logic is consolidated and the extra generator cases are in. Anything else you'd like changed before this can move forward? |
merelcht
left a comment
There was a problem hiding this comment.
Thanks @charan-rathore this looks good to me now 👍
I'll ask the team for a second review.
SajidAlamQB
left a comment
There was a problem hiding this comment.
Thanks @charan-rathore this looks good to me, PR is blocked due to coverage so please add the necessary tests. Happy to approve once done.
CI coverage requires every line: the cyclic-chain exit in _is_generator_node_func (kedro/runner/task.py:351) was the one uncovered line. The test loops two functions' __wrapped__ attributes into each other and asserts the walk terminates and reports the function is not a generator. Signed-off-by: Charan Rathore <[email protected]>
…1/2) Signed-off-by: Charan Rathore <[email protected]>
CI coverage requires every line: the cyclic-chain exit in _is_generator_node_func (kedro/runner/task.py:351) was the one uncovered line. The test loops two functions' __wrapped__ attributes into each other and asserts the walk terminates and reports the function is not a generator. Signed-off-by: Charan Rathore <[email protected]>
|
Thanks Sajid. The one uncovered line was the cyclic wrapped chain exit in _is_generator_node_func (kedro/runner/task.py:351), so I added test_cyclic_wrapped_chain_is_not_a_generator to tests/runner/test_task.py: two functions whose wrapped attributes point at each other, asserting the walk terminates instead of looping and reports the function is not a generator. Verified locally: tests/runner/test_task.py is 11/11, and a targeted coverage run of that file shows line 351 covered now. (The commit landed as a small chain via the web UI - my push access was playing up - but the net diff is just the import and the new test.) |
ankatiyar
left a comment
There was a problem hiding this comment.
Thanks @charan-rathore
Description
Fixes #5796. Kedro checks whether a node is a generator function before streaming its output. A decorator made with
functools.wrapshides that function frominspect.isgeneratorfunction, so the runner saved the generator object once instead of saving each chunk. The two runner checks now unwrap the callable for this decision. This also makes async loading and saving reject a decorated generator the same way as an undecorated one. Other iterable objects remain ordinary outputs.Development notes
Added tests for chunk-by-chunk saves and the async rejection. Updated the generator-node documentation and upcoming release note.
pytest tests/runner/test_run_generator_node.py tests/runner/test_task.py -q -o addopts='': 20 passed. The runner suite passed with one unrelated CPU-count-sensitive test excluded (306 passed, one skipped, ten deselected); without exclusion, 315 passed, one skipped, one unrelated failure. Ruff import/format checks pass.Instinct assisted with the change and tests; Charan reviewed before opening.
Developer Certificate of Origin
The commit is signed off as Charan Rathore.
Checklist