1
\$\begingroup\$

I'm writing a pure-python geometry tool for technical drawings / computational geometry (not a solver, as a solver has to work with constraints). I've already mentioned a previous version on code review, as an example for Metaclass Wrapping Builtin to Enforce Subclass Type. This question is about the Point class and the documentation/style/examples or packaging. I'm using Yapf/isort for formatting, and it would be helpful if I could get further best practices.

Tree

$ tree --gitignore
.
├── conf.py
├── geometry
│   ├── __init__.py
│   └── point.py
├── __init__.py
├── LICENSE.MD
├── README.md
├── ROADMAP.md
├── TODO.MD
└── _utils
    └── _type_enforcer.py

point.py

#!/usr/bin/env python
import math
from math import cos, sin, tan
from typing import ClassVar, Iterable, Union

from .._utils._type_enforcer import TypeEnforcerMetaclass

Point = ClassVar["Point"]


class Point(complex, metaclass=TypeEnforcerMetaclass, enforce_all=True):
    r"""
    :param x: x coordinate of the point.
    :type x: ``int``, ``float``
    :param y: y coordinate, Stored as the imaginary component
    :type y: ``int``, ``float``

    ::
        .. highlight:: python
        >>> a = Point(2, 3)
        >>> b = Point(-3, 5)
        >>> a
        Point(2.0, 3.0)
        >>> a + b
        Point(-1.0, 8.0)
        >>> a.midpoint(b)
        Point(-0.5, 4.0)
        >>> a * b
        Point(-6.0, 15.0)

    """
    ORIGIN = 0  # Works because 0 == 0+0j

    def __new__(cls, *args, **kwargs):
        if len(args) == 1:
            if isinstance(args[0], complex):
                return super().__new__(cls, args[0].real, args[0].imag,
                                       *args[1:], **kwargs)
            elif isinstance(args[0], Iterable) and len(args[0]) == 2:
                return super().__new__(cls, args[0][0], args[0][1], *args[1:],
                                       **kwargs)
            else:
                raise ValueError(
                    """Only complex or iterable with length 2  can be used
                with single-argument form""")
        else:
            return super().__new__(cls, *args, **kwargs)

    def __init__(self, x, y: float = 0):
        pass

    def __repr__(self):
        return f"Point({self.real}, {self.imag})"

    def __complex__(self):
        """Convert self to exact type complex"""
        return complex(self)

    def __getitem__(self, val):
        if val not in range(0, 2):
            raise IndexError("")

    def __mul__(self, other) -> "Point":
        """Element-wise multiplication of two points, unless other is a real.number or a complex. Falls back on complex behavior.
        :param other: Another point, real number, or complex
        :type other: ``int``, ``float``, ``point``, ``complex``
        ::
            .. highlight:: python
            >>> Point(-3, 6) * Point(2, -3)
            Point(-6.0, -18.0)
        """
        if isinstance(other, Point):
            return Point(self.real * other.real, self.imag * other.imag)

        return super().__mul__(other)

    @property
    def x(self):
        """
        X coordinate of the point. Represented internally as real component
        ::
            .. highlight:: python
            >>> a = Point(3, 5)
            >>> a.x
            3.0
            >>> a.x = 5
            >>> a
            Point(5.0, 5.0)
        """
        return self.real

    @x.setter
    def x(self, val):
        self.real = val

    @property
    def y(self):
        """
        Y coordinate of the point. Represented internally as imag component
        ::
            .. highlight:: python
            >>> a = Point(3, 5)
            >>> a.y
            5.0
            >>> a.y = 2
            >>> a
            Point(5.0, 2.0)
        """
        return self.imag

    @y.setter
    def y(self, val):
        self.imag = val

    def scale(self, other, center=ORIGIN):
        """Scales self by other around center
        :param other: vector to scale self by. Floats are interpreted as
        scaling by (other, other)
        :type other: Point, Iterable, complex, float
        ::
            .. highlight:: python
            >>> Point(2,1).scale(2)
            Point(4,2)
            >>> Point(3,4).scale((3, 5))
            Point(9, 20)
            >>> Point(2,2).scale(10+10j, center=Point(2,2))
            Point(2,2)
        """
        scale = 0
        if isinstance(other, Point):
            scale = other
        elif isinstance(other, Iterable) and len(other) == 2:
            scale = Point(*other)
        elif isinstance(other, complex):
            scale = Point(other)
        elif hasattr(other, "real"):
            scale = Point(other, other)
        else:
            raise ValueError(other, "is not a valid type for Point.scale")
        local = self - center
        local *= scale
        return local + center

    def midpoint(self, other):
        """Midpoint between self and other, in cartesian coordinates.
        Floats are interpreted as Point(other, 0)
        :param other: coordinate to take midpoint between it and self
        :type other: Point, complex or float.
        ::
        .. highlight:: python
            >>> Point(3, 5).midpoint(Point(5, 3))
            Point(4, 4)
        """
        return self * 0.5 + other * 0.5

    def distance(self, other: Point = ORIGIN) -> float:
        """Euclidian distance between self and other
        Floats are interpreted as Point(other, 0)
        :param other: coordinate to take euclidian distance of
        :returns: Euclidian distance
        :rtype: float
        ::
        .. highlight:: python
            >>> Point(3.0, 4.0).distance()
            5.0
        """
        return abs(self - other)

    def taxicab_distance(self, other: Point = ORIGIN) -> float:
        """Returns taxicab distance between self and other.
        Floats are interpreted as Point(other, 0)
        :param other: coordinate to take taxicab distance of.
        :type other: Point, complex, float 
        :returns: `abs(self.x - other.x) + abs(self.y - other.y)`
        :rtype: float
        ::
        .. highlight:: python
            >>> Point(3.0, 4.0).taxicab_distance()
            7.0
            >>> # 3.0 - 0.0 + 4.0 - 0.0 = 7.0
        """
        return abs(self.real - other.real) + abs(self.imag - other.imag)

    def rotate(self, theta: float, center: Point = ORIGIN) -> Point:
        """Rotates point around center by theta radians. Equivalent to
        `Point(self * cmath.exp(theta * 1j * cmath.pi))`
        :param theta: angle of rotation in radians
        :param center: center of rotation. Defaults to ORIGIN
        :returns: new rotated Point
        :rtype: Point
        ::
        .. highlight:: python
            >>> Point(2,3).rotate(math.pi/2)
            Point(-3.0, -2.0)
        """
        local_coords = self - center
        # Multiplying by z, where abs(z) == 1 is the same as rotation
        local_coords *= Point.__conj_rotation(theta)
        local_coords += center

        return local_coords

    @staticmethod
    def __conj_rotation(theta: float):
        """Returns a complex representing a rotation vector.
        :param theta: theta, in radians of the rotation
        :type theta: float
        :return: representation of rotation
        :rtype: complex
        ::
            .. highlight:: python
            >>> Point._Point__conj_rotation(math.pi)
            (-1+...e-16j)
        """
        return complex(cos(theta), sin(theta))




