| # | |
| # Licensed to the Apache Software Foundation (ASF) under one or more | |
| # contributor license agreements. See the NOTICE file distributed with | |
| # this work for additional information regarding copyright ownership. | |
| # The ASF licenses this file to You under the Apache License, Version 2.0 | |
| # (the "License"); you may not use this file except in compliance with | |
| # the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| import contextlib | |
| import random | |
| import threading | |
| from collections.abc import Callable | |
| from collections.abc import Iterable | |
| from typing import Any | |
| from typing import Generic | |
| from typing import Optional | |
| from typing import TypeVar | |
| from apache_beam.dataframe import partitionings | |
| class Session(object): | |
| """A session represents a mapping of expressions to concrete values. | |
| The bindings typically include required placeholders, but may be any | |
| intermediate expression as well. | |
| """ | |
| def __init__(self, bindings=None): | |
| self._bindings = dict(bindings or {}) | |
| def evaluate(self, expr: 'Expression') -> Any: | |
| if expr not in self._bindings: | |
| self._bindings[expr] = expr.evaluate_at(self) | |
| return self._bindings[expr] | |
| def lookup(self, expr: 'Expression') -> Any: | |
| return self._bindings[expr] | |
| class PartitioningSession(Session): | |
| """An extension of Session that enforces actual partitioning of inputs. | |
| Each expression is evaluated multiple times for various supported | |
| partitionings determined by its `requires_partition_by` specification. For | |
| each tested partitioning, the input is partitioned and the expression is | |
| evaluated on each partition separately, as if this were actually executed in | |
| a parallel manner. | |
| For each input partitioning, the results are verified to be partitioned | |
| appropriately according to the expression's `preserves_partition_by` | |
| specification. | |
| For testing only. | |
| """ | |
| def evaluate(self, expr): | |
| import collections | |
| import pandas as pd | |
| def is_scalar(expr): | |
| return not isinstance(expr.proxy(), pd.core.generic.NDFrame) | |
| if expr not in self._bindings: | |
| if is_scalar(expr) or not expr.args(): | |
| result = super().evaluate(expr) | |
| else: | |
| scaler_args = [arg for arg in expr.args() if is_scalar(arg)] | |
| def evaluate_with(input_partitioning): | |
| parts = collections.defaultdict( | |
| lambda: Session({arg: self.evaluate(arg) | |
| for arg in scaler_args})) | |
| for arg in expr.args(): | |
| if not is_scalar(arg): | |
| input = self.evaluate(arg) | |
| for key, part in input_partitioning.test_partition_fn(input): | |
| parts[key]._bindings[arg] = part | |
| if not parts: | |
| parts[None] # Create at least one entry. | |
| results = [] | |
| for session in parts.values(): | |
| if any(len(session.lookup(arg)) for arg in expr.args() | |
| if not is_scalar(arg)): | |
| results.append(session.evaluate(expr)) | |
| expected_output_partitioning = output_partitioning( | |
| expr, input_partitioning) | |
| if not expected_output_partitioning.check(results): | |
| raise AssertionError( | |
| f"""Expression does not preserve partitioning! | |
| Expression: {expr} | |
| Requires: {expr.requires_partition_by()} | |
| Preserves: {expr.preserves_partition_by()} | |
| Input partitioning: {input_partitioning} | |
| Expected output partitioning: {expected_output_partitioning} | |
| """) | |
| if results: | |
| return pd.concat(results) | |
| else: | |
| # Choose any single session. | |
| return next(iter(parts.values())).evaluate(expr) | |
| # Store random state so it can be re-used for each execution, in case | |
| # the expression is part of a test that relies on the random seed. | |
| random_state = random.getstate() | |
| result = None | |
| # Run with all supported partitionings s.t. the smallest subpartitioning | |
| # is used last. This way the final result is computed with the most | |
| # challenging partitioning. Avoids heisenbugs where sometimes the result | |
| # is computed trivially with Singleton partitioning and passes. | |
| for input_partitioning in sorted(set([expr.requires_partition_by(), | |
| partitionings.Arbitrary(), | |
| partitionings.JoinIndex(), | |
| partitionings.Index(), | |
| partitionings.Singleton()])): | |
| if not expr.requires_partition_by().is_subpartitioning_of( | |
| input_partitioning): | |
| continue | |
| random.setstate(random_state) | |
| result = evaluate_with(input_partitioning) | |
| assert result is not None | |
| self._bindings[expr] = result | |
| return self._bindings[expr] | |
| # The return type of an Expression | |
| T = TypeVar('T') | |
| def output_partitioning(expr, input_partitioning): | |
| """ Return the expected output partitioning for `expr` when it's input is | |
| partitioned by `input_partitioning`. | |
| For internal use only; No backward compatibility guarantees """ | |
| assert expr.requires_partition_by().is_subpartitioning_of(input_partitioning) | |
| if expr.preserves_partition_by().is_subpartitioning_of(input_partitioning): | |
| return min(input_partitioning, expr.preserves_partition_by()) | |
| else: | |
| return partitionings.Arbitrary() | |
| class Expression(Generic[T]): | |
| """An expression is an operation bound to a set of arguments. | |
| An expression represents a deferred tree of operations, which can be | |
| evaluated at a specific bindings of root expressions to values. | |
| requires_partition_by indicates the upper bound of a set of partitionings that | |
| are acceptable inputs to this expression. The expression should be able to | |
| produce the correct result when given input(s) partitioned by its | |
| requires_partition_by attribute, or by any partitoning that is _not_ | |
| a subpartitioning of it. | |
| preserves_partition_by indicates the upper bound of a set of partitionings | |
| that can be preserved by this expression. When the input(s) to this expression | |
| are partitioned by preserves_partition_by, or by any partitioning that is | |
| _not_ a subpartitioning of it, this expression should produce output(s) | |
| partitioned by the same partitioning. | |
| However, if the partitioning of an expression's input is a subpartitioning of | |
| the partitioning that it preserves, the output is presumed to have no | |
| particular partitioning (i.e. Arbitrary()). | |
| For example, let's look at an "element-wise operation", that has no | |
| partitioning requirement, and preserves any partitioning given to it:: | |
| requires_partition_by = Arbitrary() -----------------------------+ | |
| | | |
| +-----------+-------------+---------- ... ----+---------| | |
| | | | | | | |
| Singleton() < Index([i]) < Index([i, j]) < ... < Index() < Arbitrary() | |
| | | | | | | |
| +-----------+-------------+---------- ... ----+---------| | |
| | | |
| preserves_partition_by = Arbitrary() ----------------------------+ | |
| As a more interesting example, consider this expression, which requires Index | |
| partitioning, and preserves just Singleton partitioning:: | |
| requires_partition_by = Index() -----------------------+ | |
| | | |
| +-----------+-------------+---------- ... ----| | |
| | | | | | |
| Singleton() < Index([i]) < Index([i, j]) < ... < Index() < Arbitrary() | |
| | | |
| | | |
| preserves_partition_by = Singleton() | |
| Note that any non-Arbitrary partitioning is an acceptable input for this | |
| expression. However, unless the inputs are Singleton-partitioned, the | |
| expression makes no guarantees about the partitioning of the output. | |
| """ | |
| def __init__(self, name: str, proxy: T, _id: Optional[str] = None): | |
| self._name = name | |
| self._proxy = proxy | |
| # Store for preservation through pickling. | |
| self._id = _id or '%s_%s_%s' % (name, type(proxy).__name__, id(self)) | |
| def proxy(self) -> T: | |
| return self._proxy | |
| def __hash__(self): | |
| return hash(self._id) | |
| def __eq__(self, other): | |
| return self._id == other._id | |
| def __repr__(self): | |
| return '%s[%s]' % (self.__class__.__name__, self._id) | |
| def placeholders(self): | |
| """Returns all the placeholders that self depends on.""" | |
| raise NotImplementedError(type(self)) | |
| def evaluate_at(self, session: Session) -> T: | |
| """Returns the result of self with the bindings given in session.""" | |
| raise NotImplementedError(type(self)) | |
| def requires_partition_by(self) -> partitionings.Partitioning: | |
| """Returns the partitioning, if any, require to evaluate this expression. | |
| Returns partitioning.Arbitrary() to require no partitioning is required. | |
| """ | |
| raise NotImplementedError(type(self)) | |
| def preserves_partition_by(self) -> partitionings.Partitioning: | |
| """Returns the partitioning, if any, preserved by this expression. | |
| This gives an upper bound on the partitioning of its ouput. The actual | |
| partitioning of the output may be less strict (e.g. if the input was | |
| less partitioned). | |
| """ | |
| raise NotImplementedError(type(self)) | |
| class PlaceholderExpression(Expression): | |
| """An expression whose value must be explicitly bound in the session.""" | |
| def __init__( | |
| self, | |
| proxy: T, | |
| reference: Any = None, | |
| ): | |
| """Initialize a placeholder expression. | |
| Args: | |
| proxy: A proxy object with the type expected to be bound to this | |
| expression. Used for type checking at pipeline construction time. | |
| """ | |
| super().__init__('placeholder', proxy) | |
| self._reference = reference | |
| def placeholders(self): | |
| return frozenset([self]) | |
| def args(self): | |
| return () | |
| def evaluate_at(self, session): | |
| return session.lookup(self) | |
| def requires_partition_by(self): | |
| return partitionings.Arbitrary() | |
| def preserves_partition_by(self): | |
| return partitionings.Index() | |
| class ConstantExpression(Expression): | |
| """An expression whose value is known at pipeline construction time.""" | |
| def __init__(self, value: T, proxy: Optional[T] = None): | |
| """Initialize a constant expression. | |
| Args: | |
| value: The constant value to be produced by this expression. | |
| proxy: (Optional) a proxy object with same type as `value` to use for | |
| rapid type checking at pipeline construction time. If not provided, | |
| `value` will be used directly. | |
| """ | |
| if proxy is None: | |
| proxy = value | |
| super().__init__('constant', proxy) | |
| self._value = value | |
| def placeholders(self): | |
| return frozenset() | |
| def args(self): | |
| return () | |
| def evaluate_at(self, session): | |
| return self._value | |
| def requires_partition_by(self): | |
| return partitionings.Arbitrary() | |
| def preserves_partition_by(self): | |
| return partitionings.Arbitrary() | |
| class ComputedExpression(Expression): | |
| """An expression whose value must be computed at pipeline execution time.""" | |
| def __init__( | |
| self, | |
| name: str, | |
| func: Callable[..., T], | |
| args: Iterable[Expression], | |
| proxy: Optional[T] = None, | |
| _id: Optional[str] = None, | |
| requires_partition_by: partitionings.Partitioning = partitionings.Index(), | |
| preserves_partition_by: partitionings.Partitioning = partitionings. | |
| Singleton(), | |
| ): | |
| """Initialize a computed expression. | |
| Args: | |
| name: The name of this expression. | |
| func: The function that will be used to compute the value of this | |
| expression. Should accept arguments of the types returned when | |
| evaluating the `args` expressions. | |
| args: The list of expressions that will be used to produce inputs to | |
| `func`. | |
| proxy: (Optional) a proxy object with same type as the objects that this | |
| ComputedExpression will produce at execution time. If not provided, a | |
| proxy will be generated using `func` and the proxies of `args`. | |
| _id: (Optional) a string to uniquely identify this expression. | |
| requires_partition_by: The required (common) partitioning of the args. | |
| preserves_partition_by: The level of partitioning preserved. | |
| """ | |
| if (not _get_allow_non_parallel() and | |
| isinstance(requires_partition_by, partitionings.Singleton)): | |
| reason = requires_partition_by.reason or ( | |
| f"Encountered non-parallelizable form of {name!r}.") | |
| raise NonParallelOperation( | |
| f"{reason}\n" | |
| "Consider using an allow_non_parallel_operations block if you're " | |
| "sure you want to do this. See " | |
| "https://s.apache.org/dataframe-non-parallel-operations for more " | |
| "information.") | |
| args = tuple(args) | |
| if proxy is None: | |
| proxy = func(*(arg.proxy() for arg in args)) | |
| super().__init__(name, proxy, _id) | |
| self._func = func | |
| self._args = args | |
| self._requires_partition_by = requires_partition_by | |
| self._preserves_partition_by = preserves_partition_by | |
| def placeholders(self): | |
| if not hasattr(self, '_placeholders'): | |
| self._placeholders = frozenset.union( | |
| frozenset(), *[arg.placeholders() for arg in self.args()]) | |
| return self._placeholders | |
| def args(self): | |
| return self._args | |
| def evaluate_at(self, session): | |
| return self._func(*(session.evaluate(arg) for arg in self._args)) | |
| def requires_partition_by(self): | |
| return self._requires_partition_by | |
| def preserves_partition_by(self): | |
| return self._preserves_partition_by | |
| def elementwise_expression(name, func, args): | |
| return ComputedExpression( | |
| name, | |
| func, | |
| args, | |
| requires_partition_by=partitionings.Arbitrary(), | |
| preserves_partition_by=partitionings.Arbitrary()) | |
| _ALLOW_NON_PARALLEL = threading.local() | |
| _ALLOW_NON_PARALLEL.value = False | |
| def _get_allow_non_parallel(): | |
| return _ALLOW_NON_PARALLEL.value | |
| @contextlib.contextmanager | |
| def allow_non_parallel_operations(allow=True): | |
| if allow is None: | |
| yield | |
| else: | |
| old_value, _ALLOW_NON_PARALLEL.value = _ALLOW_NON_PARALLEL.value, allow | |
| try: | |
| yield | |
| finally: | |
| _ALLOW_NON_PARALLEL.value = old_value | |
| class NonParallelOperation(Exception): | |
| def __init__(self, msg): | |
| super().__init__(self, msg) | |
| self.msg = msg |