This article and the next are optional reading, for when you publish a library that other people install.
You maintain a small parsing library. Version 1.4 raises ValueError when
the input is bad, and users ask for the line and column of the error. You
can add them in 1.5 without breaking anyone who already catches
ValueError, by raising a subclass:
class ParseError(ValueError):
def __init__(self, message: str, *, line: int, column: int) -> None:
super().__init__(message)
self.line = line
self.column = column
try:
raise ParseError("expected a number", line=2, column=4)
except ValueError as error:
print(f"old handler caught: {error}")
Old code still catches the error with except ValueError. New code can
catch ParseError and read error.line.
Your API is more than its signatures
Callers depend on what your functions do, not only on their names and types. Each change in this table keeps every signature the same, and each one can still break a caller:
| Change | Who it breaks |
|---|---|
| A different exception class | except clauses that name the old class |
| Results sorted instead of in insertion order | Callers that take the first result |
| A new default value for an argument | Calls that leave the argument out |
| An extra key in a returned dict | Strict schemas, == checks and saved output |
Even the subclass above breaks code that checks type(error) is ValueError.
Write down which of these behaviors you promise, and which can change, such
as the wording of error messages. Users rely on behavior you never promised
too, so judge what a change costs them even when your promises allow it.
Say which names are public
List your public names in __all__ in each public module. It decides what
from yourlib import * imports, and type checkers and linters read it as
your list of exports. It does not stop anyone importing other names, so also
document what you support.
mypy's strict mode treats a name that a module imports as private to that
module, which matters most in __init__.py. To export the name, list it in
__all__, or import it as from .errors import ParseError as ParseError.
Ship an empty py.typed file inside your package, so type checkers read
your annotations. The next article checks that it reaches the wheel.
Use version numbers to show what changed
Semantic Versioning gives each part of MAJOR.MINOR.PATCH
a meaning. A patch release fixes bugs. A minor release adds features and
can deprecate old ones. Only a major release removes or changes what you
promised. Before 1.0, anything may change.
Deprecate before you remove
Keep the old name working for at least one minor release, and make it warn.
@deprecated is in warnings from Python 3.13, and in the
typing_extensions package for 3.12:
import sys
import warnings
if sys.version_info >= (3, 13):
from warnings import deprecated
else:
from typing_extensions import deprecated
def encode_text(text: str) -> bytes:
return text.encode("utf-8")
@deprecated("Use encode_text(); encode() will be removed in version 3.0")
def encode(text: str) -> bytes:
return encode_text(text)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
encode("café")
print(caught[0].category.__name__, caught[0].message)
# DeprecationWarning Use encode_text(); encode() will be removed in version 3.0
Each call to encode emits a DeprecationWarning. Python shows it by default
only when the call is in the script you run. pytest shows it in its summary,
so most users see it in their test runs. mypy reports each use too, but only
with --enable-error-code deprecated. Deprecate a Function Without Breaking
Callers, in the error-handling track, covers the decorator in more detail.
Check your understanding You deprecated a function two releases ago, and most users still call it. What should you check before you remove it? Show answer Hide answer
Check that the warning reaches them, for example in their test output. Check that the replacement covers what they use the old function for. And check that your release notes show the change with a before-and-after example. Then announce the release that removes it, early enough for users to move.
Test the package users install
Build the wheel, install it into a new virtual environment, and run a small script that imports your public names from outside your project folder. A clean install proves the packaging, not the behavior, so keep tests for the promises in the table above as well. The next article, Test the Wheel You Ship, Not Your Checkout, walks through the install check.