if __name__ == "__main__":
    import doctest
    doctest.testmod(optionflags=doctest.ELLIPSIS)

_type_enforcer.py

#!/usr/bin/env python
import logging
from types import MemberDescriptorType, WrapperDescriptorType

default_exclude = {"__new__", "__getattribute__", "__setattribute__"}


class TypeEnforcerMetaclass(type):
    """ 
    Metaclass that enforces return type. Ensures that inhereted methods return
    proper class (that of the inheritee), as in the case of builtins.
    When `enforce_all == True`, creates intermediate class which is wrapped to
    the correct type. 

    :param enforce_all: Enforces return type of super()
    :type enforce_all: ``bool``
    :param exclude: Ignore special names, such as __X__.
    :type enforce_all: ``bool``
        
    Example::
    
        >>> class A(int, metaclass=TypeEnforcerMetaclass, enforce_all=True, exclude={"real"}):
        ...     def __repr__(self):
        ...         return f"A({super().real})"
        >>> A(1)
        A(1)
        >>> A(1) + A(4)
        A(5)
        >>> A(3) * A(-3)
        A(-9)
        >>> super(A)
        <super: <class 'A'>, NULL>
        >>> type(A)
        <class '__main__.TypeEnforcerMetaclass'>
    """

    def __new__(meta,
                name,
                bases,
                classdict,
                enforce_all=False,
                exclude=default_exclude):
        exclude = exclude.union(default_exclude)
        logging.info(f"Creating class {name} in {meta.__name__}")
        # Creates a new abstraction layer (middleman class), so super()
        # returns wrapped class
        # that has all its methods wrapped.
        # ┌────────────┐  ┌────────────┐  ┌────────────┐
        # │   Point    │⇦│   _compl   │⇦│  complex   │
        # └────────────┘  └────────────┘  └────────────┘
        superclass = bases[0]
        
        if enforce_all:
            logging.debug(f"Parameter enforce_all is turned ON")
            # Somehow, name mangling doesn't show up when printing class.
            # Probably __repr__ isn't being overriden
            inter_name = meta.__name__ + "." + superclass.__name__
            logging.debug(f"Creating intermediate class {inter_name}")

            inter_dict = dict(superclass.__dict__)
            inter_class = super(TypeEnforcerMetaclass,
                                meta).__new__(meta, inter_name, bases,
                                              inter_dict)
            bases = (inter_class, *bases)  # Neat trick for tuples

        subclass = super(TypeEnforcerMetaclass,
                         meta).__new__(meta, name, bases, classdict)
        

        logging.debug("New Bases", bases)
        logging.debug("sub:", subclass, "meta:", type(subclass))
        # Has the potential to cause errors if not enabled
        # logging.debug("inter:", inter_class, "meta:", type(inter_class))
        logging.debug("Class info: ",subclass, subclass.__name__, subclass.mro(), subclass.__dict__, sep="\n")

        base_to_wrap = subclass.mro()[1]
        type_compare = subclass
        type_to_convert = subclass

        if enforce_all:
            type_compare = inter_class
            base_to_wrap = inter_class.mro()[1]

        for attr, obj in base_to_wrap.__dict__.items():
            if isinstance(obj, MemberDescriptorType):
                logging.debug("Skipping", obj, "Due to MemberDescriptor")
                continue

            # Traverse the mro
            # == testing is exactly what we want for comparing overridden
            # definitions,
            if obj == getattr(type_compare, attr,
                              None) and attr not in exclude:
                # Dont override __new__!
                # Check if the method is inhereted from base_to_wrap
                # Iff inherited, wrap the return type
                logging.info("Wrapping", obj, "to return", type_to_convert.__name__)
                setattr(
                    type_compare, attr,
                    TypeEnforcerMetaclass.return_wrapper(
                        superclass, type_to_convert, obj))

        return subclass

    def return_wrapper(cls, convert_cls, func):
        """Wraps class methods and enforces type"""
        if isinstance(func, (str, int, float)):
            return func

        logging.debug("Decorator", cls, func.__name__)
        def convert_if(val):
            if isinstance(val, cls):
                logging.debug("Wrapped:", val.__class__,  val)
                return convert_cls(val)
            else:
                logging.debug("Skipped:", val.__class__, val)
                logging.debug("Reason:", cls.__class__, "!=", val.__class__)

                return val

        def wrapper(*args, **kwargs):
            logging.debug("Wrapper", cls, func.__name__)
            return convert_if(func(*args, **kwargs))

        wrapper.__wrapped__ = func
        return wrapper

