feat: infer fanout stages from process annotations#2157
Conversation
Greptile SummaryThis PR eliminates boilerplate
Confidence Score: 5/5Safe to merge — no functional regressions identified across the 13 affected stages. All stages that had
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["ray_stage_spec() called"] --> B["Call is_fanout_stage()"]
B --> C["get_type_hints(type(self).process)"]
C -->|"Exception (AttributeError/NameError/TypeError)"| D["logger.debug + return False"]
C -->|"Success"| E["hints.get('return')"]
E --> F["_annotation_contains_list(annotation)"]
F -->|"annotation is None"| G["return False"]
F -->|"get_origin is list"| H["return True"]
F -->|"get_origin in (UnionType, Union)"| I["Recurse on each arg via get_args"]
I -->|"any arg contains list"| H
I -->|"no arg contains list"| G
F -->|"anything else"| G
H --> J["{is_fanout_stage: True}"]
G --> K["{is_fanout_stage: False}"]
D --> K
%%{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"] --> B["Call is_fanout_stage()"]
B --> C["get_type_hints(type(self).process)"]
C -->|"Exception (AttributeError/NameError/TypeError)"| D["logger.debug + return False"]
C -->|"Success"| E["hints.get('return')"]
E --> F["_annotation_contains_list(annotation)"]
F -->|"annotation is None"| G["return False"]
F -->|"get_origin is list"| H["return True"]
F -->|"get_origin in (UnionType, Union)"| I["Recurse on each arg via get_args"]
I -->|"any arg contains list"| H
I -->|"no arg contains list"| G
F -->|"anything else"| G
H --> J["{is_fanout_stage: True}"]
G --> K["{is_fanout_stage: False}"]
D --> K
Reviews (5): Last reviewed commit: "Merge branch 'main' into fix/issue-1613-..." | 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. |
|
Thanks — I completed the audit and pushed
Validation:
|
sarahyurick
left a comment
There was a problem hiding this comment.
Thanks @nightcityblade ! Can you also go through the respective pytest files for each stage you changed, see if it already has a test to assert that the stage is a fanout stage, and if not add a test to make sure it is still marked as fanout?
Signed-off-by: nightcityblade <nightcityblade@gmail.com>
aa06659 to
697ccb2
Compare
|
Thanks for catching this. I rebased onto current main and added direct stage.fanout assertions in the existing pytest modules for the seven stages that were missing them: SnippetExtractionStage, ReadLongFormManifestStage, ManifestReaderStage, SpeakerSeparationStage, ClusterWiseFilePartitioningStage, ImageReaderStage, and HFDatasetImageReaderStage. The other five removed overrides already had direct assertions in their own modules, and the VAD/transcoding dynamic exceptions retain their dedicated fanout coverage. Validation completed:
|
|
/ok to test 6066959 |
|
/ok to test 56f7b1d |
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)