Carlos Adir
Carlos Adir

Reputation: 472

Python annotation on __init__ for two ways to instantiate a class

I have a class Circle

class Circle:
    def __init__(self, R: float):
        self.R = R

    @property
    def A(self):
        return 3.14*self.R**2

# Annotation: Circle(R: float) -> None

But I want two ways to create an instance of this class using explicit arguments

  1. Given the radius R: Circle(R = 1)
  2. Given the diameter D: Circle(D = 2)

Then I can do it

class Circle:
    def __init__(self, **kwargs):
        if "R" in kwargs:
            self.R = kwargs["R"]
        elif "D" in kwargs:
            self.R = kwargs["D"]/2

# Annotation: Circle(kwargs: Any) -> None

But the annotation of this __init__ gives no information that:

Question: How can inform the user that this class accepts two inputs? And how do I implement it in a clean code way?

Upvotes: 1

Views: 596

Answers (3)

Daniil Fajnberg
Daniil Fajnberg

Reputation: 18598

Two ways to call

Distinct call signatures for the same function are defined using typing.overload.

Explicit parameters

To force a function parameter to be "keyword-only", you can insert a bare * in the parameter list before it.

Sensible defaults

Since you'll be likely dealing with floats only, a convenient object is the math.nan singleton. It is a float instance, but always non-equal to any other float. This allows you to keep your parameter type constrained to float as opposed to float | None for example.

Exclusive or

The int class (of which bool is a subclass) has the bitwise exclusive or operator ^ defined for it. By combining that with math.isnan we can therefore concisely check that only exactly one of the two arguments were provided.

Suggested implementation

Full working example:

from math import isnan, nan
from typing import overload


class Circle:
    @overload
    def __init__(self, *, radius: float) -> None:
        ...

    @overload
    def __init__(self, *, diameter: float) -> None:
        ...

    def __init__(self, *, radius: float = nan, diameter: float = nan) -> None:
        """Takes either a `radius` or a `diameter` but not both."""
        if not isnan(radius) ^ isnan(diameter):
            raise TypeError("Either radius or diameter required")
        self.radius = radius if isnan(diameter) else diameter / 2


if __name__ == "__main__":
    c1 = Circle(radius=1)
    c2 = Circle(diameter=2)
    assert c1.radius == c2.radius
    # Circle(radius=3.14, diameter=42)  # error
    # Circle()  # same error

Some things to note

If you try this with for example PyCharm, after typing Circle and an opening parenthesis you'll see in a little popover the two possible calls listed in the order they were defined to hint to you that you have these two distinct options for calling the function. It does not show you the actual implementation's signature, where you have both parameters present.

If you add reveal_type(Circle) at the bottom and run mypy over that module, you'll get the following:

note: Revealed type is "Overload(def (*, radius: builtins.float) -> Circle, def (*, diameter: builtins.float) -> Circle)"

I agree with @dskrypa regarding names. See PEP 8 for more.

Also, the reason I defined a TypeError here is that this exception class is used by Python, when a function is called with unexpected arguments or arguments missing.

Finally, the ternary x if expr else y-construct is warranted, when you are dealing with a very simple expression and have two mutually exclusive and very simple assignment options. This is the case here after our check, so we can use it and make the code much shorter, as well as (arguably) cleaner and easier to read.

PS: In case you are wondering, bitwise XOR takes precedence over not, which is why not a ^ b without parantheses is effectively a XNOR b.

Upvotes: 2

Gui Reis
Gui Reis

Reputation: 486

In Python unfortunately it is not possible to have two __init__ for the same class. In Swift, for example, it is possible to create several constructors.

Also, it's much more common in Python to leave any parameters you want in the function call.

For example the function to create a bar graph from seaborn:

def barplot(
    x=None, y=None,
    hue=None, data=None,
    order=None, hue_order=None,
    estimator=np.mean, ci=95, n_boot=1000, units=None, seed=None,
    orient=None, color=None, palette=None, saturation=.75,
    errcolor=".26", errwidth=None, capsize=None, dodge=True,
    ax=None,
    **kwargs
):
    pass

If one of the attributes does not have a default value, put None.

In your case it would look something like this:

class Foo:
    def __init__(self, d: float = None, r: float = 0) -> None:
        self.R = r
        if d is not None:
            self.R = d/2

This way your code is much more readable. If you really need to use **kwargs, there's no way to escape the conditionals to see if each variable exists.

.

To inform the user that there are 2 variables using the docstring is an excellent practice:

class Foo:
    r"""Some description for Class."""

    def __init__(self, d: float = None, r: float = 0) -> None:
        r"""Some description.

        ### Parameters
            ``d``: float -- description
            ``r``: float -- description
        """
        self.r = r
        if d != None:
            self.R = d/2

When placing the mouse over it, it shows this documentation (or even when writing): enter image description here

Extra: The answer to this question might help you too.

Upvotes: 2

dskrypa
dskrypa

Reputation: 1108

I would write this as:

class Circle:
    def __init__(self, *, radius: float | None = None, diameter: float | None = None):
        if radius is diameter is None or None not in (radius, diameter):
            raise ValueError('radius xor diameter is required')
        elif radius is not None:
            self.radius = radius
        else:
            self.radius = diameter / 2

Note that it's more pythonic (and a better practice in general for readability) to use more verbose attribute/parameter names (in snake_case) than single-letter ones.

In general, it's better to avoid **kwargs when you are expecting specific parameters.

In cases where None would be a valid value, you could use an alternate sentinel value.

Another alternative would be to provide an alternate constructor:

from __future__ import annotations

class Circle:
    def __init__(self, radius: float):
        self.radius = radius

    @classmethod
    def from_diameter(cls, diameter: float) -> Circle:
        return cls(diameter / 2)

It would also be possible to use typing.overload with the first suggestion (or with **kwargs), but that can get a bit messier.

Upvotes: 3

Related Questions