if __name__ == "__main__":
    import doctest
    logging.setLevel(logging.INFO)
    logging.info("Running doctests")
    doctest.testmod(report=True)
\$\endgroup\$
4
  • \$\begingroup\$ I was halfway through writing an answer on your previous question, and I guess I'll still post there; but basically: why pure Python? This sounds like it's going to need grown-up numerics, and that simply isn't possible in pure Python. \$\endgroup\$
    – Reinderien
    Aug 30, 2023 at 22:51
  • \$\begingroup\$ I'm just using complex numbers, as I found it conveniently stored as much data as I need it to. Two fields, each representing a different number line. Also: I wanted to learn metaclasses, because why not learn black magic? \$\endgroup\$ Aug 30, 2023 at 23:32
  • 1
    \$\begingroup\$ If you really want complex numbers, you should rethink __mul__. It is not how complex numbers are multiplied. \$\endgroup\$
    – vnp
    Aug 31, 2023 at 3:44
  • \$\begingroup\$ @vnp There is a reason to why I overrode __mul__, and if you read it carefully, you will see that multiplying a point by a complex number, will call super().__mul__(other), thereby achieving the correct multiplication if so desired. Even with the type enforcer metaclass, it will only change the return type to return a Point \$\endgroup\$ Aug 31, 2023 at 3:47

