Once other people call your code, every name in its signature is a promise. This lesson is about breaking those promises slowly enough that nobody gets hurt — and about the parts of a signature you should never promise at all.
Deprecation that both tools can see
Model.fetch was renamed to Model.get. The old name has to keep working for
a while, and its users have to find out. One decorator does both jobs:
from warnings import deprecated
@classmethod
@deprecated("Model.fetch is deprecated; use Model.get instead")
def fetch(cls, connection: Connection, id: int) -> Self:
return cls.get(connection, id)
warnings.deprecated arrived in Python 3.13 (it’s typing_extensions.deprecated
before that), and it is two things at once. At run time it wraps the function so
calling it emits a DeprecationWarning. Statically, a checker reports the call
as an error — verified against the day’s files:
misuse.py:15: error: function models.Model.fetch is deprecated:
Model.fetch is deprecated; use Model.get instead [deprecated]
mypy needs --enable-error-code=deprecated for that; other checkers report it
by default. Note the decorator order: @classmethod on the outside, closest to
def last — the deprecation wraps the function, and classmethod wraps that.
import warnings
from warnings import deprecated
class Report:
@deprecated("Report.render_html is deprecated; use Report.render(format='html')")
def render_html(self) -> str:
return "<p>report</p>"
def render(self, format: str = "text") -> str:
return "<p>report</p>" if format == "html" else "report"
report = Report()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
print("result:", report.render_html())
print("warning:", caught[0].category.__name__)
print("message:", caught[0].message)
print("still the same object:", report.render_html() == report.render(format="html"))DeprecationWarning is hidden by default
Python shows DeprecationWarning only in __main__ and in tests. That’s
deliberate — an end user can’t act on a warning about a library’s own
dependency — but it means your users may never see it. Say it in the changelog
and the release notes too; treat the warning as the last line of defence, not
the announcement.
Test deprecations like any other behaviour:
with self.assertWarns(DeprecationWarning):
post = Post.fetch(self.connection, 1)
That test also pins the fact that the old name still works, which is the part everyone forgets to check.
Parameter names are part of the API
Any parameter that can be passed by keyword is a name you have promised. Two markers decide what you’re committing to:
def execute(self, sql: str, parameters: Sequence[Any] = (), /) -> Cursor: ...
# ^ positional-only
def __init__(self, *, null: bool = False) -> None: ...
# ^ keyword-only
- Everything before
/can never be passed by keyword, so you can rename it freely. Use it for arguments whose meaning is obvious from position. - Everything after
*must be passed by keyword. Use it for options —null=Trueat the call site says what it does, where a bareTruewouldn’t, and you’re then free to reorder or add options without breaking anyone.
def positional_only(sql, parameters=(), /):
return f"{sql} {parameters}"
def keyword_only(*, null=False, max_length=None):
return f"null={null} max_length={max_length}"
print(positional_only("select 1", ()))
print(keyword_only(null=True))
for call, description in [
(lambda: positional_only(sql="select 1"), "positional_only(sql=...)"),
(lambda: keyword_only(True), "keyword_only(True)"),
]:
try:
call()
except TypeError as error:
print(f"{description}: TypeError: {error}")The fields in this library take every option as keyword-only, which is why
TextField(max_length=100, null=True) reads the way it does — and why adding a
third option next month can’t break a caller.
Say what’s public
Three declarations, none of which cost anything:
__all__in a module lists the namesfrom module import *exports, and more importantly tells readers and tools which names are the API. Some checkers treat anything not in__all__as private when checking re-exports.py.typedis an empty file in your package directory. Without it, checkers ignore your annotations entirely in other people’s code — they assume a package without the marker has unchecked types. Shipping a fully typed library withoutpy.typedis the most common way for all this work to have no visible effect.@finalon a class or method says “don’t subclass this / don’t override this”. It’s checked statically, and it’s how you keep freedom to change internals that you’d otherwise have to support forever.
from typing import final
@final
class Cursor: # nobody may subclass this
...
An underscore is still your main tool
_instances, _title, _check — the leading underscore remains the cheapest
and clearest “not yours”. None of the above replaces it; they make it visible to
tools as well as to people.
Retire a parameter
find used to take title. It now takes where, a dict. Keep the old call
working for one release, warn about it, and make the new one the only thing the
signature advertises.
import unittest
import warnings
POSTS = [
{"title": "Hello", "views": 3},
{"title": "Goodbye", "views": 9},
]
def find(where=None, title=None):
"""TODO:
- `where` is a dict of column -> value, and is the supported API
- `title=...` still works, means where={"title": ...}, and warns
(DeprecationWarning), naming what to use instead
- passing both is a TypeError
- `where` should be keyword-only... but think about whether it can be,
given how the old API was called. The tests decide.
"""
return [post for post in POSTS if all(post[k] == v for k, v in (where or {}).items())]
class Find(unittest.TestCase):
def test_the_new_api(self):
self.assertEqual(find({"title": "Hello"})[0]["views"], 3)
def test_the_old_api_still_works(self):
with warnings.catch_warnings():
warnings.simplefilter("ignore")
self.assertEqual(find(title="Hello")[0]["views"], 3)
def test_the_old_api_warns(self):
with self.assertWarns(DeprecationWarning) as caught:
find(title="Hello")
self.assertIn("where", str(caught.warning))
def test_both_at_once_is_an_error(self):
with self.assertRaises(TypeError):
find({"title": "Hello"}, title="Hello")
def test_no_arguments_returns_everything(self):
self.assertEqual(len(find()), 2)
unittest.TextTestRunner(verbosity=2).run(
unittest.defaultTestLoader.loadTestsFromTestCase(Find)
)Hint 1: the idea
Three behaviours, one at a time: the old keyword still works but warns, naming
what to use instead; passing both is an error; and the signature makes where
the obvious thing to pass. Whether where can be keyword-only is decided by
the tests — look at how the first one calls find before you add a *.
Hint 2: a first step
Handle title first: when it’s given, call warnings.warn(...) with a
DeprecationWarning whose message mentions where, and set where from it.
Then add the both-at-once check above that. Pass stacklevel=2 as well: it
makes the warning point at the caller’s line rather than at find itself,
which is the line a user needs to change.
Show one solution
def find(where=None, *, title=None):
if title is not None:
if where is not None:
raise TypeError("pass either where= or title=, not both")
warnings.warn(
"find(title=...) is deprecated; use find(where={'title': ...}) instead",
DeprecationWarning,
stacklevel=2,
)
where = {"title": title}
return [post for post in POSTS if all(post[k] == v for k, v in (where or {}).items())]Four decisions worth stating.
where can’t be keyword-only, which is the trap in the hint: the first test
calls find({"title": "Hello"}) positionally, and that is the API you’re
advertising. title stays keyword-only, so the two can never be confused.
It could be positional-only (where=None, /), and that would buy the freedom
to rename it later — but then the deprecation message can’t tell people to write
where=..., because they can’t. The test asks for a message naming where, so
this version keeps the name promised. That’s the trade: a name you can advise
people to use is a name you now owe them.
stacklevel=2 points the warning at the caller’s line instead of at this
warnings.warn call. Without it, every user’s warning says find.py:14, which
tells them nothing about which of their calls to fix. It is the single most
commonly forgotten argument in the warnings module.
Raising TypeError for both-at-once rather than quietly preferring one: a
caller who passes both has a bug, and guessing which they meant makes it their
problem later instead of now.
When the deprecation period ends, deleting the five lines leaves exactly the function you wanted.
What you learned
warnings.deprecatedwarns at run time and errors in a checker, from one decorator;DeprecationWarningis hidden outside__main__and tests.stacklevel=2is what makes a warning point at the caller./and*decide which parameter names you’re promising; options should be keyword-only.__all__,py.typedand@finalpublish the boundary — and withoutpy.typed, none of your annotations reach your users at all.