Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move to a non-copying constructor #142

Merged
merged 5 commits into from
Apr 6, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(args[0])
AstraLuma marked this conversation as resolved.
Show resolved Hide resolved
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)
otherx, othery = Vector2._unpack(other)
AstraLuma marked this conversation as resolved.
Show resolved Hide resolved
except ValueError:
return NotImplemented
return rtype(self.x + other.x, self.y + other.y)
return rtype(self.x + otherx, self.y + othery)

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)
otherx, othery = Vector2._unpack(other)
except ValueError:
return NotImplemented
return rtype(self.x - other.x, self.y - other.y)
return rtype(self.x - otherx, self.y - othery)

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
otherx, othery = Vector2._unpack(other)
return self.x * otherx + self.y * othery

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)
otherx, othery = Vector2._unpack(other)
except (TypeError, ValueError):
return NotImplemented
else:
return self.x == other.x and self.y == other.y
return self.x == otherx and self.y == othery

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)
otherx, othery = Vector2._unpack(other)

rv = degrees(atan2(other.x, -other.y) - atan2(self.x, -self.y))
rv = degrees(atan2(otherx, -othery) - 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