Source code for rule_engine.types.coercion

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
#  rule_engine/types/coercion.py
#
#  Redistribution and use in source and binary forms, with or without
#  modification, are permitted provided that the following conditions are
#  met:
#
#  * Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above
#    copyright notice, this list of conditions and the following disclaimer
#    in the documentation and/or other materials provided with the
#    distribution.
#  * Neither the name of the project nor the names of its
#    contributors may be used to endorse or promote products derived from
#    this software without specific prior written permission.
#
#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
#  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
#  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
#  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
#  OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
#  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
#  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
#  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
#  THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
#  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
#  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#

import collections
import datetime
import decimal
import functools
import math
import sys
from typing import Any, Callable, Iterable, NoReturn

from .. import errors
from .datatype import DataType

# sys.get_int_max_str_digits was added in Python 3.10.7, older versions use CPython's default limit
_get_int_max_str_digits: Callable[[], int] = getattr(sys, 'get_int_max_str_digits', lambda: 4300)

def _get_int_max_digits(int_max_digits: int | None) -> int:
    if int_max_digits is None:
        # read the interpreter's limit each time so changes to it take effect
        int_max_digits = _get_int_max_str_digits()
    return int_max_digits

def _int_max_bits(int_max_digits: int) -> int:
    # an upper bound for the bit length of 10 ** int_max_digits, calculated without creating it because that's slow for
    # large limits (log2(10) < 3.321929)
    return int_max_digits * 3321929 // 1000000 + 1

@functools.lru_cache(maxsize=16)
def _int_limit(int_max_digits: int) -> int:
    # the smallest integer with more than int_max_digits digits
    return 10 ** int_max_digits

def _raise_int_size_error(int_max_digits: int) -> NoReturn:
    raise errors.ArithmeticError("arithmetic error: integer exceeds the limit ({0} digits)".format(int_max_digits))

def _assert_int_size(value: int, int_max_digits: int | None = None) -> None:
    # converting an int to a Decimal takes time that grows quadratically with its size, so large values are rejected
    # like Python's own int to str conversion
    int_max_digits = _get_int_max_digits(int_max_digits)
    if not int_max_digits:
        return
    bit_length = value.bit_length()
    # values with at most 3 bits per allowed digit are always less than the limit
    if bit_length <= 3 * int_max_digits:
        return
    # only compare the value to the limit when the bit length alone isn't enough to tell
    if bit_length > _int_max_bits(int_max_digits) or abs(value) >= _int_limit(int_max_digits):
        _raise_int_size_error(int_max_digits)

def _assert_member_int_sizes(values: Iterable[Any], int_max_digits: int) -> None:
    # SET members aren't converted, but comparing one to a FLOAT value converts it, so check the size of the integers
    for value in values:
        if isinstance(value, int):
            _assert_int_size(value, int_max_digits)
        elif isinstance(value, (frozenset, tuple)):
            _assert_member_int_sizes(value, int_max_digits)

def _assert_int_bit_length(bit_length: int, int_max_digits: int | None = None) -> None:
    # check the size of an integer that hasn't been created yet, this only rejects bit lengths that are too large for
    # any value so the integer still needs to be checked with _assert_int_size once it has been created
    int_max_digits = _get_int_max_digits(int_max_digits)
    if int_max_digits and bit_length > _int_max_bits(int_max_digits):
        _raise_int_size_error(int_max_digits)

def _assert_int_digits(digits: int, int_max_digits: int | None = None) -> None:
    # check the number of digits of an integer that hasn't been created yet
    int_max_digits = _get_int_max_digits(int_max_digits)
    if int_max_digits and digits > int_max_digits:
        _raise_int_size_error(int_max_digits)

def _to_decimal(value: Any, int_max_digits: int | None = None) -> decimal.Decimal:
    if isinstance(value, decimal.Decimal):
        return value
    if isinstance(value, int):
        # int subclasses (e.g. IntEnum) don't necessarily repr() as a plain number; convert via int() so the
        # Decimal constructor receives a value it can parse
        value = int(value)
        if int_max_digits is None:
            int_max_digits = _get_int_max_str_digits()
        # skip the call for values with at most 3 bits per allowed digit which are always within the limit, this is the
        # common case and is checked here to keep it fast
        if value.bit_length() > 3 * int_max_digits:
            _assert_int_size(value, int_max_digits)
        return decimal.Decimal(value)
    return decimal.Decimal(repr(value))

