Source code for pint.registry_helpers

"""
pint.registry_helpers
~~~~~~~~~~~~~~~~~~~~~

Miscellaneous methods of the registry written as separate functions.

:copyright: 2016 by Pint Authors, see AUTHORS for more details..
:license: BSD, see LICENSE for more details.
"""

from __future__ import annotations

import functools
from collections.abc import Callable, Iterable
from inspect import Parameter, signature
from itertools import zip_longest
from typing import TYPE_CHECKING, Any, overload

from ._typing import FuncType
from .errors import DimensionalityError
from .util import UnitsContainer, to_units_container

if TYPE_CHECKING:
    from ._typing import Quantity, Unit
    from .facets.plain import GenericPlainRegistry
    from .registry import UnitRegistry


def _replace_units(original_units, values_by_name):
    """Convert a unit compatible type to a UnitsContainer.

    Parameters
    ----------
    original_units :
        a UnitsContainer instance.
    values_by_name :
        a map between original names and the new values.

    Returns
    -------

    """
    q = UnitsContainer({})
    for arg_name, exponent in original_units.items():
        q *= getattr(values_by_name[arg_name], "_units", UnitsContainer({})) ** exponent

    return q


def _to_units_container(a, registry=None):
    """Convert a unit compatible type to a UnitsContainer,
    checking if it is string field prefixed with an equal
    (which is considered a reference)

    Parameters
    ----------
    a :

    registry :
         (Default value = None)

    Returns
    -------
    UnitsContainer, bool


    """
    if isinstance(a, str) and "=" in a:
        return to_units_container(a.split("=", 1)[1]), True
    return to_units_container(a, registry), False


def _parse_wrap_args(args, registry=None, *, has_varargs=False):
    # Arguments which contain definitions
    # (i.e. names that appear alone and for the first time)
    defs_args = set()
    defs_args_ndx = set()

    # Arguments which depend on others
    dependent_args_ndx = set()

    # Arguments which have units.
    unit_args_ndx = set()

    # _to_units_container
    args_as_uc = [_to_units_container(arg, registry) for arg in args]

    # Check for references in args, remove None values
    for ndx, (arg, is_ref) in enumerate(args_as_uc):
        if arg is None:
            continue
        elif is_ref:
            if len(arg) == 1:
                [(key, value)] = arg.items()
                if value == 1 and key not in defs_args:
                    # This is the first time that
                    # a variable is used => it is a definition.
                    defs_args.add(key)
                    defs_args_ndx.add(ndx)
                    args_as_uc[ndx] = (key, True)
                else:
                    # The variable was already found elsewhere,
                    # we consider it a dependent variable.
                    dependent_args_ndx.add(ndx)
            else:
                dependent_args_ndx.add(ndx)
        else:
            unit_args_ndx.add(ndx)

    # Check that all valid dependent variables
    for ndx in dependent_args_ndx:
        arg, is_ref = args_as_uc[ndx]
        if not isinstance(arg, dict):
            continue
        if not set(arg.keys()) <= defs_args:
            raise ValueError(
                "Found a missing token while wrapping a function: "
                "Not all variable referenced in %s are defined using !" % args[ndx]
            )

    single_indices = tuple((ndx,) for ndx in range(len(args)))

    def _converter(ureg, sig, values, kw, strict):
        len_initial_values = len(values)

        if has_varargs:
            # Bind before flattening: keyword-only arguments must not be
            # mistaken for elements of *args, even when defaults are omitted.
            bound = sig.bind(*values, **kw)
            bound.apply_defaults()
            values = []
            indices = []
            for name, param in sig.parameters.items():
                if param.kind == Parameter.VAR_KEYWORD:
                    continue
                start = len(values)
                if param.kind == Parameter.VAR_POSITIONAL:
                    values.extend(bound.arguments[name])
                else:
                    values.append(bound.arguments[name])
                indices.append(range(start, len(values)))
        else:
            indices = single_indices
            # pack kwargs
            for i, (param_name, param) in enumerate(sig.parameters.items()):
                if param.kind == Parameter.VAR_KEYWORD:
                    continue
                if i >= len_initial_values:
                    values.append(kw[param_name])

        values_by_name = {}

        # first pass: Grab named values
        for ndx in defs_args_ndx:
            positions = indices[ndx]
            # An empty *args has no unit-bearing value to bind. Its reference
            # is dimensionless, just as for a plain scalar argument.
            value = values[positions[0]] if positions else None
            values_by_name[args_as_uc[ndx][0]] = value
            if positions:
                values[positions[0]] = getattr(value, "_magnitude", value)
                # Only the first variadic value defines the reference; every
                # subsequent one must be converted to that same unit.
                for pos in positions[1:]:
                    other = values[pos]
                    values[pos] = ureg._convert(
                        getattr(other, "_magnitude", other),
                        getattr(other, "_units", UnitsContainer({})),
                        getattr(value, "_units", UnitsContainer({})),
                    )

        # second pass: calculate derived values based on named values
        for ndx in dependent_args_ndx:
            positions = indices[ndx]
            if not positions:
                continue
            target_units = _replace_units(args_as_uc[ndx][0], values_by_name)
            assert target_units is not None
            for pos in positions:
                value = values[pos]
                values[pos] = ureg._convert(
                    getattr(value, "_magnitude", value),
                    getattr(value, "_units", UnitsContainer({})),
                    target_units,
                )

        # third pass: convert other arguments
        for ndx in unit_args_ndx:
            for pos in indices[ndx]:
                if isinstance(values[pos], ureg.Quantity):
                    values[pos] = ureg._convert(
                        values[pos]._magnitude, values[pos]._units, args_as_uc[ndx][0]
                    )
                else:
                    if strict:
                        if isinstance(values[pos], str):
                            # if the value is a string, we try to parse it
                            tmp_value = ureg.parse_expression(values[pos])
                            values[pos] = ureg._convert(
                                tmp_value._magnitude,
                                tmp_value._units,
                                args_as_uc[ndx][0],
                            )
                        else:
                            raise ValueError(
                                "A wrapped function using strict=True requires "
                                "quantity or a string for all arguments with not None units. "
                                "(error found for {}, {})".format(
                                    args_as_uc[ndx][0], values[pos]
                                )
                            )

        if has_varargs:
            ndx = 0
            for name, param in sig.parameters.items():
                if param.kind == Parameter.VAR_KEYWORD:
                    continue
                positions = indices[ndx]
                if param.kind == Parameter.VAR_POSITIONAL:
                    bound.arguments[name] = tuple(values[pos] for pos in positions)
                else:
                    bound.arguments[name] = values[positions[0]]
                ndx += 1
            return bound.args, bound.kwargs, values_by_name

        # unpack kwargs
        for i, (param_name, param) in enumerate(sig.parameters.items()):
            if param.kind == Parameter.VAR_KEYWORD:
                continue
            if i >= len_initial_values:
                kw[param_name] = values[i]

        return values[:len_initial_values], kw, values_by_name

    return _converter


