-
Notifications
You must be signed in to change notification settings - Fork 4.6k
[Python] Add PCollection.with_side_outputs for chainable side outputs #38268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
ff094dd
fb348cc
fa48a24
dd2bd04
68cf7bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -27,6 +27,7 @@ | |||||||||||||||
| # pytype: skip-file | ||||||||||||||||
|
|
||||||||||||||||
| import collections | ||||||||||||||||
| import copy | ||||||||||||||||
| import itertools | ||||||||||||||||
| from typing import TYPE_CHECKING | ||||||||||||||||
| from typing import Any | ||||||||||||||||
|
|
@@ -138,19 +139,105 @@ def __or__(self, ptransform): | |||||||||||||||
| return self.pipeline.apply(ptransform, self) | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class _SideOutputsContainer: | ||||||||||||||||
| """Lightweight accessor over named side-output PCollections. | ||||||||||||||||
|
|
||||||||||||||||
| Supports attribute access (``container.dropped``), indexing | ||||||||||||||||
| (``container["dropped"]``), iteration over tag names, ``len()``, and | ||||||||||||||||
| ``in``. ``__getattr__`` on a missing tag raises ``AttributeError`` with | ||||||||||||||||
| a message listing available tags. | ||||||||||||||||
| """ | ||||||||||||||||
| def __init__(self, side_outputs: dict[str, 'PCollection']): | ||||||||||||||||
| object.__setattr__(self, '_side_outputs', dict(side_outputs)) | ||||||||||||||||
|
Comment on lines
+150
to
+151
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| def __getattr__(self, tag: str) -> 'PCollection': | ||||||||||||||||
| if tag.startswith('__'): | ||||||||||||||||
| raise AttributeError(tag) | ||||||||||||||||
|
Comment on lines
+154
to
+155
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||||||||||||
| try: | ||||||||||||||||
| return self._side_outputs[tag] | ||||||||||||||||
| except KeyError as exc: | ||||||||||||||||
| available = sorted(self._side_outputs) | ||||||||||||||||
| raise AttributeError( | ||||||||||||||||
| f"No side output named {tag!r}. Available: {available}") from exc | ||||||||||||||||
|
|
||||||||||||||||
| def __getitem__(self, tag: str) -> 'PCollection': | ||||||||||||||||
| return self._side_outputs[tag] | ||||||||||||||||
|
|
||||||||||||||||
| def __iter__(self): | ||||||||||||||||
| return iter(self._side_outputs) | ||||||||||||||||
|
|
||||||||||||||||
| def __len__(self): | ||||||||||||||||
| return len(self._side_outputs) | ||||||||||||||||
|
|
||||||||||||||||
| def __contains__(self, tag): | ||||||||||||||||
| return tag in self._side_outputs | ||||||||||||||||
|
Comment on lines
+172
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Implementing
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class PCollection(PValue, Generic[T]): | ||||||||||||||||
| """A multiple values (potentially huge) container. | ||||||||||||||||
|
|
||||||||||||||||
| Dataflow users should not construct PCollection objects directly in their | ||||||||||||||||
| pipelines. | ||||||||||||||||
| """ | ||||||||||||||||
| def __init__( | ||||||||||||||||
| self, | ||||||||||||||||
| pipeline: 'Pipeline', | ||||||||||||||||
| tag: Optional[str] = None, | ||||||||||||||||
| element_type: Optional[Union[type, 'typehints.TypeConstraint']] = None, | ||||||||||||||||
| windowing: Optional['Windowing'] = None, | ||||||||||||||||
| is_bounded=True): | ||||||||||||||||
| super().__init__( | ||||||||||||||||
| pipeline, | ||||||||||||||||
| tag=tag, | ||||||||||||||||
| element_type=element_type, | ||||||||||||||||
| windowing=windowing, | ||||||||||||||||
| is_bounded=is_bounded) | ||||||||||||||||
| self._side_outputs: Optional[dict[str, 'PCollection']] = None | ||||||||||||||||
|
|
||||||||||||||||
| def __eq__(self, other): | ||||||||||||||||
| if isinstance(other, PCollection): | ||||||||||||||||
| return self.tag == other.tag and self.producer == other.producer | ||||||||||||||||
|
|
||||||||||||||||
| def __hash__(self): | ||||||||||||||||
| return hash((self.tag, self.producer)) | ||||||||||||||||
|
|
||||||||||||||||
| def __copy__(self): | ||||||||||||||||
| result = type(self).__new__(type(self)) | ||||||||||||||||
| result.__dict__ = self.__dict__.copy() | ||||||||||||||||
| return result | ||||||||||||||||
|
|
||||||||||||||||
| @property | ||||||||||||||||
| def side_outputs(self) -> _SideOutputsContainer: | ||||||||||||||||
| return _SideOutputsContainer(self._side_outputs or {}) | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Accessing
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| def with_side_outputs(self, **side_outputs: 'PCollection') -> 'PCollection': | ||||||||||||||||
| """Return a copy of this PCollection carrying the given side outputs. | ||||||||||||||||
|
|
||||||||||||||||
| Each kwarg becomes accessible as ``result.side_outputs.<tag>``. Tags | ||||||||||||||||
| must be valid Python identifiers (enforced by ``**`` syntax). | ||||||||||||||||
|
|
||||||||||||||||
| Calling ``with_side_outputs`` again replaces any previously-set side | ||||||||||||||||
| outputs on the new copy; the original is unchanged. | ||||||||||||||||
|
|
||||||||||||||||
| This annotation is not preserved across runner API round-trips; inspect | ||||||||||||||||
| ``producer.outputs`` on the deserialized pipeline instead. | ||||||||||||||||
|
|
||||||||||||||||
| Only the main output participates in composite-boundary type checking. | ||||||||||||||||
| """ | ||||||||||||||||
| for side_tag, side_pcoll in side_outputs.items(): | ||||||||||||||||
| if not isinstance(side_pcoll, PCollection): | ||||||||||||||||
| raise TypeError( | ||||||||||||||||
| 'Side output %r must be a PCollection. Got %r.' % | ||||||||||||||||
| (side_tag, side_pcoll)) | ||||||||||||||||
| if side_pcoll.pipeline != self.pipeline: | ||||||||||||||||
| raise ValueError( | ||||||||||||||||
| 'Side output %r must belong to the same pipeline as %r.' % | ||||||||||||||||
| (side_tag, self)) | ||||||||||||||||
|
|
||||||||||||||||
| result = copy.copy(self) | ||||||||||||||||
| result._side_outputs = dict(side_outputs) | ||||||||||||||||
| return result | ||||||||||||||||
|
|
||||||||||||||||
| @property | ||||||||||||||||
| def windowing(self) -> 'Windowing': | ||||||||||||||||
| if not hasattr(self, '_windowing'): | ||||||||||||||||
|
|
||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accessing
result._side_outputsdirectly can raise anAttributeErrorif thePCollectionwas reconstructed (e.g., viafrom_runner_apior other deserialization paths) where_side_outputsis not populated in the instance's__dict__. Usinggetattr(result, '_side_outputs', None)is safer and prevents potential crashes during pipeline replacement or other graph operations on reconstructed pipelines.