[docs] def coerce_value(value: Any, verify_type: bool = True, *, int_max_digits: int | None = None) -> Any: """ Take a native Python *value* and convert it to a value of a data type which can be represented by a Rule Engine :py:class:`~.DataType`. This function is useful for converting native Python values at the engine boundaries such as when resolving a symbol from an object external to the engine. .. versionadded:: 2.0.0 .. versionchanged:: 5.2.0 Added the *int_max_digits* parameter. :param value: The value to convert. :param bool verify_type: Whether or not to verify the converted value's type. :param int int_max_digits: The maximum number of digits that an integer can have to be converted to a ``FLOAT`` value. Converting an integer takes time that grows quadratically with its size. ``0`` disables the limit, and ``None`` (the default) uses the interpreter's limit from :py:func:`sys.get_int_max_str_digits`. :return: The converted value. :raises ~rule_engine.errors.ArithmeticError: *value* is an integer with more digits than allowed by *int_max_digits*, or it is a :py:class:`dict`, :py:class:`list`, :py:class:`range`, :py:class:`set` or :py:class:`tuple` that contains one. """ # ARRAY if isinstance(value, (list, range, tuple)): int_max_digits = _get_int_max_digits(int_max_digits) value = tuple(coerce_value(v, verify_type=verify_type, int_max_digits=int_max_digits) for v in value) # DATETIME elif isinstance(value, datetime.date) and not isinstance(value, datetime.datetime): value = datetime.datetime(value.year, value.month, value.day) # FLOAT elif isinstance(value, (float, int)) and not isinstance(value, bool): value = _to_decimal(value, int_max_digits) # MAPPING elif isinstance(value, (dict, collections.OrderedDict)): int_max_digits = _get_int_max_digits(int_max_digits) value = collections.OrderedDict( ( coerce_value(k, verify_type=verify_type, int_max_digits=int_max_digits), coerce_value(v, verify_type=verify_type, int_max_digits=int_max_digits) ) for k, v in value.items() ) # SET elif isinstance(value, set): int_max_digits = _get_int_max_digits(int_max_digits) if int_max_digits: _assert_member_int_sizes(value, int_max_digits) if verify_type: DataType.from_value(value) # use this to raise a TypeError, if the type is incompatible return value
[docs] def is_integer_number(value: Any) -> bool: """ Check whether *value* is an integer number (i.e. a whole, number). This can, for example, be used to check if a floating point number such as ``3.0`` can safely be converted to an integer without loss of information. .. versionadded:: 2.1.0 :param value: The value to check. This value is a native Python type. :return: Whether or not the value is an integer number. :rtype: bool """ if not is_real_number(value): return False if math.floor(value) != value: return False return True
[docs] def is_natural_number(value: Any) -> bool: """ Check whether *value* is a natural number (i.e. a whole, non-negative number). This can, for example, be used to check if a floating point number such as ``3.0`` can safely be converted to an integer without loss of information. :param value: The value to check. This value is a native Python type. :return: Whether or not the value is a natural number. :rtype: bool """ if not is_integer_number(value): return False if value < 0: return False return True
[docs] def is_real_number(value: Any) -> bool: """ Check whether *value* is a real number (i.e. capable of being represented as a floating point value without loss of information as well as being finite). Despite being able to be represented as a float, ``NaN`` is not considered a real number for the purposes of this function. :param value: The value to check. This value is a native Python type. :return: Whether or not the value is a natural number. :rtype: bool """ if not is_numeric(value): return False if not math.isfinite(value): return False return True
[docs] def is_numeric(value: Any) -> bool: """ Check whether *value* is a numeric value (i.e. capable of being represented as a floating point value without loss of information). :param value: The value to check. This value is a native Python type. :return: Whether or not the value is numeric. :rtype: bool """ if not isinstance(value, (decimal.Decimal, float, int)): return False if isinstance(value, bool): return False return True