def _apply_defaults(sig, args, kwargs):
    """Apply default keyword arguments.

    Named keywords may have been left blank. This function applies the default
    values so that every argument is defined.
    """

    for i, param in enumerate(sig.parameters.values()):
        if (
            i >= len(args)
            and param.default != Parameter.empty
            and param.name not in kwargs
        ):
            kwargs[param.name] = param.default
    return list(args), kwargs


@overload
def wraps[QuantityT: Quantity, UnitT: Unit](
    ureg: GenericPlainRegistry[QuantityT, UnitT],
    ret: str | Unit,
    args: str | Unit | Iterable[str | Unit | None] | None,
    strict: bool = True,
) -> Callable[[Callable[..., Any]], Callable[..., QuantityT]]: ...


# Skipped conversion and multiple return values need not produce a Quantity.
@overload
def wraps[QuantityT: Quantity, UnitT: Unit](
    ureg: GenericPlainRegistry[QuantityT, UnitT],
    ret: str | Unit | Iterable[str | Unit | None] | None,
    args: str | Unit | Iterable[str | Unit | None] | None,
    strict: bool = True,
) -> Callable[[Callable[..., Any]], Callable[..., object]]: ...


[docs] def wraps[QuantityT: Quantity, UnitT: Unit]( ureg: GenericPlainRegistry[QuantityT, UnitT], ret: str | Unit | Iterable[str | Unit | None] | None, args: str | Unit | Iterable[str | Unit | None] | None, strict: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., object]]: """Wraps a function to become pint-aware. Use it when a function requires a numerical value but in some specific units. The wrapper function will take a pint quantity, convert to the units specified in `args` and then call the wrapped function with the resulting magnitude. The value returned by the wrapped function will be converted to the units specified in `ret`. Parameters ---------- ureg : pint.UnitRegistry a UnitRegistry instance. ret : str, pint.Unit, or iterable of str or pint.Unit Units of each of the return values. Use `None` to skip argument conversion. args : str, pint.Unit, or iterable of str or pint.Unit Units of each of the input arguments. Use `None` to skip argument conversion. One unit specification applies to every element of a ``*args`` parameter. If that specification defines a reference such as ``"=A"``, the first element defines its unit; an empty ``*args`` defines it as dimensionless. strict : bool Indicates that only quantities are accepted. (Default value = True) Returns ------- callable the wrapper function. Raises ------ TypeError if the number of given arguments does not match the number of function parameters. if any of the provided arguments is not a unit a string or Quantity """ if not isinstance(args, (list, tuple)): args = (args,) for arg in args: if arg is not None and not isinstance(arg, (ureg.Unit, str)): raise TypeError( "wraps arguments must by of type str or Unit, not %s (%s)" % (type(arg), arg) ) is_ret_container = isinstance(ret, (list, tuple)) if is_ret_container: for arg in ret: if arg is not None and not isinstance(arg, (ureg.Unit, str)): raise TypeError( "wraps 'ret' argument must by of type str or Unit, not %s (%s)" % (type(arg), arg) ) ret = ret.__class__([_to_units_container(arg, ureg) for arg in ret]) else: if ret is not None and not isinstance(ret, (ureg.Unit, str)): raise TypeError( "wraps 'ret' argument must by of type str or Unit, not %s (%s)" % (type(ret), ret) ) ret = _to_units_container(ret, ureg) def decorator(func: Callable[..., Any]) -> Callable[..., object]: sig = signature(func) params = tuple(sig.parameters.values()) has_varargs = any(param.kind == Parameter.VAR_POSITIONAL for param in params) has_var_keyword = any(param.kind == Parameter.VAR_KEYWORD for param in params) count_params = sum(param.kind != Parameter.VAR_KEYWORD for param in params) converter_args = args if has_var_keyword and len(args) == count_params + 1 and args[-1] is None: converter_args = args[:-1] if len(converter_args) != count_params: raise TypeError( "%s takes %i parameters, but %i units were passed" % (func.__name__, count_params, len(args)) ) converter = _parse_wrap_args(converter_args, ureg, has_varargs=has_varargs) assigned = tuple( attr for attr in functools.WRAPPER_ASSIGNMENTS if hasattr(func, attr) ) updated = tuple( attr for attr in functools.WRAPPER_UPDATES if hasattr(func, attr) ) @functools.wraps(func, assigned=assigned, updated=updated) def wrapper(*values, **kw) -> object: if not has_varargs: values, kw = _apply_defaults(sig, values, kw) # In principle, the values are used as is # When then extract the magnitudes when needed. new_values, new_kw, values_by_name = converter( ureg, sig, values, kw, strict ) result = func(*new_values, **new_kw) if is_ret_container: out_units = ( _replace_units(r, values_by_name) if is_ref else r for (r, is_ref) in ret ) return ret.__class__( res if unit is None else ureg.Quantity(res, unit) for unit, res in zip_longest(out_units, result) ) if ret[0] is None: return result return ureg.Quantity( result, _replace_units(ret[0], values_by_name) if ret[1] else ret[0] ) return wrapper return decorator
[docs] def check[F: FuncType]( ureg: UnitRegistry, *args: str | UnitsContainer | Unit | None ) -> Callable[[F], F]: """Decorator to for quantity type checking for function inputs. Use it to ensure that the decorated function input parameters match the expected dimension of pint quantity. The wrapper function raises: - `pint.DimensionalityError` if an argument doesn't match the required dimensions. ureg : UnitRegistry a UnitRegistry instance. args : str or UnitContainer or None Dimensions of each of the input arguments, one per parameter of the decorated function in signature order. A dimension given for a ``*args`` parameter is checked against each value supplied through it. Use `None` to skip checking a parameter; ``**kwargs`` requires `None`. Returns ------- callable the wrapped function. Raises ------ TypeError If the number of given dimensions does not match the number of function parameters. ValueError If the any of the provided dimensions cannot be parsed as a dimension. """ dimensions = [ ureg.get_dimensionality(dim) if dim is not None else None for dim in args ] def decorator(func): sig = signature(func) params = tuple(sig.parameters.values()) count_params = len(params) if len(dimensions) != count_params: raise TypeError( "%s takes %i parameters, but %i dimensions were passed" % (func.__name__, count_params, len(dimensions)) ) for param, dim in zip(params, dimensions): if param.kind is Parameter.VAR_KEYWORD and dim is not None: raise TypeError("use None for the **kwargs dimension") needs_binding = any( param.kind not in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) for param in params ) assigned = tuple( attr for attr in functools.WRAPPER_ASSIGNMENTS if hasattr(func, attr) ) updated = tuple( attr for attr in functools.WRAPPER_UPDATES if hasattr(func, attr) ) @functools.wraps(func, assigned=assigned, updated=updated) def wrapper(*args, **kwargs): if needs_binding: # Binding distinguishes positional values from keyword-only and # variadic parameters before dimensional checks run. bound = sig.bind(*args, **kwargs) bound.apply_defaults() values = [bound.arguments[param.name] for param in params] else: values = list(args) used_kwargs = 0 for param in params[len(args) :]: if ( param.name in kwargs and param.kind is not Parameter.POSITIONAL_ONLY ): values.append(kwargs[param.name]) used_kwargs += 1 elif param.default is not Parameter.empty: values.append(param.default) else: break if len(values) != count_params or used_kwargs != len(kwargs): # Invalid call: let it raise TypeError before any checks. return func(*args, **kwargs) for param, dim, value in zip(params, dimensions, values): if dim is None: continue if param.kind is Parameter.VAR_POSITIONAL: items = value else: items = (value,) for item in items: if not ureg.Quantity(item).check(dim): val_dim = ureg.get_dimensionality(item) raise DimensionalityError(item, "a quantity of", val_dim, dim) return func(*args, **kwargs) return wrapper return decorator