Source code for gfw.common.beam.transforms.core
"""General-purpose PTransforms that don't belong to a specific I/O system."""
import json
import logging
from operator import itemgetter
from typing import Any, Optional, Sequence
import apache_beam as beam
from apache_beam import PTransform
from apache_beam.pvalue import PCollection
from apache_beam.transforms.combiners import Sample
from apache_beam.transforms.window import FixedWindows
logger = logging.getLogger(__name__)
[docs]
class GroupBy(beam.PTransform):
"""Wrapper around :class:`beam.GroupBy` with automatic labeling.
This transform wraps Beam's native :class:`beam.GroupBy` and adds an automatically generated
label based on the grouping keys. For example, grouping by `["user", "country"]`
with `elements="Sessions"` results in a label like ``GroupSessionsByUserAndCountry``.
If ``dict_fields=True`` (default), string positional fields are interpreted as dictionary keys
and wrapped with :func:`operator.itemgetter`. If False, strings are treated as attribute names.
Example:
.. code-block:: python
pcoll | GroupBy("user", "country", elements="Sessions")
Args:
*fields:
Positional key fields to group by. If these are strings and ``dict_fields=True``,
they will be interpreted as dictionary keys.
elements:
A human-readable label describing the grouped elements (e.g., ``Messages`` or
``Sessions``). It is used to generate the step label.
dict_fields:
If True (default), string fields are interpreted as dictionary keys and
wrapped with :func:`operator.itemgetter`. Set to False to use Beam's default behavior
(attribute access).
**kwargs:
Same as :class:`beam.GroupBy` interface.
"""
def __init__(
self, *fields: Any, elements: str = "", dict_fields: bool = True, **kwargs: Any
) -> None:
self._fields = fields
self._elements = elements
self._dict_fields = dict_fields
self._kwargs = kwargs
if self._dict_fields:
self._kwargs.update({k: itemgetter(k) for k in self._fields})
self._fields = ()
keys = list(self._fields) + list(self._kwargs.keys())
super().__init__(label=self.create_label(keys, elements))
[docs]
@classmethod
def create_label(cls, keys: Sequence[str], elements: str) -> str:
"""Generate a descriptive label for the GroupBy transform based on keys and elements.
Constructs a label string combining the human-readable element description and
the grouping keys, formatted in a CamelCase style joined by 'And'.
For example, keys ``['user', 'country']`` and elements 'Sessions' result in
``GroupSessionsByUserAndCountry``.
Args:
keys:
A sequence of key field names used for grouping.
elements:
A human-readable label describing the grouped elements.
Returns:
A formatted string label for use as the PTransform's step label.
"""
key_label = "And".join(s.title() for s in keys)
return f"Group{elements}By{key_label}"
[docs]
def expand(self, pcoll: PCollection) -> PCollection:
"""Applies the wrapped Beam GroupBy transform to the input PCollection."""
return pcoll | beam.GroupBy(*self._fields, **self._kwargs)
[docs]
class SampleAndLogElements(PTransform):
"""A Beam PTransform that logs elements of a PCollection.
Args:
sample_size:
The number of elements to log. If not provided, logs all elements.
window_size:
The window duration in seconds used when sampling unbounded sources;
only applicable when ``sample_size`` is set.
pretty_print:
If True, formats each element as pretty-printed JSON when possible.
message:
A custom string format for the log message. Must contain the placeholder ``{e}``.
"""
def __init__(
self,
sample_size: Optional[int] = None,
window_size: int = 60,
pretty_print: bool = False,
message: str = "Element: {e}",
) -> None:
self._sample_size = sample_size
self._window_size = window_size
self._pretty_print = pretty_print
self._message = message
[docs]
def expand(self, pcoll: PCollection) -> PCollection:
"""Log elements of a PCollection, optionally sampling a ``sample_size`` elements."""
samples = pcoll
if self._sample_size:
samples = (
pcoll
# Windowing is needed for sampling on unbounded sources.
| "Apply Fixed Window" >> beam.WindowInto(FixedWindows(self._window_size))
# Defaults are not supported if you are not using a Global Window.
| "Sample" >> Sample.FixedSizeGlobally(self._sample_size).without_defaults()
| "Flatten Samples" >> beam.FlatMap(lambda elements: elements)
)
_ = samples | "Log Elements" >> beam.Map(self._log_element)
return pcoll
def _log_element(self, element: Any) -> Any:
formatted = element
if self._pretty_print:
formatted = json.dumps(element, indent=4)
log_message = self._message.format(e=formatted)
logger.debug(log_message)
return element