diff --git a/.gitignore b/.gitignore index 2726f519..4aeaa48e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /*.egg-info/ __pycache__/ .hypothesis/ +/.mypy_cache/ diff --git a/.travis.yml b/.travis.yml index b2cbefa4..7f963766 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,3 +36,4 @@ install: script: - pytest + - mypy ppb_vector tests diff --git a/dev-requirements.txt b/dev-requirements.txt index ba545d17..5591de24 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,2 +1,3 @@ pytest~=3.8 hypothesis +mypy diff --git a/ppb_vector/vector2.py b/ppb_vector/vector2.py index 6fd9d540..cbaddb1a 100644 --- a/ppb_vector/vector2.py +++ b/ppb_vector/vector2.py @@ -1,97 +1,172 @@ import typing import collections +import functools from math import acos, atan2, cos, degrees, hypot, isclose, radians, sin from numbers import Real -from collections.abc import Sequence +from collections.abc import Sequence, Mapping __all__ = 'Vector2', +# Vector or subclass +VectorOrSub = typing.TypeVar('VectorOrSub', bound='Vector2') + +Realish = typing.Union[Real, float, int] + +# Anything convertable to a Vector, including lists, tuples, and dicts VectorLike = typing.Union[ - 'Vector2', - typing.List[Real], # TODO: Length 2 - typing.Tuple[Real, Real], - typing.Dict[str, Real], # TODO: Length 2, keys 'x', 'y' + 'Vector2', # Or subclasses, unconnected to the VectorOrSub typevar above + typing.Tuple[typing.SupportsFloat, typing.SupportsFloat], + typing.Sequence[typing.SupportsFloat], # TODO: Length 2 + typing.Mapping[str, typing.SupportsFloat], # TODO: Length 2, keys 'x', 'y' ] def is_vector_like(value: typing.Any) -> bool: - return isinstance(value, (Vector2, list, tuple, dict)) - + return isinstance(value, (Vector2, Sequence, dict)) + + +@functools.lru_cache() +def _find_lowest_type(left: typing.Type, right: typing.Type) -> typing.Type: + """ + Guess which is the more specific type. + """ + # Basically, see what classes are unique in each type's MRO and return who + # has the most. + lmro = set(left.__mro__) + rmro = set(right.__mro__) + lspecial = lmro - rmro + rspecial = rmro - lmro + if len(lmro) > len(rmro): + return left + elif len(rmro) > len(lmro): + return right + else: + # They're equal, just arbitrarily pick one + return left -_fakevector = collections.namedtuple('_fakevector', ['x', 'y']) -def _mkvector(value, *, castto=_fakevector): - if isinstance(value, Vector2): - return value - # FIXME: Allow all types of sequences - elif isinstance(value, (list, tuple)) and len(value) == 2: - return castto(value[0], value[1]) - # FIXME: Allow all types of mappings - elif isinstance(value, dict) and 'x' in value and 'y' in value and len(value) == 2: - return castto(value['x'], value['y']) +def _find_lowest_vector(left: typing.Type, right: typing.Type) -> typing.Type: + if left is right: + return left + elif not issubclass(left, Vector2): + return right + elif not issubclass(right, Vector2): + return left else: - raise ValueError(f"Cannot use {value} as a vector-like") + return _find_lowest_type(left, right) -class Vector2(Sequence): +class Vector2: + x: float + y: float + _length: float - def __init__(self, x: Real, y: Real): - self.x = x - self.y = y - self.length = hypot(x, y) + def __init__(self, x: typing.SupportsFloat, y: typing.SupportsFloat): + try: + self.x = x.__float__() + except AttributeError: + raise TypeError(f"{type(x).__name__} object not convertable to float") + try: + self.y = y.__float__() + except AttributeError: + raise TypeError(f"{type(y).__name__} object not convertable to float") @classmethod - def convert(cls, value: VectorLike) -> 'Vector2': + def convert(cls: typing.Type[VectorOrSub], value: VectorLike) -> VectorOrSub: """ - Constructs a vector from a vector-like. + Constructs a vector from a vector-like. Does not perform a copy. """ - return _mkvector(value, castto=type(cls)) + # Use Vector2.convert() instead of type(self).convert() so that + # _find_lowest_vector() can resolve things well. + if isinstance(value, cls): + return value + elif isinstance(value, Vector2): + return cls(value.x, value.y) + elif isinstance(value, Sequence) and len(value) == 2: + return cls(value[0].__float__(), value[1].__float__()) + elif isinstance(value, Mapping) and 'x' in value and 'y' in value and len(value) == 2: + return cls(value['x'].__float__(), value['y'].__float__()) + else: + raise ValueError(f"Cannot use {value} as a vector-like") - def __len__(self) -> int: + @property + def length(self) -> float: + # Surprisingly, caching this value provides no descernable performance + # benefit, according to microbenchmarks. + return hypot(self.x, self.y) + + def __len__(self: VectorOrSub) -> int: return 2 - def __add__(self, other: VectorLike) -> 'Vector2': + def __add__(self: VectorOrSub, other: VectorLike) -> VectorOrSub: + rtype = _find_lowest_vector(type(other), type(self)) try: - other = _mkvector(other) + other = Vector2.convert(other) except ValueError: return NotImplemented - rtype = type(other) if isinstance(other, Vector2) else type(self) return rtype(self.x + other.x, self.y + other.y) - def __sub__(self, other: VectorLike) -> 'Vector2': + def __sub__(self: VectorOrSub, other: VectorLike) -> VectorOrSub: + rtype = _find_lowest_vector(type(other), type(self)) try: - other = _mkvector(other) + other = Vector2.convert(other) except ValueError: return NotImplemented - rtype = type(other) if isinstance(other, Vector2) else type(self) return rtype(self.x - other.x, self.y - other.y) - def __mul__(self, other: VectorLike) -> 'Vector2': + def dot(self: VectorOrSub, other: VectorLike) -> Realish: + """ + Return the dot product of two vectors. + """ + other = Vector2.convert(other) + return self.x * other.x + self.y * other.y + + def scale_by(self: VectorOrSub, other: Realish) -> VectorOrSub: + """ + Scale by the given amount. + """ + return type(self)(self.x * other, self.y * other) + + @typing.overload + def __mul__(self: VectorOrSub, other: VectorLike) -> Realish: pass + + @typing.overload + def __mul__(self: VectorOrSub, other: Realish) -> VectorOrSub: pass + + def __mul__(self, other): + """ + Performs a dot product or scale based on other. + """ if is_vector_like(other): try: - other = _mkvector(other) + return self.dot(other) except ValueError: return NotImplemented - return self.x * other.x + self.y * other.y elif isinstance(other, Real): - return Vector2(self.x * other, self.y * other) + return self.scale_by(other) else: return NotImplemented - def __rmul__(self, other: VectorLike) -> 'Vector2': + @typing.overload + def __rmul__(self: VectorOrSub, other: VectorLike) -> Realish: pass + + @typing.overload + def __rmul__(self: VectorOrSub, other: Realish) -> VectorOrSub: pass + + def __rmul__(self, other): return self.__mul__(other) - def __xor__(self, other: VectorLike) -> Real: + def __xor__(self: VectorOrSub, other: VectorLike) -> Realish: """ Computes the magnitude of the cross product """ - other = _mkvector(other) + other = Vector2.convert(other) return self.x * other.y - self.y * other.x - def __getitem__(self, item: typing.Union[str, int]) -> Real: + def __getitem__(self: VectorOrSub, item: typing.Union[str, int]) -> Realish: if hasattr(item, '__index__'): - item = item.__index__() + item = item.__index__() # type: ignore if isinstance(item, str): if item == 'x': return self.x @@ -109,32 +184,28 @@ def __getitem__(self, item: typing.Union[str, int]) -> Real: else: raise TypeError - def __repr__(self) -> str: + def __repr__(self: VectorOrSub) -> str: return f"{type(self).__name__}({self.x}, {self.y})" - def __eq__(self, other: VectorLike) -> bool: + def __eq__(self: VectorOrSub, other: typing.Any) -> bool: if is_vector_like(other): - other = _mkvector(other) + other = Vector2.convert(other) return self.x == other.x and self.y == other.y else: return False - def __ne__(self, other: VectorLike) -> bool: - if is_vector_like(other): - other = _mkvector(other) - return self.x != other.x or self.y != other.y - else: - return True + def __ne__(self: VectorOrSub, other: typing.Any) -> bool: + return not (self == other) - def __iter__(self) -> typing.Iterator[Real]: + def __iter__(self: VectorOrSub) -> typing.Iterator[Realish]: yield self.x yield self.y - def __neg__(self) -> 'Vector2': - return self * -1 + def __neg__(self: VectorOrSub) -> VectorOrSub: + return self.scale_by(-1) - def angle(self, other: VectorLike) -> Real: - other = _mkvector(other, castto=Vector2) + def angle(self: VectorOrSub, other: VectorLike) -> float: + other = Vector2.convert(other) rv = degrees( atan2(other.x, -other.y) - atan2(self.x, -self.y) ) # This normalizes the value to (-180, +180], which is the opposite of @@ -146,7 +217,7 @@ def angle(self, other: VectorLike) -> Real: return rv - def isclose(self, other: 'Vector2', *, rel_tol: float=1e-06, abs_tol: float=1e-3): + def isclose(self: VectorOrSub, other: VectorLike, *, rel_tol: Realish=1e-06, abs_tol: Realish=1e-3) -> bool: """ Determine whether two vectors are close in value. @@ -162,42 +233,51 @@ def isclose(self, other: 'Vector2', *, rel_tol: float=1e-06, abs_tol: float=1e-3 For the values to be considered close, the difference between them must be smaller than at least one of the tolerances. """ + other = Vector2.convert(other) diff = (self - other).length return ( - diff < rel_tol * max(self.length, other.length) or - diff < abs_tol + diff < rel_tol * self.length or + diff < rel_tol * other.length or + diff < float(abs_tol) ) - def rotate(self, degrees: Real) -> 'Vector2': + def rotate(self: VectorOrSub, degrees: Realish) -> VectorOrSub: r = radians(degrees) r_cos = cos(r) r_sin = sin(r) x = self.x * r_cos - self.y * r_sin y = self.x * r_sin + self.y * r_cos - return Vector2(x, y) + return type(self)(x, y) - def normalize(self) -> 'Vector2': + def normalize(self: VectorOrSub) -> VectorOrSub: return self.scale(1) - def truncate(self, max_length: Real) -> 'Vector2': + def truncate(self: VectorOrSub, max_length: Realish) -> VectorOrSub: if self.length > max_length: - return self.scale(max_length) + return self.scale_to(max_length) return self - def scale(self, length: Real) -> 'Vector2': + def scale_to(self: VectorOrSub, length: Realish) -> VectorOrSub: + """ + Scale the vector to the given length + """ try: scale = length / self.length except ZeroDivisionError: scale = 1 - return self * scale + return self.scale_by(scale) + + scale = scale_to - def reflect(self, surface_normal: VectorLike) -> 'Vector2': + def reflect(self: VectorOrSub, surface_normal: VectorLike) -> VectorOrSub: """ Calculate the reflection of the vector against a given surface normal """ - surface_normal = _mkvector(surface_normal, castto=Vector2) + surface_normal = Vector2.convert(surface_normal) if not isclose(surface_normal.length, 1): raise ValueError("Reflection requires a normalized vector.") return self - (2 * (self * surface_normal) * surface_normal) + +Sequence.register(Vector2) diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 00000000..d55de6e9 --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,76 @@ +import pytest # type: ignore + +from ppb_vector import Vector2 + +# List of operations that (Vector2, Vector2) -> Vector2 +BINARY_OPS = [ + Vector2.__add__, + Vector2.__sub__, + Vector2.reflect, +] + +# List of operations that (Vector2, Real) -> Vector2 +VECTOR_NUMBER_OPS = [ + Vector2.scale_by, + Vector2.rotate, + Vector2.truncate, + Vector2.scale_to, +] + +# List of operations that (Vector2) -> Vector2 +UNARY_OPS = [ + lambda v: type(v).convert(v), + Vector2.__neg__, + Vector2.normalize, +] + +@pytest.mark.parametrize('op', BINARY_OPS) +def test_binop_same(op): + class V(Vector2): pass + + # Normalize for reflect + a = op(V(1, 2), V(3, 4).normalize()) + + assert isinstance(a, V) + + +@pytest.mark.parametrize('op', BINARY_OPS) +def test_binop_different(op): + class V1(Vector2): pass + class V2(Vector2): pass + + # Normalize for reflect + a = op(V1(1, 2), V2(3, 4).normalize()) + b = op(V2(1, 2), V1(3, 4).normalize()) + assert isinstance(a, (V1, V2)) + assert isinstance(b, (V1, V2)) + + +@pytest.mark.parametrize('op', BINARY_OPS) +def test_binop_subclass(op): + class V1(Vector2): pass + class V2(V1): pass + + # Normalize for reflect + a = op(V1(1, 2), V2(3, 4).normalize()) + b = op(V2(1, 2), V1(3, 4).normalize()) + assert isinstance(a, V2) + assert isinstance(b, V2) + + +@pytest.mark.parametrize('op', VECTOR_NUMBER_OPS) +def test_vnumop(op): + class V(Vector2): pass + + a = op(V(1, 2), 42) + + assert isinstance(a, V) + + +@pytest.mark.parametrize('op', UNARY_OPS) +def test_monop(op): + class V(Vector2): pass + + a = op(V(1, 2)) + + assert isinstance(a, V) diff --git a/tests/test_vector2.py b/tests/test_vector2.py index 146d5ce7..aba4b2a2 100644 --- a/tests/test_vector2.py +++ b/tests/test_vector2.py @@ -1,4 +1,4 @@ -import pytest +import pytest # type: ignore from ppb_vector import Vector2 @@ -18,3 +18,28 @@ def test_is_iterable(): @pytest.mark.parametrize('test_vector, expected_result', negation_data) def test_negation(test_vector, expected_result): assert -test_vector == expected_result + + +@pytest.mark.parametrize('value', [ + Vector2(1, 2), + [3, 4], + (5, 6), + {'x': 7, 'y': 8}, +]) +def test_convert(value): + v = Vector2.convert(value) + assert isinstance(v, Vector2) + assert v == value + + +@pytest.mark.parametrize('value', [ + Vector2(1, 2), + [3, 4], + (5, 6), + {'x': 7, 'y': 8}, +]) +def test_convert_subclass(value): + class V(Vector2): pass + v = V.convert(value) + assert isinstance(v, V) + assert v == value diff --git a/tests/test_vector2_addition.py b/tests/test_vector2_addition.py index b653cc5a..9cb6a827 100644 --- a/tests/test_vector2_addition.py +++ b/tests/test_vector2_addition.py @@ -1,4 +1,4 @@ -import pytest +import pytest # type: ignore from ppb_vector import Vector2 diff --git a/tests/test_vector2_angle.py b/tests/test_vector2_angle.py index ace5fe36..2913d9b3 100644 --- a/tests/test_vector2_angle.py +++ b/tests/test_vector2_angle.py @@ -1,6 +1,6 @@ from ppb_vector import Vector2 from math import isclose -import pytest +import pytest # type: ignore from hypothesis import assume, given, note from utils import angle_isclose, vectors @@ -10,7 +10,7 @@ (Vector2(1, 1), Vector2(-1, 0), 135), (Vector2(0, 1), Vector2(0, -1), 180), (Vector2(-1, -1), Vector2(1, 0), 135), - (Vector2(-1, -1), Vector2(-1, 0), 45), + (Vector2(-1, -1), Vector2(-1, 0), -45), (Vector2(1, 0), Vector2(0, 1), 90), (Vector2(1, 0), Vector2(1, 0), 0), ]) @@ -20,14 +20,14 @@ def test_angle(left, right, expected): assert -180 < lr <= 180 assert -180 < rl <= 180 assert isclose(lr, expected) - assert isclose(rl, -expected) + assert isclose(rl, 180 if expected == 180 else -expected) @given( left=vectors(), right=vectors(), ) -def test_angle(left, right): +def test_angle_prop(left, right): lr = left.angle(right) rl = right.angle(left) assert -180 < lr <= 180 diff --git a/tests/test_vector2_cross.py b/tests/test_vector2_cross.py index 35f17cc1..e4838fe8 100644 --- a/tests/test_vector2_cross.py +++ b/tests/test_vector2_cross.py @@ -1,5 +1,5 @@ from ppb_vector import Vector2 -import pytest +import pytest # type: ignore @pytest.mark.parametrize("left, right, expected", [ (Vector2(1, 1), Vector2(0, -1), -1), diff --git a/tests/test_vector2_length.py b/tests/test_vector2_length.py index 219d0bc9..5a04b328 100644 --- a/tests/test_vector2_length.py +++ b/tests/test_vector2_length.py @@ -1,5 +1,5 @@ import ppb_vector -import pytest +import pytest # type: ignore @pytest.mark.parametrize("x, y, expected", [ diff --git a/tests/test_vector2_member_access.py b/tests/test_vector2_member_access.py index 8b31b195..3a282de1 100644 --- a/tests/test_vector2_member_access.py +++ b/tests/test_vector2_member_access.py @@ -1,4 +1,4 @@ -import pytest +import pytest # type: ignore from ppb_vector import Vector2 diff --git a/tests/test_vector2_normalize.py b/tests/test_vector2_normalize.py index 12743bde..f315f925 100644 --- a/tests/test_vector2_normalize.py +++ b/tests/test_vector2_normalize.py @@ -1,4 +1,4 @@ -import pytest +import pytest # type: ignore import ppb_vector diff --git a/tests/test_vector2_reflect.py b/tests/test_vector2_reflect.py index b4461d90..4e642150 100644 --- a/tests/test_vector2_reflect.py +++ b/tests/test_vector2_reflect.py @@ -1,5 +1,5 @@ from ppb_vector import Vector2 -import pytest +import pytest # type: ignore from hypothesis import given, assume, note from math import isclose, isinf from utils import angle_isclose, units, vectors diff --git a/tests/test_vector2_rotate.py b/tests/test_vector2_rotate.py index 31d38990..232b47a3 100644 --- a/tests/test_vector2_rotate.py +++ b/tests/test_vector2_rotate.py @@ -1,6 +1,6 @@ from ppb_vector import Vector2 from utils import angle_isclose, vectors -import pytest +import pytest # type: ignore import math from hypothesis import assume, given, note import hypothesis.strategies as st diff --git a/tests/test_vector2_scalar_multiplication.py b/tests/test_vector2_scalar_multiplication.py index 2e0fcef9..4251b428 100644 --- a/tests/test_vector2_scalar_multiplication.py +++ b/tests/test_vector2_scalar_multiplication.py @@ -1,4 +1,4 @@ -import pytest +import pytest # type: ignore from ppb_vector import Vector2 diff --git a/tests/test_vector2_scale.py b/tests/test_vector2_scale.py index 06c269a6..2b2be309 100644 --- a/tests/test_vector2_scale.py +++ b/tests/test_vector2_scale.py @@ -1,4 +1,4 @@ -import pytest +import pytest # type: ignore from math import hypot from ppb_vector import Vector2 diff --git a/tests/test_vector2_substraction.py b/tests/test_vector2_substraction.py index 715352b1..d38d8bdb 100644 --- a/tests/test_vector2_substraction.py +++ b/tests/test_vector2_substraction.py @@ -1,4 +1,4 @@ -import pytest +import pytest # type: ignore from ppb_vector import Vector2 diff --git a/tests/test_vector2_truncate.py b/tests/test_vector2_truncate.py index 606243ea..55d52aed 100644 --- a/tests/test_vector2_truncate.py +++ b/tests/test_vector2_truncate.py @@ -1,4 +1,4 @@ -import pytest +import pytest # type: ignore from ppb_vector import Vector2