Skip to content

Commit

Permalink
Merge #142
Browse files Browse the repository at this point in the history
142: Move to a non-copying constructor r=nbraud a=astronouth7303

Also remove `.convert()`.

Closes #137 

Co-authored-by: Jamie Bliss <jamie@ivyleav.es>
Co-authored-by: Nicolas Braud-Santoni <nicolas@braud-santoni.eu>
  • Loading branch information
3 people committed Apr 6, 2019
2 parents e0ffe50 + 5e0f4a4 commit f5c63ae
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 53 deletions.
105 changes: 54 additions & 51 deletions ppb_vector/vector2.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,25 @@ class Vector2:
__slots__ = ('x', 'y', '__weakref__')

@typing.overload
def __init__(self, x: typing.SupportsFloat, y: typing.SupportsFloat): pass
def __new__(cls, x: typing.SupportsFloat, y: typing.SupportsFloat): pass

@typing.overload
def __init__(self, other: VectorLike): pass
def __new__(cls, other: VectorLike): pass

def __init__(self, *args, **kwargs):
def __new__(cls, *args, **kwargs):
"""
Make a vector from coordinates, or convert a vector-like.
A vector-like can be:
- a length-2 :py:class:`Sequence <collections.abc.Sequence>`, whose
contents are interpreted as the ``x`` and ``y`` coordinates like ``(4, 2)``
- a length-2 :py:class:`Mapping <collections.abc.Mapping>`, whose keys
are ``x`` and ``y`` like ``{'x': 4, 'y': 2}``
- any instance of :py:class:`Vector2` or any subclass.
"""
if args and kwargs:
raise TypeError("Got a mix of positional and keyword arguments")

Expand All @@ -116,10 +129,17 @@ def __init__(self, *args, **kwargs):
if kwargs:
x, y = kwargs['x'], kwargs['y']
elif len(args) == 1:
x, y = Vector2.convert(args[0])
value = args[0]
if isinstance(value, cls):
# Short circuit if already a valid instance
return value

x, y = Vector2._unpack(value)
elif len(args) == 2:
x, y = args

self = super().__new__(cls)

try:
# The @dataclass decorator made the class frozen, so we need to
# bypass the class' default assignment function :
Expand All @@ -134,36 +154,19 @@ def __init__(self, *args, **kwargs):
except ValueError:
raise TypeError(f"{type(y).__name__} object not convertable to float")

return self

#: Return a new :py:class:`Vector2` replacing specified fields with new values.
update = dataclasses.replace

@classmethod
def convert(cls: typing.Type[Vector], value: VectorLike) -> Vector:
"""Constructs a vector from a vector-like.
A vector-like can be:
- a length-2 :py:class:`Sequence <collections.abc.Sequence>`, whose
contents are interpreted as the ``x`` and ``y`` coordinates like ``(4, 2)``
- a length-2 :py:class:`Mapping <collections.abc.Mapping>`, whose keys
are ``x`` and ``y`` like ``{'x': 4, 'y': 2}``
- any instance of :py:class:`Vector2` or any subclass.
:py:meth:`convert` does not perform a copy when ``value`` already has the
right type.
"""
# 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)
@staticmethod
def _unpack(value: VectorLike) -> typing.Tuple[float, float]:
if isinstance(value, Vector2):
return value.x, value.y
elif isinstance(value, Sequence) and len(value) == 2:
return cls(value[0], value[1])
return float(value[0]), float(value[1])
elif isinstance(value, Mapping) and 'x' in value and 'y' in value and len(value) == 2:
return cls(value['x'], value['y'])
return float(value['x']), float(value['y'])
else:
raise ValueError(f"Cannot use {value} as a vector-like")

Expand All @@ -185,9 +188,9 @@ def asdict(self) -> typing.Mapping[str, float]:
>>> v.asdict()
{'x': 42.0, 'y': 69.0}
The conversion can be reversed by :py:meth:`convert`:
The conversion can be reversed by constructing:
>>> assert v == Vector2.convert(v.asdict())
>>> assert v == Vector2(v.asdict())
"""
return {'x': self.x, 'y': self.y}

Expand All @@ -198,42 +201,42 @@ def __add__(self: Vector, other: VectorLike) -> Vector:
"""Add two vectors.
:param other: A :py:class:`Vector2` or a vector-like.
For a description of vector-likes, see :py:func:`convert`.
For a description of vector-likes, see :py:func:`__new__`.
>>> Vector2(1, 0) + (0, 1)
Vector2(1.0, 1.0)
"""
rtype = _find_lowest_vector(type(other), type(self))
try:
other = Vector2.convert(other)
other_x, other_y = Vector2._unpack(other)
except ValueError:
return NotImplemented
return rtype(self.x + other.x, self.y + other.y)
return rtype(self.x + other_x, self.y + other_y)

def __sub__(self: Vector, other: VectorLike) -> Vector:
"""Subtract one vector from another.
:param other: A :py:class:`Vector2` or a vector-like.
For a description of vector-likes, see :py:func:`convert`.
For a description of vector-likes, see :py:func:`__new__`.
>>> Vector2(3, 3) - (1, 1)
Vector2(2.0, 2.0)
"""
rtype = _find_lowest_vector(type(other), type(self))
try:
other = Vector2.convert(other)
other_x, other_y = Vector2._unpack(other)
except ValueError:
return NotImplemented
return rtype(self.x - other.x, self.y - other.y)
return rtype(self.x - other_x, self.y - other_y)

def dot(self: Vector, other: VectorLike) -> float:
"""Dot product of two vectors.
:param other: A :py:class:`Vector2` or a vector-like.
For a description of vector-likes, see :py:func:`convert`.
For a description of vector-likes, see :py:func:`__new__`.
"""
other = Vector2.convert(other)
return self.x * other.x + self.y * other.y
other_x, other_y = Vector2._unpack(other)
return self.x * other_x + self.y * other_y

def scale_by(self: Vector, scalar: typing.SupportsFloat) -> Vector:
"""Scalar multiplication.
Expand Down Expand Up @@ -338,17 +341,17 @@ def __eq__(self: Vector, other: typing.Any) -> bool:
"""Test wheter two vectors are equal.
:param other: A :py:class:`Vector2` or a vector-like.
For a description of vector-likes, see :py:func:`convert`.
For a description of vector-likes, see :py:func:`__new__`.
>>> Vector2(1, 0) == (0, 1)
False
"""
try:
other = Vector2.convert(other)
other_x, other_y = Vector2._unpack(other)
except (TypeError, ValueError):
return NotImplemented
else:
return self.x == other.x and self.y == other.y
return self.x == other_x and self.y == other_y

