Mutually exclusive arguments

Question:

Sometimes I have functions that accept multiple ways to pass data. This is usually with exceptions, like this:

class InvalidFileError(Exception):
    
    def __init__(self, filecontent: str = None, path: pathlib.Path = None):
        """
        Path and content are mutually exclusive, though this is not enforced.
        If both are provided, will default to path
        """
        self.message = None
        if path:
            self.message = f"File at path {path} is invalid"
        elif content:
            self.message = f"Invalid data:n{content}"
        if self.message:
            super().__init__(self.message)

In the above code, filecontent and path are mutually exclusive.

How should I make these arguments mutually exclusive? Should I just leave a warning in the docstring and hope it is respected? Should I throw an exception if both are provided? Should I throw a warning? Should I have it default to one of the arguments?

All of these ways work. However, none of them feel right. What’s the best (most pythonic) way to do this?

Asked By: Zach Joseph

||

Answers:

The standard way (which can be enforced via static type checkers like mypy) is to use typing.overload to explicitly define the acceptable combinations of arguments:

import pathlib
from typing import overload

class InvalidFileError(Exception):

    @overload
    def __init__(self, *, content: str = None): ...

    @overload
    def __init__(self, *, path: pathlib.Path = None): ...
    
    def __init__(self, *, content: str = None, path: pathlib.Path = None):
        """
        Path and content are mutually exclusive.
        """
        if path and content:
            raise TypeError("Invalid arg combination.  Did you forget to run mypy?")

        self.message: str | None = None
        if path:
            self.message = f"File at path {path} is invalid"
        elif content:
            self.message = f"Invalid data:n{content}"

        if self.message:
            super().__init__(self.message)

Running the four possible ways to call this through mypy we get:

InvalidFileError()  # ok
InvalidFileError(content="foo")  # ok
InvalidFileError(path=pathlib.Path("bar"))  # ok
InvalidFileError(content="foo", path=pathlib.Path("bar"))  # error:
# test.py:28: error: No overload variant of "InvalidFileError" matches argument types "str", "Path"
# test.py:28: note: Possible overload variants:
# test.py:28: note:     def __init__(self, *, content: Optional[str] = ...) -> InvalidFileError
# test.py:28: note:     def __init__(self, *, path: Optional[Path] = ...) -> InvalidFileError

A simpler option would be to forgo the complication of having them be separate keyword arguments, and just take a single positional argument with a union type:

class InvalidFileError(Exception):
    
    def __init__(self, path_or_content: pathlib.Path | str | None = None):
        if path_or_content is None:
            self.message: str | None = None
        elif isinstance(path_or_content, pathlib.Path):
            self.message = f"File at path {path_or_content} is invalid"
        elif isinstance(path_or_content, str):
            self.message = f"Invalid data:n{path_or_content}"
        else:
            raise TypeError(f"{path_or_content!r} is an invalid type!")

        if self.message:
            super().__init__(self.message)


InvalidFileError()  # ok
InvalidFileError("foo")  # ok
InvalidFileError(pathlib.Path("bar"))  # ok
Answered By: Samwise
Categories: questions Tags:
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.