How to express multiple types for a single parameter or a return value in docstrings that are processed by Sphinx?

Python 3.10 | (pipe, binary or) Union type hint syntax sugar

Once you get access, this will be the way to go, it is sweet:

def f(i: int|str) -> int|str:
    if type(i) is str:
        return int(i) + 1
    else:
        return str(i)

The PEP: https://peps.python.org/pep-0604/

Documented at: https://docs.python.org/3.11/library/typing.html#typing.Union

Union type; Union[X, Y] is equivalent to X | Y and means either X or Y.

Python 3.5 Union type hints

https://docs.python.org/3/library/typing.html#typing.Union

from typing import Union

def f(i: Union[int,str]) -> Union[int,str]:
    if type(i) is str:
        return int(i) + 1
    else:
        return str(i)

What to do before you get access to typing

For the poor souls stuck in older Pythons, I recommend using the exact same syntax as that Python 3 module, which will:

  • make porting easier, and possibly automatable, later on
  • specifies a unique well defined canonical way to do things

Example:

def f(i: Union[int,str]) -> Union[int,str]:
    """
    :param i: Description of the parameter
    :type i: Union[int,str]
    :rtype: Union[int,str]
    """
    if type(i) is str:
        return int(i) + 1
    else:
        return str(i)

or syntax

While reading through the docs I found another recommendation, now likely fully obsoleted by Union which also just works, but which might work on even older sphinx https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html#info-field-lists

Multiple types in a type field will be linked automatically if separated by the word “or”:

:type an_arg: int or None
:vartype a_var: str or int
:rtype: float or str

typing.Optional: optional arguments

As mentioned in this comment, Optional is a synonym to Union[SomeType,None], e.g.:

from typing import Optional

def maybe_i(i: Optional[int] = None) -> int:
    if i is None:
        return 0
    return i + 1

assert maybe_i() == 0
assert maybe_i(1) == 2

However, with the introduction of |, perhaps the scales have shifted in favor of SomeType|None which both golfs better (5 chars vs 11 chars, if you don’t spaces around |) and is more explicit:

def maybe_i(i: int|None = None) -> int:
    if i is None:
        return 0
    return i + 1

assert maybe_i() == 0
assert maybe_i(1) == 2

Sphinx support

Sphinx supports both typing and :type x: Union[int,str] well now. Example:

requirements.txt

Sphinx==4.5.0

main.py

from typing import Optional, Union

class C:
    '''
    My doc for C!
    '''
    pass

class D:
    '''
    My doc for D!
    '''
    pass

def main(i: Union[C, D]) -> Union[C, D]:
    '''
    My doc for main!

    :param i: My doc for i!
    '''
    return C()

def main_docstring(i):
    '''
    My doc for main_docstring!

    :param i: My doc for i!
    :type i: Union[C, D]
    :rtype: Union[C, D]
    '''
    return C()

def main_optional(i: Optional[C]) -> Optional[C]:
    '''
    My doc for main_optional!
    '''
    return None

def main_optional_docstring(i):
    '''
    My doc for main_optional_docstring!

    :param i: My doc for i!
    :type i: Optional[C]
    :rtype: Optional[C]
    '''
    return None

conf.py

import os
import sys
sys.path.insert(0, os.path.abspath('.'))
extensions = [ 'sphinx.ext.autodoc' ]
#autodoc_typehints = "description"

index.rst

.. automodule:: main
    :members:

Build with:

sphinx-build . out

Now:

xdg-open out/index.html

contains:

enter image description here

and all type links work just fine. Also note how it automatically uses the nicer pipe notation even if we wrote Union[].

One thing to note is that the types set with typing syntax show next to the argument, while those set with :type: show on the description.

We can make everything show on the description by uncommenting on conf.py as mentioned at Python 3: Sphinx doesn’t show type hints correctly

autodoc_typehints = "description"

which gives:

enter image description here

but it would be even better if we could instead do it the other way around and show :type: next to the arguments. Anyways, both are acceptable.

typing.Protocol: enter polymorphism

Union is usually a code smell. For small stuff it is OK. But saner APIs will instead use polymorphism when possible: How to implement virtual methods in Python?

And now typing also offers static polymorphism check with Protocol, e.g.:

from typing import Protocol

class CanFly(Protocol):
    def fly(self) -> str:
        raise NotImplementedError()

class Bird(CanFly):
    def fly(self):
        return 'Bird.fly'

class Bat(CanFly):
    def fly(self):
        return 'Bat.fly'

def send_mail(flyer: CanFly):
    print(flyer.fly())

send_mail(Bird())
send_mail(Bat())

So here send_mail can take any type that implements CanFly, e.g. either Bird() or Bat(), and we don’t need any ugly if type checks.

Leave a Comment