feat: infer fanout stages from process annotations#2157
Conversation
Signed-off-by: nightcityblade <nightcityblade@gmail.com>
Greptile SummaryThis PR eliminates the need for manual
Confidence Score: 4/5Safe to merge; the inference logic is correct for the annotated cases in the codebase, and all Ray backend consumers already use The core change is a clean, well-scoped addition. The only gaps are a missing non-fanout regression test and silent swallowing of hint-resolution failures with no diagnostic output — both quality concerns rather than correctness defects on the current code paths. The new exception handler in Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["ray_stage_spec() called on ProcessingStage"] --> B["is_fanout_stage()"]
B --> C["get_type_hints(type(self).process)"]
C --> D{Exception?}
D -- "AttributeError / NameError / TypeError" --> E["return False (silent)"]
D -- "Success" --> F["process_hints.get('return')"]
F --> G["_annotation_contains_list(annotation)"]
G --> H{annotation is None?}
H -- Yes --> I["return False"]
H -- No --> J{get_origin is list?}
J -- Yes --> K["return True fanout"]
J -- No --> L{get_origin in UnionType or Union?}
L -- Yes --> M["recurse on each Union arg"]
M --> G
L -- No --> N["return False"]
K --> O["ray_stage_spec returns is_fanout_stage True"]
E --> P["ray_stage_spec returns is_fanout_stage False"]
I --> P
N --> P
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["ray_stage_spec() called on ProcessingStage"] --> B["is_fanout_stage()"]
B --> C["get_type_hints(type(self).process)"]
C --> D{Exception?}
D -- "AttributeError / NameError / TypeError" --> E["return False (silent)"]
D -- "Success" --> F["process_hints.get('return')"]
F --> G["_annotation_contains_list(annotation)"]
G --> H{annotation is None?}
H -- Yes --> I["return False"]
H -- No --> J{get_origin is list?}
J -- Yes --> K["return True fanout"]
J -- No --> L{get_origin in UnionType or Union?}
L -- Yes --> M["recurse on each Union arg"]
M --> G
L -- No --> N["return False"]
K --> O["ray_stage_spec returns is_fanout_stage True"]
E --> P["ray_stage_spec returns is_fanout_stage False"]
I --> P
N --> P
Reviews (1): Last reviewed commit: "feat: infer fanout stages from process a..." | Re-trigger Greptile |
| try: | ||
| process_hints = get_type_hints(type(self).process) | ||
| except (AttributeError, NameError, TypeError): | ||
| return False |
There was a problem hiding this comment.
Silent
False return with no diagnostic logging makes misclassification hard to debug. If a stage's process() annotation references a type that's only imported under TYPE_CHECKING (a common pattern), get_type_hints() raises NameError, the exception is swallowed, and the stage silently behaves as non-fanout — with no indication of why. A logger.debug here would make such failures visible.
| try: | |
| process_hints = get_type_hints(type(self).process) | |
| except (AttributeError, NameError, TypeError): | |
| return False | |
| try: | |
| process_hints = get_type_hints(type(self).process) | |
| except (AttributeError, NameError, TypeError) as exc: | |
| logger.debug( | |
| "Could not resolve type hints for {}.process; defaulting is_fanout_stage to False: {}", | |
| type(self).__name__, | |
| exc, | |
| ) | |
| return False |
| class TestProcessingStageFanoutDetection: | ||
| def test_base_ray_stage_spec_marks_list_outputs_as_fanout(self): | ||
| stage = MockFanoutStage() |
There was a problem hiding this comment.
There is no test asserting that
is_fanout_stage() returns False for a stage whose process() returns a single item, nor that ray_stage_spec() emits {"is_fanout_stage": False} in that case. Without this regression test, a future change that accidentally makes _annotation_contains_list always return True would go undetected.
| class TestProcessingStageFanoutDetection: | |
| def test_base_ray_stage_spec_marks_list_outputs_as_fanout(self): | |
| stage = MockFanoutStage() | |
| class TestProcessingStageFanoutDetection: | |
| def test_base_ray_stage_spec_marks_non_list_outputs_as_non_fanout(self): | |
| stage = MockStageA() | |
| assert stage.is_fanout_stage() is False | |
| assert stage.ray_stage_spec()["is_fanout_stage"] is False | |
| def test_base_ray_stage_spec_marks_list_outputs_as_fanout(self): | |
| stage = MockFanoutStage() |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
sarahyurick
left a comment
There was a problem hiding this comment.
Hi @nightcityblade I will echo previous feedback from other PRs you opened for this issue:
"""
Let's remove the ray_stage_spec function from the actual stages and make sure the tests still pass. The ones I found:
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/text/download/base/url_generation.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/image/io/image_reader.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/audio/datasets/fleurs/create_initial_manifest.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/audio/common.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/video/clipping/clip_extraction_stages.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/audio/alm/pretrain/extraction.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/audio/segmentation/speaker_separation.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/file_partitioning.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/audio/datasets/readspeech/create_initial_manifest.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/audio/alm/pretrain/io.py
- https://github.com/NVIDIA-NeMo/Curator/blob/main/nemo_curator/stages/deduplication/semantic/pairwise_io.py
Do all of these work with this PR? Are there any that should be considered exceptions/explicitly keep ray_stage_spec for some reason?
"""
|
Thanks for flagging the broader set of I haven’t pushed on this PR yet because I want to verify that list carefully and keep the branch focused on overrides that can be removed without changing behavior. I’ll follow up on this PR once I’ve finished that pass. |
Description
closes #1613
is_fanout_stagefrom the annotatedprocess()return type in the baseProcessingStagelist[Task]and unions that includelist[...]as fanout-capable outputsray_stage_spec()override fromURLGenerationStagenow that the base behavior covers itUsage
Checklist
Validation run locally:
python3 -m ruff check nemo_curator/stages/base.py nemo_curator/stages/text/download/base/url_generation.py tests/stages/common/test_base.py tests/stages/text/download/base/test_url_generation.pypython3 -m pytest --noconftest tests/stages/common/test_base.py tests/stages/text/download/base/test_url_generation.py -q(blocked in this environment:cosmos_xennais not installed for package import; the full test setup also requiresrayviatests/conftest.py)