def __iter__(self: Vector) -> typing.Iterator[float]:
yield self.x
Expand All @@ -369,7 +372,7 @@ def angle(self: Vector, other: VectorLike) -> float:
"""Compute the angle between two vectors, expressed in degrees.
:param other: A :py:class:`Vector2` or a vector-like.
For a description of vector-likes, see :py:func:`convert`.
For a description of vector-likes, see :py:func:`__new__`.
>>> Vector2(1, 0).angle( (0, 1) )
90.0
Expand All @@ -379,9 +382,9 @@ def angle(self: Vector, other: VectorLike) -> float:
:py:meth:`angle` is guaranteed to produce an angle between -180° and 180°.
"""
other = Vector2.convert(other)
other_x, other_y = Vector2._unpack(other)

rv = degrees(atan2(other.x, -other.y) - atan2(self.x, -self.y))
rv = degrees(atan2(other_x, -other_y) - atan2(self.x, -self.y))
# This normalizes the value to (-180, +180], which is the opposite of
# what Python usually does but is normal for angles
if rv <= -180:
Expand All @@ -397,7 +400,7 @@ def isclose(self: Vector, other: VectorLike, *,
"""Perform an approximate comparison of two vectors.
:param other: A :py:class:`Vector2` or a vector-like.
For a description of vector-likes, see :py:func:`convert`.
For a description of vector-likes, see :py:func:`__new__`.
>>> assert Vector2(1, 0).isclose((1, 1e-10))
Expand All @@ -419,12 +422,12 @@ def isclose(self: Vector, other: VectorLike, *,
if abs_tol < 0 or rel_tol < 0:
raise ValueError("Vector2.isclose takes non-negative tolerances")

other = Vector2.convert(other)
other = Vector2(other)

rel_length = max(
self.length,
other.length,
*map(lambda v: Vector2.convert(v).length, rel_to),
*map(lambda v: Vector2(v).length, rel_to),
)

diff = (self - other).length
Expand Down Expand Up @@ -530,7 +533,7 @@ def reflect(self: Vector, surface_normal: VectorLike) -> Vector:
"""Reflect a vector against a surface.
:param other: A :py:class:`Vector2` or a vector-like.
For a description of vector-likes, see :py:func:`convert`.
For a description of vector-likes, see :py:func:`__new__`.
Compute the reflection of a :py:class:`Vector2` on a surface going
through the origin, described by its normal vector.
Expand All @@ -541,7 +544,7 @@ def reflect(self: Vector, surface_normal: VectorLike) -> Vector:
>>> Vector2(5, 3).reflect( Vector2(-1, -2).normalize() )
Vector2(0.5999999999999996, -5.800000000000001)
"""
surface_normal = Vector2.convert(surface_normal)
surface_normal = Vector2(surface_normal)
if not isclose(surface_normal.length, 1):
raise ValueError("Reflection requires a normalized vector.")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class V(Vector2):
)
@pytest.mark.parametrize("cls", [Vector2, V]) # type: ignore
def test_convert_class(cls, vector_like):
vector = cls.convert(vector_like)
vector = cls(vector_like)
assert isinstance(vector, cls)
assert vector == vector_like

Expand Down
16 changes: 16 additions & 0 deletions tests/test_vector2_ctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,19 @@ def test_ctor_vector_like(cls, x: Vector2):
@given(x=floats(), y=floats())
def test_ctor_coordinates(cls, x: float, y: float):
assert cls(x, y) == cls((x, y))


@pytest.mark.parametrize("cls", [Vector2, V])
def test_ctor_noncopy_same(cls):
v = cls(1, 2)
assert cls(v) is v


def test_ctor_noncopy_subclass():
v = V(1, 2)
assert Vector2(v) is v


def test_ctor_noncopy_superclass():
v = Vector2(1, 2)
assert V(v) is not v
2 changes: 1 addition & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def isclose(
SCALAR_OPS = [Vector2.rotate, Vector2.scale_by, Vector2.scale_to, Vector2.truncate]

# List of operations that (Vector2) -> Vector2
UNARY_OPS = [Vector2.__neg__, Vector2.convert, Vector2.normalize]
UNARY_OPS = [Vector2.__neg__, Vector2, Vector2.normalize]

# List of (Vector2) -> scalar operations
UNARY_SCALAR_OPS = [
Expand Down

0 comments on commit f5c63ae

Please sign in to comment.