2 Answers 2

2
\$\begingroup\$

doctests

Kudos for the doctests, they are very helpful. Doctests are documentation I can believe! They do not bit rot (since failing tests are quickly noticed).

    doctest.testmod(optionflags=doctest.ELLIPSIS)
    Example::
    
        >>> class A(int, metaclass=TypeEnforcerMetaclass, enforce_all=True, exclude={"real"}):
        ...     def __repr__(self):
        ...         return f"A({super().real})"

I do worry a little about unintended ... matching.

The doctests are very nice, but they're no substitute for a test suite. The two tools have different audiences. Doctests are short, non-comprehensive, and must be understood by newbies. A test suite can be long, boring, and explore a tediously large number of odd corner cases.


documentation toolchain

class Point(...):
    r"""
    :param x: ...
    :type x: ``int``, ``float``

I guess you're using something like Sphinx? The need for a r"raw" docstring seems a little inconvenient. But maybe you really need fancy formatting of types. Docstrings ideally look good both before and after sphinx processing. That is, we prefer docstrings that aren't cluttered with formatting details, such as "highlight".

Consider re-evaluating your goals and your documentation toolchain.

There's more than one standard for writing docstrings, and it seems clear that you're trying to adhere to one. So write it down, preferably accompanied by an URL citation.

I'm a little sad we couldn't get away with using optional type hints to document valid inputs, but maybe we really do need __new__ instead of __init__ to accomplish your desired goals. When such type annotations are used, I try to mention the type just once, so annotation and documentation can't get out of sync with one another.


limited flexibility

    def __new__(cls, *args, **kwargs):
        if len(args) == 1:
            if isinstance(args[0], complex):
                return super().__new__(cls, args[0].real, args[0].imag,
                                       *args[1:], **kwargs)

Ok, we're trying to offer a lot of flexibility to the caller, kind of a DWIM Public API. Maybe that's good. I guess I'm not yet convinced, in part because I haven't seen motivating examples of tests calling into this code. I will just assume it's a Good Thing for now.

I don't understand what's going on with that *args[1:] expression. Didn't we just establish that it must always be the empty list here? Recommend you elide it. And similarly where it appears again, two lines down.

            elif isinstance(args[0], Iterable) and len(args[0]) == 2:
                return super().__new__(cls, args[0][0], args[0][1], *args[1:],
                                       **kwargs)

I don't understand that conjunct, it just doesn't make any sense. First we ask if .__iter__ is present. And then we take a length?!? But we never asked about .__len__. Being a container is a much stronger restriction than being iterable. Consider this example:

def odds(limit=11):
    """Generates a small subset of the odd integers."""
    yield from range(1, limit, 2)

Maybe you want to assign list(args[0]) to a temp var first? At that point you know you have a container.

Let's move on to the ... , args[0][0], args[0][1], ... expressions. If I pass in a set of {3, 4} we will survive the iterable and length tests. But you didn't ask if subscripting is supported; dereferencing [0] and [1] will blow up.

EAFP

It is easier to ask for forgiveness than permission. You've been attempting to go down the LBYL path, and it's not always easy.

Embrace try!


defaults

    def __init__(self, x, y: float = 0):
        pass

Sorry, I didn't understand that.

Perhaps you intended ...(self, x: float = 0.0, y: float = 0.0): ?

Also, this kind of highlights the whole Union[int, float] issue, which unfairly (arbitrarily?) rejects Decimal and Fraction.

Consider turning everything that you store into a float, in the interest of simplicity. It would be one of your class invariants.

    @x.setter
    def x(self, val):
        self.real = val

Back to the invariant thing, maybe assign float(val) ? You did a bunch of validating in __new__, which the setter threatens to subvert.


getitem

    def __getitem__(self, val):
        if val not in range(0, 2):
            raise IndexError("")

I don't understand that. Didn't you want to map 0 to .real and 1 to .imag ?

Also, might as well include val in the diagnostic error message.


LBYL

        elif isinstance(other, Iterable) and len(other) == 2:
            scale = Point(*other)

In scale() the second conjunct isn't safe because we only looked for .__iter__ and not .__len__.

Embrace try!

The high level design issue here is you attempt to accept roughly as many cases as numpy, but without writing down clear motivating use cases and without offering Green unit tests and coverage measurements.

Recommend you scale down your ambitions, and wait for motivating use cases to organically arise.


DRY

    def midpoint(self, other):
        """Midpoint between self and other, in cartesian coordinates.
        Floats are interpreted as Point(other, 0)
        :param other: coordinate to take midpoint between it and self
        :type other: Point, complex or float.

