diff --git a/ppb/sprites.py b/ppb/sprites.py index f9b0cf8e..5235e2f1 100644 --- a/ppb/sprites.py +++ b/ppb/sprites.py @@ -4,6 +4,7 @@ from ppb import Vector from ppb.events import EventMixin +from ppb.utils import FauxFloat import ppb_vector.vector2 @@ -17,7 +18,7 @@ side_attribute_error_message = error_message.format -class Side: +class Side(FauxFloat): sides = { LEFT: ('x', -1), RIGHT: ('x', 1), @@ -33,51 +34,20 @@ def __repr__(self): return f"Side({self.parent!r}, {self.side!r})" def __str__(self): - return str(self.value) - - def __add__(self, other): - return self.value + other - - def __radd__(self, other): - return other + self.value - - def __sub__(self, other): - return self.value - other - - def __rsub__(self, other): - return other - self.value - - def __eq__(self, other): - return self.value == other - - def __le__(self, other): - return self.value <= other - - def __ge__(self, other): - return self.value >= other - - def __ne__(self, other): - return self.value != other - - def __gt__(self, other): - return self.value > other - - def __lt__(self, other): - return self.value < other + return str(float(self)) def _lookup_side(self, side): dimension, sign = self.sides[side] return dimension, sign * self.parent._offset_value - @property - def value(self): + def __float__(self): dimension, offset = self._lookup_side(self.side) return self.parent.position[dimension] + offset @property def top(self): self._attribute_gate(TOP, [TOP, BOTTOM]) - return Vector(self.value, self.parent.top.value) + return Vector(float(self), float(self.parent.top)) @top.setter def top(self, value): @@ -87,7 +57,7 @@ def top(self, value): @property def bottom(self): self._attribute_gate(BOTTOM, [TOP, BOTTOM]) - return Vector(self.value, self.parent.bottom.value) + return Vector(float(self), float(self.parent.bottom)) @bottom.setter def bottom(self, value): @@ -97,7 +67,7 @@ def bottom(self, value): @property def left(self): self._attribute_gate(LEFT, [LEFT, RIGHT]) - return Vector(self.parent.left.value, self.value) + return Vector(float(self.parent.left), float(self)) @left.setter def left(self, value): @@ -107,7 +77,7 @@ def left(self, value): @property def right(self): self._attribute_gate(RIGHT, [LEFT, RIGHT]) - return Vector(self.parent.right.value, self.value) + return Vector(float(self.parent.right), float(self)) @right.setter def right(self, value): @@ -117,9 +87,9 @@ def right(self, value): @property def center(self): if self.side in (TOP, BOTTOM): - return Vector(self.parent.center.x, self.value) + return Vector(self.parent.center.x, float(self)) else: - return Vector(self.value, self.parent.center.y) + return Vector(float(self), self.parent.center.y) @center.setter def center(self, value): diff --git a/ppb/utils.py b/ppb/utils.py index 13beb393..1ca3032e 100644 --- a/ppb/utils.py +++ b/ppb/utils.py @@ -1,7 +1,9 @@ import logging import sys +import numbers +import math -__all__ = 'LoggingMixin', +__all__ = 'LoggingMixin', 'FauxFloat', # Dictionary mapping file names -> module names @@ -48,3 +50,88 @@ def logger(self): module_name = _get_module(file_name) return logging.getLogger(module_name) + + +class FauxFloat(numbers.Real): + """ + When applied to a class that implements __float__, provides the full suite + of number-related special methods. + + While this mixin doesn't do anything about it, you should consider makiing + your class immutable. Odd things could potentially happen otherwise. + """ + + def __abs__(self): + return float(self).__abs__() + + def __add__(self, other): + return float(self).__add__(other) + + def __ceil__(self): + return math.ceil(float(self)) + + def __eq__(self, other): + return float(self).__eq__(other) + + def __float__(self, other): + return float(self).__float__(other) + + def __floor__(self): + return math.floor(float(self)) + + def __floordiv__(self, other): + return float(self).__floordiv__(other) + + def __ge__(self, other): + return float(self).__ge__(other) + + def __gt__(self, other): + return float(self).__gt__(other) + + def __le__(self, other): + return float(self).__le__(other) + + def __lt__(self, other): + return float(self).__lt__(other) + + def __mod__(self, other): + return float(self).__mod__(other) + + def __mul__(self, other): + return float(self).__mul__(other) + + def __neg__(self): + return float(self).__neg__() + + def __pos__(self): + return float(self).__pos__() + + def __pow__(self, other): + return float(self).__pow__(other) + + def __radd__(self, other): + return float(self).__radd__(other) + + def __rfloordiv__(self, other): + return float(self).__rfloordiv__(other) + + def __rmod__(self, other): + return float(self).__rmod__(other) + + def __rmul__(self, other): + return float(self).__rmul__(other) + + def __round__(self, ndigits=None): + return float(self).__round__(ndigits) + + def __rpow__(self, other): + return float(self).__rpow__(other) + + def __rtruediv__(self, other): + return float(self).__rtruediv__(other) + + def __truediv__(self, other): + return float(self).__truediv__(other) + + def __trunc__(self): + return float(self).__trunc__() diff --git a/requirements-tests.txt b/requirements-tests.txt index e079f8a6..9b9e5f99 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -1 +1,2 @@ pytest +hypothesis diff --git a/tests/test_fauxfloat.py b/tests/test_fauxfloat.py new file mode 100644 index 00000000..6247e60a --- /dev/null +++ b/tests/test_fauxfloat.py @@ -0,0 +1,90 @@ +import math +import operator + +from ppb.utils import FauxFloat + +import pytest + +from hypothesis import given, assume +import hypothesis.strategies as st + + +def get_thingy(num): + class Thingy(FauxFloat): + def __float__(self): + return num + + return Thingy() + + +# The use of parametrize() over st.sampled_from() is deliberate. + + +@pytest.mark.parametrize( + "operation", + [ + float, abs, bool, int, math.ceil, math.floor, math.trunc, operator.neg, + operator.pos, + ], +) +@given(num=st.floats(allow_nan=False, allow_infinity=False)) +def test_unary_ops(operation, num): + t = get_thingy(num) + + assert operation(t) == operation(num) + + +@pytest.mark.parametrize( + "operation", + [ + operator.lt, operator.le, operator.eq, operator.ne, operator.ge, + operator.gt, operator.add, operator.mul, operator.sub, + ], +) +@given( + num=st.floats(allow_nan=False, allow_infinity=False), + other=st.floats(allow_nan=False, allow_infinity=False), +) +def test_binary_ops(operation, num, other): + t = get_thingy(num) + + assert operation(t, other) == operation(num, other) + assert operation(other, t) == operation(other, num) + + +@pytest.mark.parametrize( + "operation", + [ + operator.floordiv, operator.mod, operator.truediv, + ], +) +@given( + num=st.floats(allow_nan=False, allow_infinity=False), + other=st.floats(allow_nan=False, allow_infinity=False), +) +def test_binary_ops_nonzero(operation, num, other): + assume(num != 0) + assume(other != 0) + t = get_thingy(num) + + assert operation(t, other) == operation(num, other) + assert operation(other, t) == operation(other, num) + + +@given( + base=st.floats(allow_nan=False, allow_infinity=False, min_value=-1e20, max_value=1e20), + exponent=st.floats(allow_nan=False, allow_infinity=False, min_value=-10, max_value=10), +) +def test_pow(base, exponent): + assume(base != 0 and exponent != 0) + + assert operator.pow(get_thingy(base), exponent) == operator.pow(base, exponent) + assert operator.pow(base, get_thingy(exponent)) == operator.pow(base, exponent) + + +@given( + num=st.floats(allow_nan=False, allow_infinity=False), + digits=st.integers() | st.none(), +) +def test_round(num, digits): + assert round(get_thingy(num), digits) == round(num, digits)