From 3cecf0d989956299477fb0ea272b0efc9a7ecee6 Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Wed, 18 Sep 2024 18:04:35 +0200 Subject: [PATCH] Add `GF2` type for Galois field elements - add `GF2` class in the `lalib.elements` sub-package implementing a typical Galois field with two elements - the singleton objects `one` and `zero` are the concrete instances of the `GF2` type for the end users - besides the typical Galois arithmetic, `one` and `zero` behave like the built-in numbers `1` and `0` and implement the `numbers.Rational` interface - add exhaustive docstrings with usage examples - add (unit) test cases with 100% coverage --- noxfile.py | 7 + poetry.lock | 2 +- pyproject.toml | 5 + src/lalib/elements/gf2.py | 535 ++++++++++++++++++++++++ tests/elements/test_gf2.py | 832 +++++++++++++++++++++++++++++++++++++ tests/test_docstrings.py | 1 + 6 files changed, 1381 insertions(+), 1 deletion(-) create mode 100644 src/lalib/elements/gf2.py create mode 100644 tests/elements/test_gf2.py diff --git a/noxfile.py b/noxfile.py index 0ce38b7..bf01f0c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -230,6 +230,7 @@ TEST_DEPENDENCIES = ( "pytest", "pytest-cov", "semver", + 'typing-extensions; python_version < "3.11"', # to support Python 3.9 & 3.10 "xdoctest", ) @@ -284,6 +285,7 @@ def test_coverage_run(session: nox.Session) -> None: session.install(".") install_pinned(session, "coverage", *TEST_DEPENDENCIES) + session.env["NO_CROSS_REFERENCE"] = "true" session.run( "python", "-m", @@ -430,6 +432,11 @@ def start(session: nox.Session) -> None: session.env["PIP_CACHE_DIR"] = ".cache/pip" session.env["PIP_DISABLE_PIP_VERSION_CHECK"] = "true" + if session.python in ("3.12", "3.11"): + session.env["PRAGMA_SUPPORT_39_N_310"] = "to support Python 3.9 & 3.10" + else: + session.env["PRAGMA_SUPPORT_39_N_310"] = f"{_magic_number =}" + def suppress_poetry_export_warning(session: nox.Session) -> None: """Temporary fix to avoid poetry's warning ... diff --git a/poetry.lock b/poetry.lock index e172dc3..0e30eaa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1674,4 +1674,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "3a34bd29eb4226a6054fe5ddba556605fcd621ceff7661334edf429d715c320f" +content-hash = "e9e490a864511852844926112978e57c1421328f6231437f8f280ddfc88cecde" diff --git a/pyproject.toml b/pyproject.toml index 698ccd0..95e4da8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ repository = "https://github.com/webartifex/lalib" python = "^3.9" +typing-extensions = [ { python = "<3.11", version = "^4.12" } ] # to support Python 3.9 & 3.10 + [tool.poetry.group.dev.dependencies] @@ -127,6 +129,9 @@ exclude_lines = [ # "pragma: no cover" # => Intentionally commented out as we thrive for 100% test coverage + # PyPI's "typing-extensions" are needed to make `mypy` work + "pragma: no cover ${PRAGMA_SUPPORT_39_N_310}", + ] diff --git a/src/lalib/elements/gf2.py b/src/lalib/elements/gf2.py new file mode 100644 index 0000000..a69ec66 --- /dev/null +++ b/src/lalib/elements/gf2.py @@ -0,0 +1,535 @@ +"""A Galois field implementation with two elements. + +This module defines two singleton objects, `one` and `zero`, +that follow the rules of a Galois field of two elements, +or `GF2` for short: + +>>> one + one +zero +>>> zero + one +one +>>> one * one +one +>>> one * zero +zero + +They mix with numbers that compare equal to either `1` or `0`, +for example: + +>>> one + 1 +zero +>>> 0 * zero +zero + +Further usage explanations of `one` and `zero` +can be found in the various docstrings of the `GF2` class. +""" + +import abc +import functools +import math +import numbers + +# When giving up support for Python 3.9, we can get rid of `Optional` +from typing import Callable, ClassVar, Literal, Optional + + +try: + from typing import Self +except ImportError: # pragma: no cover to support Python 3.9 & 3.10 + from typing_extensions import Self + + +THRESHOLD = 1e-12 + + +def to_gf2( + value: complex, # `mypy` reads `complex | float | int` + *, + strict: bool = True, + threshold: float = THRESHOLD, +) -> int: + """Cast a number as a possible Galois field value: `1` or `0`. + + By default, the `value` is parsed in a `strict` mode where + `value`s equal to `1` or `0` within the specified `threshold` + return either `1` or `0` exactly. + + Args: + value: to be cast; must behave like a number; + for `complex` numbers their `.real` part is used + strict: if `True`, only accept `value`s equal to + `1` or `0` within the `threshold` as `1` or `0`; + otherwise, cast any number different from `0` as `1` + threshold: used for the equality checks to find + `1`-like and `0`-like `value`s + + Returns: + either `1` or `0` + + Raises: + TypeError: `value` does not behave like a number + ValueError: `value != 1` or `value != 0` in `strict` mode + """ + try: + value = complex(value) + except (TypeError, ValueError): + msg = "`value` must be a number" + raise TypeError(msg) from None + + if not (abs(value.imag) < threshold): + msg = "`value` must be either `1`-like or `0`-like" + raise ValueError(msg) + + value = value.real + + if math.isnan(value): + msg = "`value` must be either `1`-like or `0`-like" + raise ValueError(msg) + + if strict: + if abs(value - 1) < threshold: + return 1 + if abs(value) < threshold: + return 0 + + msg = "`value` must be either `1`-like or `0`-like" + raise ValueError(msg) + + if abs(value) < threshold: + return 0 + + return 1 + + +class _GF2Meta(abc.ABCMeta): + """Make data type of `one` and `zero` appear to be `GF2`.""" + + def __repr__(cls) -> str: + return "GF2" + + +@functools.total_ordering +class GF2(metaclass=_GF2Meta): + """A Galois field value: either `one` or `zero`. + + Implements the singleton design pattern such that + only one instance per field value exists in the + computer's memory, i.e., there is only one `one` + and one `zero` object at all times. + """ + + _instances: ClassVar = {} + _value: int + + @staticmethod + def __new__( + cls: type[Self], + value: object = None, + *, + strict: bool = True, + threshold: float = THRESHOLD, + ) -> Self: + """See docstring for `.__init__()`.""" + if isinstance(value, cls): + return value + + if value is None: + try: + value = cls._value + except AttributeError: + try: + return cls._instances[0] + except KeyError: + msg = "Must create `one` and `zero` first (internal error)" + raise RuntimeError(msg) from None + else: + value = to_gf2(value, strict=strict, threshold=threshold) # type: ignore[arg-type] + + try: + return cls._instances[value] + except KeyError: + obj = super().__new__(cls) + cls._instances[int(obj)] = obj + return obj + + def __init__( + self, + value: object = None, + *, + strict: bool = True, + threshold: float = THRESHOLD, + ) -> None: + """Obtain one of two objects: `one` or `zero`. + + Args: + value: to be cast; must behave like a number; + for `complex` numbers their `.real` part is used + strict: if `True`, only accept `value`s equal to + `1` or `0` within the `threshold` as `one` or `zero`; + otherwise, cast any number different from `0` as `one` + threshold: used for the equality checks to find + `1`-like and `0`-like `value`s + + Returns: + either `one` or `zero` + + Raises: + TypeError: `value` does not behave like a number + ValueError: `value != 1` or `value != 0` in `strict` mode + """ + + def __repr__(self) -> str: + """Text representation: `repr(one)` and `repr(zero)`. + + `eval(repr(self)) == self` must be `True`; in other words, + the text representation of an object must be valid code on its + own and evaluate into a (new) object with the same value. + + See: https://docs.python.org/3/reference/datamodel.html#object.__repr__ + """ + return "one" if self._value else "zero" + + __str__ = __repr__ + + def __complex__(self) -> complex: + """Cast `self` as a `complex` number: `complex(self)`.""" + return complex(self._value, 0) + + def __float__(self) -> float: + """Cast `self` as a `float`ing-point number: `float(self)`.""" + return float(self._value) + + def __int__(self) -> int: + """Cast `self` as a `int`: `int(self)`.""" + return int(self._value) + + __hash__ = __int__ + + def __bool__(self) -> bool: + """Cast `self` as a `bool`ean: `bool(self)`. + + Example usage: + + >>> bool(zero) + False + >>> if zero + one: + ... result = one + ... else: + ... result = zero + >>> result + one + """ + return bool(self._value) + + def __abs__(self) -> Self: + """Take the absolute value of `self`: `abs(self)`.""" + return self + + def __trunc__(self) -> int: + """Truncate `self` to the next `int`: `math.trunc(self)`.""" + return int(self) + + def __floor__(self) -> int: + """Round `self` down to the next `int`: `math.floor(self)`.""" + return int(self) + + def __ceil__(self) -> int: + """Round `self` up to the next `int`: `math.ceil(self)`.""" + return int(self) + + def __round__(self, ndigits: Optional[int] = 0) -> int: + """Round `self` to the next `int`: `round(self)`.""" + return int(self) + + @property + def real(self) -> int: + """The `.real` part of a `complex` number. + + For a non-`complex` number this is the number itself. + """ + # Return an `int` to align with `.imag` above + return int(self._value) + + @property + def imag(self) -> Literal[0]: + """The `.imag`inary part of a `complex` number. + + For a non-`complex` number this is always `0`. + """ + # `numbers.Real` returns an `int` here + # whereas `numbers.Complex` returns a `float` + # => must return an `int` to make `mypy` happy + return 0 + + def conjugate(self) -> Self: + """The conjugate of a `complex` number. + + For a non-`complex` number this is the number itself. + """ + return self + + @property + def numerator(self) -> int: + """Smallest numerator when expressed as a `Rational` number. + + Either `1` or `0`. + + Reasoning: + - `int(one) == 1` => `GF2(1 / 1) == one` + - `int(zero) == 0` => `GF2(0 / 1) == zero` + + See also docstring for `.denominator`. + """ + return int(self) + + @property + def denominator(self) -> Literal[1]: + """Smallest denominator when expressed as a `Rational` number. + + Always `1` for `GF2` values. + + See also docstring for `.numerator`. + """ + return 1 + + def __eq__(self, other: object) -> bool: + """Comparison: `self == other`. + + Example usage: + + >>> zero == one + False + >>> one == one + True + >>> one != zero + True + >>> one == 1 + True + """ + try: + other = GF2(other) + except (TypeError, ValueError): + return False + else: + return self is other # `one` and `zero` are singletons + + def __lt__(self, other: object) -> bool: + """Comparison: `self < other`. + + Example usage: + + >>> zero < one + True + >>> one < one + False + >>> 0 < one + True + """ + try: + other = GF2(other) + except TypeError: + return NotImplemented + except ValueError: + msg = "`other` must be either `1`-like or `0`-like" + raise ValueError(msg) from None + else: + return int(self) < int(other) + + def __le__(self, other: object) -> bool: + """Comparison: `self <= other`. + + Example usage: + + >>> zero <= one + True + >>> zero <= zero + True + >>> one <= zero + False + >>> zero <= 1 + True + """ + # The `numbers.Rational` abstract base class requires both + # `.__lt__()` and `.__le__()` to be present alongside + # `.__eq__()` => `@functools.total_ordering` is not enough + return self == other or self < other + + def _compute(self, other: object, func: Callable) -> Self: + """Run arithmetic operations using `int`s. + + The `GF2` atithmetic operations can transparently be conducted + by converting `self` and `other` into `int`s first, and + then "do the math". + + Besides the generic arithmetic, this method also handles the + casting of non-`GF2` values and various errors occuring + along the way. + """ + try: + other = GF2(other) + except TypeError: + return NotImplemented + except ValueError: + msg = "`other` must be a `1`-like or `0`-like value" + raise ValueError(msg) from None + else: + try: + return self.__class__(func(int(self), int(other))) + except ZeroDivisionError: + msg = "division by `0`-like value" + raise ZeroDivisionError(msg) from None + + def __pos__(self) -> Self: + """Make `self` positive: `+self`.""" + return self + + def __neg__(self) -> Self: + """Make `self` negative: `-self`.""" + return self + + def __add__(self, other: object) -> Self: + """Addition / Subtraction: `self + other` / `self - other`. + + For `GF2`, addition and subtraction are identical. Besides + `one + one` which cannot result in a "two", all operations + behave as one would expect from `int`s. + + Example usage: + + >>> one + one + zero + >>> one + zero + one + >>> zero - one + one + >>> zero + 0 + zero + >>> 1 + one + zero + """ + return self._compute(other, lambda s, o: (s + o) % 2) + + __radd__ = __add__ + __sub__ = __add__ + __rsub__ = __add__ + + def __mul__(self, other: object) -> Self: + """Multiplication: `self * other`. + + Multiplying `GF2` values is like multiplying `int`s. + + Example usage: + + >>> one * one + one + >>> zero * one + zero + >>> 0 * one + zero + """ + return self._compute(other, lambda s, o: s * o) + + __rmul__ = __mul__ + + def __truediv__(self, other: object) -> Self: + """Division: `self / other` and `self // other`. + + Dividing `GF2` values is like dividing `int`s. + + Example usage: + + >>> one / one + one + >>> zero // one + zero + >>> one / zero + Traceback (most recent call last): + ... + ZeroDivisionError: ... + >>> 1 // one + one + """ + return self._compute(other, lambda s, o: s / o) + + __floordiv__ = __truediv__ + + def __rtruediv__(self, other: object) -> Self: + """(Reflected) Division: `other / self` and `other // self`. + + See docstring for `.__truediv__()`. + """ + return self._compute(other, lambda s, o: o / s) + + __rfloordiv__ = __rtruediv__ + + def __mod__(self, other: object) -> Self: + """Modulo Division: `self % other`. + + Modulo dividing `GF2` values is like modulo dividing `int`s. + + Example usage: + + >>> one % one + zero + >>> zero % one + zero + >>> one % zero + Traceback (most recent call last): + ... + ZeroDivisionError: ... + >>> 1 % one + zero + """ + return self._compute(other, lambda s, o: s % o) + + def __rmod__(self, other: object) -> Self: + """(Reflected) Modulo Division: `other % self`. + + See docstring for `.__mod__()`. + """ + return self._compute(other, lambda s, o: o % s) + + def __pow__(self, other: object, _modulo: Optional[object] = None) -> Self: + """Exponentiation: `self ** other`. + + Powers of `GF2` values are like powers of `int`s. + + Example usage: + + >>> one ** one + one + >>> zero ** one + zero + >>> one ** zero + one + >>> 1 ** one + one + """ + return self._compute(other, lambda s, o: s**o) + + def __rpow__(self, other: object, _modulo: Optional[object] = None) -> Self: + """(Reflected) Exponentiation: `other ** self`. + + See docstring for `.__pow__()`. + """ + return self._compute(other, lambda s, o: o**s) + + +numbers.Rational.register(GF2) + + +class GF2One(GF2): + """The Galois field value `one`.""" + + _value = 1 + + +class GF2Zero(GF2): + """The Galois field value `zero`.""" + + _value = 0 + + +one = GF2One() +zero = GF2Zero() diff --git a/tests/elements/test_gf2.py b/tests/elements/test_gf2.py new file mode 100644 index 0000000..89ba236 --- /dev/null +++ b/tests/elements/test_gf2.py @@ -0,0 +1,832 @@ +"""Test the `GF2` singeltons `one` and `zero`.""" + +import decimal +import fractions +import importlib +import math +import numbers +import operator +import os +import sys + +import pytest + +from lalib.elements import gf2 + + +one, zero = ( + gf2.one, + gf2.zero, +) + +to_gf2 = gf2.to_gf2 + +GF2, GF2One, GF2Zero = ( + gf2.GF2, + gf2.GF2One, + gf2.GF2Zero, +) + +_THRESHOLD = gf2.THRESHOLD + +del gf2 + + +CROSS_REFERENCE = not os.environ.get("NO_CROSS_REFERENCE") + + +default_threshold = _THRESHOLD +within_threshold = _THRESHOLD / 10 +not_within_threshold = _THRESHOLD * 10 + +strict_one_like_values = ( + 1, + 1.0, + 1.0 + within_threshold, + (1 + 0j), + (1 + 0j) + complex(0, within_threshold), + (1 + 0j) + complex(within_threshold, 0), + decimal.Decimal("1"), + fractions.Fraction(1, 1), + "1", + "1.0", + "1+0j", +) + +non_strict_one_like_values = ( + 0.0 + not_within_threshold, + 1.0 + not_within_threshold, + (1 + 0j) + complex(not_within_threshold, 0), + 42, + decimal.Decimal("42"), + fractions.Fraction(42, 1), + "42", + "42.0", + "42+0j", + "+inf", + "-inf", +) + +one_like_values = strict_one_like_values + non_strict_one_like_values + +zero_like_values = ( + 0, + 0.0, + 0.0 + within_threshold, + (0 + 0j), + (0 + 0j) + complex(0, within_threshold), + (0 + 0j) + complex(within_threshold, 0), + decimal.Decimal("0"), + fractions.Fraction(0, 1), + "0", + "0.0", + "0+0j", +) + + +def test_thresholds(): + """Sanity check for the thresholds used in the tests below.""" + assert within_threshold < default_threshold < not_within_threshold + + +class TestGF2Casting: + """Test the `to_gf2()` function. + + `to_gf2(...)` casts numbers into either `1` or `0`. + """ + + @pytest.mark.parametrize("value", strict_one_like_values) + def test_cast_ones_strictly(self, value): + """`to_gf2(value, strict=True)` returns `1`.""" + result1 = to_gf2(value) # `strict=True` by default + assert result1 == 1 + + result2 = to_gf2(value, strict=True) + assert result2 == 1 + + @pytest.mark.parametrize("value", one_like_values) + def test_cast_ones_not_strictly(self, value): + """`to_gf2(value, strict=False)` returns `1`.""" + result = to_gf2(value, strict=False) + assert result == 1 + + @pytest.mark.parametrize("value", non_strict_one_like_values) + def test_cannot_cast_ones_strictly(self, value): + """`to_gf2(value, strict=False)` returns `1`.""" + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + to_gf2(value) + + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + to_gf2(value, strict=True) + + @pytest.mark.parametrize("value", zero_like_values) + def test_cast_zeros(self, value): + """`to_gf2(value, strict=...)` returns `0`.""" + result1 = to_gf2(value) # `strict=True` by default + assert result1 == 0 + + result2 = to_gf2(value, strict=True) + assert result2 == 0 + + result3 = to_gf2(value, strict=False) + assert result3 == 0 + + @pytest.mark.parametrize( + "value", + [ + complex(1, not_within_threshold), + complex(0, not_within_threshold), + ], + ) + @pytest.mark.parametrize("strict", [True, False]) + def test_cannot_cast_with_non_zero_imag_part(self, value, strict): + """Cannot create `1` or `0` if `.imag != 0`.""" + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + to_gf2(value, strict=strict) + + @pytest.mark.parametrize("value", ["abc", (1,), [1]]) + @pytest.mark.parametrize("strict", [True, False]) + def test_cannot_cast_from_wrong_type(self, value, strict): + """Cannot create `1` or `0` from a non-numeric value.""" + with pytest.raises(TypeError): + to_gf2(value, strict=strict) + + @pytest.mark.parametrize("strict", [True, False]) + def test_cannot_cast_from_nan_value(self, strict): + """Cannot create `1` or `0` from undefined value.""" + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + to_gf2(float("NaN"), strict=strict) + + +@pytest.mark.parametrize("cls", [GF2, GF2One, GF2Zero]) +class TestGF2ConstructorWithCastedValue: + """Test the `GF2` class's constructor. + + `GF2(value, ...)` returns either `one` or `zero`. + """ + + @pytest.mark.parametrize("value", strict_one_like_values) + def test_cast_ones_strictly(self, cls, value): + """`GF2(value, strict=True)` returns `one`.""" + result1 = cls(value) # `strict=True` by default + assert result1 is one + + result2 = cls(value, strict=True) + assert result2 is one + + @pytest.mark.parametrize("value", one_like_values) + def test_cast_ones_not_strictly(self, cls, value): + """`GF2(value, strict=False)` returns `one`.""" + result = cls(value, strict=False) + assert result is one + + @pytest.mark.parametrize("value", non_strict_one_like_values) + def test_cannot_cast_ones_strictly(self, cls, value): + """`GF2(value, strict=False)` returns `1`.""" + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + cls(value) + + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + cls(value, strict=True) + + @pytest.mark.parametrize("value", zero_like_values) + def test_cast_zeros(self, cls, value): + """`GF2(value, strict=...)` returns `zero`.""" + result1 = cls(value) # `strict=True` by default + assert result1 is zero + + result2 = cls(value, strict=True) + assert result2 is zero + + result3 = cls(value, strict=False) + assert result3 is zero + + @pytest.mark.parametrize( + "value", + [ + complex(1, not_within_threshold), + complex(0, not_within_threshold), + ], + ) + @pytest.mark.parametrize("strict", [True, False]) + def test_cannot_cast_with_non_zero_imag_part(self, cls, value, strict): + """Cannot create `one` or `zero` if `.imag != 0`.""" + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + cls(value, strict=strict) + + @pytest.mark.parametrize("value", ["abc", (1,), [1]]) + @pytest.mark.parametrize("strict", [True, False]) + def test_cannot_cast_from_wrong_type(self, cls, value, strict): + """Cannot create `one` or `zero` from a non-numeric value.""" + with pytest.raises(TypeError): + cls(value, strict=strict) + + @pytest.mark.parametrize("strict", [True, False]) + def test_cannot_cast_from_nan_value(self, cls, strict): + """Cannot create `one` or `zero` from undefined value.""" + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + cls(float("NaN"), strict=strict) + + @pytest.mark.parametrize("scaler", [1, 10, 100, 1000]) + def test_get_one_if_within_threshold(self, cls, scaler): + """`GF2()` returns `one` if `value` is larger than `threshold`.""" + # `not_within_threshold` is larger than the `default_threshold` + # but still different from `1` => `strict=False` + value = scaler * not_within_threshold + threshold = scaler * default_threshold + + result = cls(value, strict=False, threshold=threshold) + assert result is one + + @pytest.mark.parametrize("scaler", [1, 10, 100, 1000]) + @pytest.mark.parametrize("strict", [True, False]) + def test_get_zero_if_within_threshold(self, cls, scaler, strict): + """`GF2()` returns `zero` if `value` is smaller than `threshold`.""" + # `within_threshold` is smaller than the `default_threshold` + value = scaler * within_threshold + threshold = scaler * default_threshold + + result = cls(value, strict=strict, threshold=threshold) + assert result is zero + + +@pytest.mark.parametrize("strict", [True, False]) +class TestGF2ConstructorWithoutCastedValue: + """Test the `GF2` class's constructor. + + `GF2()` returns either `one` or `zero`. + """ + + def test_get_one_from_sub_class_with_no_input_value(self, strict): + """`GF2One()` returns `one`.""" + result = GF2One(strict=strict) + assert result is one + + @pytest.mark.parametrize("cls", [GF2, GF2Zero]) + def test_get_zero_with_no_input_value(self, cls, strict): + """`GF2()` and `GF2Zero()` return `zero`.""" + result = cls(strict=strict) + assert result is zero + + +class TestGenericBehavior: + """Test the classes behind `one` and `zero`.""" + + def test_cannot_instantiate_base_class_alone(self, monkeypatch): + """`GF2One` and `GF2Zero` must be instantiated before `GF2`.""" + monkeypatch.setattr(GF2, "_instances", {}) + with pytest.raises(RuntimeError, match="internal error"): + GF2() + + @pytest.mark.parametrize("cls", [GF2, GF2One, GF2Zero]) + def test_create_singletons(self, cls): + """Singleton pattern: The classes always return the same instance.""" + first = cls() + second = cls() + assert first is second + + @pytest.mark.parametrize("obj", [one, zero]) + def test_sub_classes_return_objs(self, obj): + """`type(one)` and `type(zero)` return ... + + the sub-classes that create `one` and `zero`. + """ + sub_cls = type(obj) + assert sub_cls is not GF2 + + new_obj = sub_cls() + assert new_obj is obj + + @pytest.mark.parametrize("obj", [one, zero]) + @pytest.mark.parametrize( + "type_", + [ + numbers.Number, + numbers.Complex, + numbers.Real, + numbers.Rational, + ], + ) + def test_objs_are_numbers(self, obj, type_): + """`one` and `zero` are officially `Numbers`s.""" + assert isinstance(obj, type_) + + @pytest.mark.parametrize("cls", [GF2, GF2One, GF2Zero]) + @pytest.mark.parametrize( + "method", + [ + "__abs__", + "__trunc__", + "__floor__", + "__ceil__", + "__round__", + "__floordiv__", + "__rfloordiv__", + "__mod__", + "__rmod__", + "__lt__", + "__le__", + "numerator", + "denominator", + ], + ) + @pytest.mark.parametrize("value", [1, 0]) + def test_classes_fulfill_rational_numbers_abc( + self, + cls, + method, + monkeypatch, + value, + ): + """Ensure all of `numbers.Rational`'s abstact methods are implemented.""" + monkeypatch.setattr(GF2, "_instances", {}) + monkeypatch.delattr(GF2, method) + + sub_cls = type("GF2Baby", (cls, numbers.Rational), {}) + + with pytest.raises(TypeError, match="instantiate abstract class"): + sub_cls(value) + + @pytest.mark.parametrize("func", [repr, str]) + @pytest.mark.parametrize("obj", [one, zero]) + def test_text_repr_for_objs(self, func, obj): + """`repr(one)` and `repr(zero)` return "one" and "zero" ... + + ... which is valid code evaluating into the objects themselves. + + `str()` does the same as `repr()`. + """ + new_obj = eval(func(obj)) # noqa: S307 + assert new_obj is obj + + @pytest.mark.parametrize("func", [repr, str]) + @pytest.mark.parametrize("obj", [one, zero]) + def test_text_repr_for_classes(self, func, obj): + """'GF2' is the text representation for all sub-classes ... + + ... which is valid code referring to the base class `GF2`. + + `GF2()` returns `zero` if called without arguments. + """ + base_cls = eval(func(type(obj))) # noqa: S307 + assert base_cls is GF2 + + new_obj = base_cls() + assert new_obj is zero + + +class TestNumericBehavior: + """Test how `one` and `zero` behave like numbers.""" + + def test_make_complex(self): + """`one` and `zero` behave like `1 + 0j` and `0 + 0j`.""" + assert (complex(one), complex(zero)) == (1 + 0j, 0 + 0j) + + def test_make_float(self): + """`one` and `zero` behave like `1.0` and `0.0`.""" + assert (float(one), float(zero)) == (1.0, 0.0) + + @pytest.mark.parametrize("func", [int, hash]) + def test_make_int(self, func): + """`one` and `zero` behave like `1` and `0`. + + That also holds true for their hash values. + """ + assert (func(one), func(zero)) == (1, 0) + + def test_make_bool(self): + """`one` and `zero` behave like `True` and `False`.""" + assert (bool(one), bool(zero)) == (True, False) + + @pytest.mark.parametrize("obj", [one, zero]) + def test_get_abs_value(self, obj): + """`abs(one)` and `abs(zero)` are `one` and `zero`.""" + assert abs(obj) is obj + + @pytest.mark.parametrize("obj", [one, zero]) + @pytest.mark.parametrize("func", [math.trunc, math.floor, math.ceil, round]) + def test_round_obj(self, obj, func): + """`func(one)` and `func(zero)` equal `1` and `0`.""" + assert func(obj) in (1, 0) + + if CROSS_REFERENCE: + assert func(obj) == obj + + def test_real_part(self): + """`one.real` and `zero.real` are `1` and `0`.""" + assert (one.real, zero.real) == (1, 0) + + def test_imag_part(self): + """`one.imag` and `zero.imag` are `0`.""" + assert (one.imag, zero.imag) == (0, 0) + + def test_conjugate(self): + """`one.conjugate()` and `zero.conjugate()` are `1 + 0j` and `0 + 0j`.""" + assert (one.conjugate(), zero.conjugate()) == (1 + 0j, 0 + 0j) + + def test_one_as_fraction(self): + """`one.numerator / one.denominator` equals `1`.""" + assert (one.numerator, one.denominator) == (1, 1) + + def test_zero_as_fraction(self): + """`one.numerator / one.denominator` equals `0`.""" + assert (zero.numerator, zero.denominator) == (0, 1) + + +class TestComparison: + """Test `one` and `zero` interact with relational operators.""" + + @pytest.mark.parametrize("obj", [one, zero]) + def test_equal_to_itself(self, obj): + """`one` and `zero` are equal to themselves.""" + assert obj == obj # noqa: PLR0124 + + @pytest.mark.parametrize( + ["first", "second"], + [ + (one, one), + (one, 1), + (one, 1.0), + (one, 1 + 0j), + (zero, zero), + (zero, 0), + (zero, 0.0), + (zero, 0 + 0j), + ], + ) + def test_equal_to_another(self, first, second): + """`one` and `zero` are equal to `1`-like and `0`-like numbers.""" + assert first == second + assert second == first + + @pytest.mark.parametrize( + ["first", "second"], + [ + (one, zero), + (one, 0), + (one, 0.0), + (one, 0 + 0j), + (zero, 1), + (zero, 1.0), + (zero, 1 + 0j), + ], + ) + def test_not_equal_to_another_one_or_zero_like(self, first, second): + """`one` and `zero` are not equal to `0`-like and `1`-like numbers.""" + assert first != second + assert second != first + + @pytest.mark.parametrize( + ["first", "second"], + [ + (one, 42), + (one, 42.0), + (one, 42 + 0j), + (one, 0 + 42j), + (zero, 42), + (zero, 42.0), + (zero, 42 + 0j), + (zero, 0 + 42j), + ], + ) + def test_not_equal_to_another_non_one_like(self, first, second): + """`one` and `zero` are not equal to non-`1`-or-`0`-like numbers.""" + assert first != second + assert second != first + + @pytest.mark.parametrize("operator", [operator.gt, operator.ge]) + def test_one_greater_than_or_equal_to_zero(self, operator): + """`one > zero` and `one >= zero`.""" + assert operator(one, zero) + + @pytest.mark.parametrize("operator", [operator.lt, operator.le]) + def test_one_not_smaller_than_or_equal_to_zero(self, operator): + """`not one < zero` and `not one <= zero`.""" + assert not operator(one, zero) + + @pytest.mark.parametrize("operator", [operator.lt, operator.le]) + def test_zero_smaller_than_or_equal_to_one(self, operator): + """`zero < one` and `zero <= one`.""" + assert operator(zero, one) + + @pytest.mark.parametrize("operator", [operator.gt, operator.ge]) + def test_zero_not_greater_than_or_equalt_to_one(self, operator): + """`not zero > one` and `not zero >= one`.""" + assert not operator(zero, one) + + @pytest.mark.parametrize("obj", [one, zero]) + def test_obj_not_strictly_greater_than_itself(self, obj): + """`obj >= obj` but not `obj > obj`.""" + assert obj >= obj # noqa: PLR0124 + assert not obj > obj # noqa: PLR0124 + + @pytest.mark.parametrize("obj", [one, zero]) + def test_obj_not_strictly_smaller_than_itself(self, obj): + """`obj <= obj` but not `obj < obj`.""" + assert obj <= obj # noqa: PLR0124 + assert not obj < obj # noqa: PLR0124 + + @pytest.mark.parametrize("obj", [one, zero]) + @pytest.mark.parametrize( + "operator", + [operator.gt, operator.ge, operator.lt, operator.le], + ) + def test_compare_to_other_operand_of_wrong_type(self, obj, operator): + """`one` and `zero` may only interact with numbers.""" + with pytest.raises(TypeError): + operator(obj, "abc") + + with pytest.raises(TypeError): + operator("abc", obj) + + @pytest.mark.parametrize("obj", [one, zero]) + @pytest.mark.parametrize( + "operator", + [operator.gt, operator.ge, operator.lt, operator.le], + ) + def test_compare_to_other_operand_of_wrong_value(self, obj, operator): + """`one` and `zero` may only interact with `1`-like or `0`-like numbers.""" + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + operator(obj, 42) + + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + operator(42, obj) + + +class TestArithmetic: + """Test `one` and `zero` interact with arithmetic operators.""" + + @pytest.mark.parametrize("obj", [one, zero]) + @pytest.mark.parametrize("operator", [operator.pos, operator.neg]) + def test_make_obj_positive_or_negative(self, obj, operator): + """`+one` and `+zero` equal `-one` and `-zero`.""" + assert obj is operator(obj) + + @pytest.mark.parametrize( + "objs", + [ + (one, one, zero), + (one, zero, one), + (zero, zero, zero), + (one, 1, zero), + (one, 1.0, zero), + (one, 1 + 0j, zero), + (one, 0, one), + (one, 0.0, one), + (one, 0 + 0j, one), + (zero, 1, one), + (zero, 1.0, one), + (zero, 1 + 0j, one), + (zero, 0, zero), + (zero, 0.0, zero), + (zero, 0 + 0j, zero), + ], + ) + @pytest.mark.parametrize("operator", [operator.add, operator.sub]) + def test_addition_and_subtraction(self, objs, operator): + """Adding and subtracting `one` and `zero` is identical and commutative.""" + first, second, expected = objs + + result1 = operator(first, second) + assert result1 is expected + + result2 = operator(second, first) + assert result2 is expected + + if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math + + result3 = GF2((operator(int(abs(first)), int(abs(second))) + 2) % 2) + assert result3 is expected + + result4 = GF2((operator(int(abs(second)), int(abs(first))) + 2) % 2) + assert result4 is expected + + @pytest.mark.parametrize( + ["first", "second", "expected"], + [ + (one, one, one), + (one, zero, zero), + (zero, zero, zero), + (one, 1, one), + (one, 1.0, one), + (one, 1 + 0j, one), + (one, 0, zero), + (one, 0.0, zero), + (one, 0 + 0j, zero), + (zero, 1, zero), + (zero, 1.0, zero), + (zero, 1 + 0j, zero), + (zero, 0, zero), + (zero, 0.0, zero), + (zero, 0 + 0j, zero), + ], + ) + def test_multiplication(self, first, second, expected): + """Multiplying `one` and `zero` is commutative.""" + result1 = first * second + assert result1 is expected + + result2 = second * first + assert result2 is expected + + if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math + + result3 = GF2(int(abs(first)) * int(abs(second))) + assert result3 is expected + + result4 = GF2(int(abs(second)) * int(abs(first))) + assert result4 is expected + + @pytest.mark.parametrize( + "objs", + [ + # In Python 3.9 we cannot floor-divide a `complex` number + (one, one, one), + (one, 1, one), + (one, 1.0, one), + (one, 1 + 0j, one), + (1, one, one), + (1.0, one, one), + (zero, one, zero), + (zero, 1, zero), + (zero, 1.0, zero), + (zero, 1 + 0j, zero), + (0, one, zero), + (0.0, one, zero), + ], + ) + @pytest.mark.parametrize( + "operator", + [operator.truediv, operator.floordiv], + ) + def test_division_by_one(self, objs, operator): + """Division by `one`.""" + first, second, expected = objs + + result1 = operator(first, second) + assert result1 is expected + + if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math + + result2 = GF2(operator(int(abs(first)), int(abs(second)))) + assert result2 is expected + + @pytest.mark.parametrize( + "objs", + [ + # In Python 3.9 we cannot modulo-divide a `complex` number + (one, one, zero), + (one, 1, zero), + (one, 1.0, zero), + (one, 1 + 0j, zero), + (1, one, zero), + (1.0, one, zero), + (zero, one, zero), + (zero, 1, zero), + (zero, 1.0, zero), + (zero, 1 + 0j, zero), + (0, one, zero), + (0.0, one, zero), + ], + ) + def test_modulo_division_by_one(self, objs): + """Division by `one`.""" + first, second, expected = objs + + result1 = first % second + assert result1 is expected + + if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math + + result2 = GF2(int(abs(first)) % int(abs(second))) + assert result2 is expected + + @pytest.mark.parametrize( + "objs", + [ + # In Python 3.9 we cannot floor-divide a `complex` number + (one, zero), + (one, 0), + (one, 0.0), + (1, zero), + (1.0, zero), + (zero, zero), + (zero, 0), + (zero, 0.0), + (0, zero), + (0.0, zero), + ], + ) + @pytest.mark.parametrize( + "operator", + [operator.truediv, operator.floordiv, operator.mod], + ) + def test_division_by_zero(self, objs, operator): + """Division by `zero` raises `ZeroDivisionError`.""" + first, second = objs + + with pytest.raises(ZeroDivisionError): + operator(first, second) + + if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math + + with pytest.raises(ZeroDivisionError): + operator(int(abs(first)), int(abs(second))) + + @pytest.mark.parametrize( + "objs", + [ + (one, one, one), + (one, 1, one), + (one, 1.0, one), + (one, 1 + 0j, one), + (1, one, one), + (1.0, one, one), + (1 + 0j, one, one), + (zero, one, zero), + (zero, 1, zero), + (zero, 1.0, zero), + (zero, 1 + 0j, zero), + (0, one, zero), + (0.0, one, zero), + (0 + 0j, one, zero), + (one, zero, one), + (one, 0, one), + (one, 0.0, one), + (one, 0 + 0j, one), + (1, zero, one), + (1.0, zero, one), + (1 + 0j, zero, one), + (zero, zero, one), + (zero, 0, one), + (zero, 0.0, one), + (zero, 0 + 0j, one), + (0, zero, one), + (0.0, zero, one), + (0 + 0j, zero, one), + ], + ) + def test_to_the_power_of(self, objs): + """Exponentiation with `one` and `zero`.""" + first, second, expected = objs + + result1 = first**second + assert result1 is expected + + if CROSS_REFERENCE: # cast `one` and `zero` as `integer`s before doing the math + + result2 = GF2(int(abs(first)) ** int(abs(second))) + assert result2 is expected + + @pytest.mark.parametrize("obj", [one, zero]) + @pytest.mark.parametrize( + "operator", + [ + operator.add, + operator.mul, + operator.truediv, + operator.floordiv, + operator.mod, + operator.pow, + ], + ) + def test_other_operand_of_wrong_type(self, obj, operator): + """`one` and `zero` may only interact with numbers.""" + # Cannot use a `str` like `"abc"` as then `%` means string formatting + with pytest.raises(TypeError): + operator(obj, ("a", "b", "c")) + + with pytest.raises(TypeError): + operator(("a", "b", "c"), obj) + + @pytest.mark.parametrize("obj", [one, zero]) + @pytest.mark.parametrize( + "operator", + [ + operator.add, + operator.mul, + operator.truediv, + operator.floordiv, + operator.mod, + operator.pow, + ], + ) + def test_other_operand_of_wrong_value(self, obj, operator): + """`one` and `zero` may only interact with `1`-like or `0`-like numbers.""" + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + operator(obj, 42) + + with pytest.raises(ValueError, match="`1`-like or `0`-like"): + operator(42, obj) + + +@pytest.mark.skipif( + not sys.version_info < (3, 11), + reason='"typing-extensions" are installed to support Python 3.9 & 3.10', +) +def test_can_import_typing_extensions(): + """For Python versions 3.11+ we do not need the "typing-extensions".""" + package = importlib.import_module("lalib.elements.gf2") + importlib.reload(package) + + assert package.Self is not None diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py index 4890a36..89ae74d 100644 --- a/tests/test_docstrings.py +++ b/tests/test_docstrings.py @@ -14,6 +14,7 @@ import xdoctest "module", [ "lalib", + "lalib.elements.gf2", ], ) def test_docstrings(module):