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?
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
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?
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