Here the type annotation admits Any type in a way that $ mypy *.py can check, yet the documentation restricts to a smaller set of types, hidden from mypy.

    def distance(self, other: Point = ORIGIN) -> float:
        """Euclidian distance between self and other
        Floats are interpreted as Point(other, 0)
        :param other: coordinate to take euclidian distance of

Here we have a lovely annotation which is not too ambitious; it specifies exactly one type. But the documentation admits of multiple types, and is not self-consistent (a "float" is not a "coordinate"). Subsequent methods exhibit similar difficulties.

Recommend you push your type documentation exclusively into the annotations, so mypy can help you out. I want to be able to believe the documentation that I'm reading.


name mangling

    def __conj_rotation(theta: float):

Recommend you rename to _conj_rotation, as name mangling is seldom what you want.

Alternatively, supply automated tests involving inheritance which demonstrate some name mangling benefit to the app-level developer.


This codebase appears to achieve many of its design objectives. I am skeptical that its ambition to "accept any plausible type!" is compatible with the current target code and the current automated testing code.

I would be willing to delegate or accept maintenance tasks on this codebase.

\$\endgroup\$
5
  • \$\begingroup\$ Not a codebase yet (or close for that matter). I'm not using Sphinx quite yet, but I'm doing all the docstrings so they show up as nice hints in VS code. \$\endgroup\$ Aug 30, 2023 at 23:30
  • \$\begingroup\$ Unaccept for now, so I can gather more advice. \$\endgroup\$ Aug 30, 2023 at 23:37
  • \$\begingroup\$ @j-h minor correction to your answer: hasattr(int, "real") always returns True. Same with hasattr(int, "imag") \$\endgroup\$ Aug 31, 2023 at 0:22
  • \$\begingroup\$ Oh dear, a thousand pardons! I incorrectly looked at 42.real which leads to a parse ambiguity. I should instead have been looking at (42).real, which works nicely as you indicated. (Or assign n = 42 and then look at n.real. Or even introducing a SPACE will help: 42 .real.) My erroneous text suggested that scale(2.0) would work where perhaps scale(2) would not. I am deleting that, since scaling by an integer works fine. \$\endgroup\$
    – J_H
    Aug 31, 2023 at 0:51
  • \$\begingroup\$ 999 too many pardons for me. I never knew that a number could have its attributes accessed by a space. \$\endgroup\$ Aug 31, 2023 at 1:13
1
\$\begingroup\$

You want to learn about metaclasses. That's good! You also want to develop a geometry tool following best practices - that's also good! Where this immediately falls apart is

  • Pure Python is expressly an anti-goal. Good Python programs make use of the right tool for the job, and sometimes pure Python is the wrong tool for the job when better libraries are available.
  • Metaclass (as you describe it) black magic is an extreme case of five-dimensional hypercube peg, round hole. It's fine to do metaclasses where they're called-for, and they aren't called-for here. Attempting to bolt on runtime type checking in any kind of systematic sense is trying to turn Python into something it's simply not.
  • Some things just aren't classes. This is just a series of numeric routines.
  • If I found this at work my reaction would be some variation of oh heck no.
  • There's a sentiment here that I've seen play out to disastrous effect, that basically runs "I want to isolate from the world, therefore I need to rebuild the world". Your learning will be better-served by learning how to integrate into the package ecosystem, rather than building an inner platform.

Those grumps aside, let's go through the code:

#!/usr/bin/env python needs to be #!/usr/bin/env python3.

For all of the effort you go through to perform type checks, you're still missing many type hints - e.g. __init__ x, the return types for your first four member functions, etc. I encourage you to run mypy on this code.

if val not in range(0, 2): is probably better as if not (0 <= val <= 1).

raise IndexError("") is not informative enough and should have a message.

As @vnp correctly says, it's very important that you do not conflate complex multiplication and Hadamard (element-wise) multiplication. The latter should in no way, shape or form be supported through the * operator so long as this class smells like a complex. It's not obvious to me that scale is always going to do the right thing, since it invokes * when that should be element-wise multiplication.

rtype is redundant when you have the correct type hint.

A sample of simplified routines could look like

#!/usr/bin/env python3


def element_mul(a: complex | float, b: complex | float) -> complex:
    """Element-wise multiplication, aka. Hadamard product
    >>> element_mul(-3 + 6j, 2 - 3j)
    (-6-18j)
    """
    a = complex(a)
    b = complex(b)
    return complex(a.real*b.real, a.imag*b.imag)


def midpoint(a: complex | float, b: complex | float) -> complex:
    """
    >>> midpoint(0 + 1j, 4 + 5j)
    (2+3j)
    """
    return (a + b)*0.5


def distance(a: complex | float, b: complex | float = 0) -> float:
    """Euclidean distance between points
    >>> distance(3 + 4j)
    5.0
    >>> distance(4 + 1j, 4 + 2.j)
    1.0
    """
    return abs(a - b)


def scale(self: complex, scale_by: float | complex, centre: float | complex = 0) -> complex:
    """
    Scale self by scale_by about origin
    >>> scale(2 + 1j, 2)
    (4+2j)
    >>> scale(3 + 4j, 3 + 5j)
    (9+20j)
    >>> scale(2 + 2j, 10 + 10j, 2 + 2j)
    (2+2j)
    """
    if not isinstance(scale_by, complex):
        scale_by = complex(scale_by, scale_by)
    return element_mul(self - centre, scale_by) + centre


if __name__ == "__main__":
    import doctest
    doctest.testmod()
\$\endgroup\$
2
  • \$\begingroup\$ I have been on the look for packages which implement the functionality I'm looking for, and I settled on sympy. However, once I got to implementing intersections with conics, sympy was too exact (and therefore slow) for my intents. Other packages were completely abandoned or unfinished (often both) \$\endgroup\$ Aug 31, 2023 at 14:40
  • \$\begingroup\$ That sounds like the actual question you should be asking: how to speed up your specific operation. \$\endgroup\$
    – Reinderien
    Aug 31, 2023 at 15:46

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.