You inherit a module written a few years ago:
import os
from typing import Dict, List, Optional
def load_tags(name: str, root: Optional[str] = None) -> Dict[str, List[str]]:
path = os.path.join(root or "config", name + ".txt")
print("loading %s" % path)
return {}
Every line still runs on Python 3.15. Newer Python has a shorter spelling for most of them, and this track taught several. This article puts the common ones in one table. Then it shows how to update old code without changing what it does.
Old spelling, new spelling
| Old | New | Since | Check before you change it |
|---|---|---|---|
"%s" % x, "{}".format(x) |
f"{x}" |
3.6 | Leave logging calls and SQL queries alone. They need the template and the values kept apart. |
A long if and elif chain on one value |
match and case |
3.10 | Keep if when the conditions test unrelated things. |
Optional[str], Union[int, str] |
str | None, int | str |
3.10 | Nothing. mypy reads both the same way. |
List[int], Dict[str, int] |
list[int], dict[str, int] |
3.9 | Nothing. mypy reads both the same way. |
T = TypeVar("T") above a function |
def first[T](items: list[T]) -> T: |
3.12 | Every Python version you support must be 3.12 or newer. |
os.path.join(root, name) |
Path(root) / name |
3.4 | Path does not check that a user's path stays inside a folder. You still have to. |
datetime.utcnow() |
datetime.now(UTC) |
3.11 | The old value has no time zone and the new one does. Using < or - between the two kinds raises TypeError. |
Hand-written __init__, __eq__ and __repr__ |
@dataclass |
3.7 | Keep any custom equality or hashing the old class had. |
String constants such as "active" |
enum.StrEnum |
3.11 | Text from outside still has to be parsed, as in Status(value). |
except (A, B): |
except A, B: |
3.14 | Every Python version you support must be 3.14 or newer. |
The module above becomes:
from pathlib import Path
def load_tags(name: str, root: str | None = None) -> dict[str, list[str]]:
path = Path(root or "config") / f"{name}.txt"
print(f"loading {path}")
return {}
A rewrite that looks the same can change the result
For a plain dict, {**a, **b} and a | b give the same result. Both
merge the two, and the right side wins on a shared key. Counter, a dict
subclass, gives | its own meaning:
from collections import Counter
left = Counter(a=3)
right = Counter(a=1, b=2)
assert dict(left | right) == {"a": 3, "b": 2} # | keeps the larger count
assert {**left, **right} == {"a": 1, "b": 2} # unpacking: right side wins
added = left.copy()
added.update(right)
assert dict(added) == {"a": 4, "b": 2} # update adds the counts
So before you rewrite a merge, find out the real types of both sides.
Unpacking with ** always builds a plain dict, even from two Counters.
Change old code in small steps
- Change one kind of thing per commit, so a reviewer can check each one.
- Run the tests before and after. A change of spelling, such as
List[int]tolist[int], needs no new test. A change of behavior, such asutcnow()or aCountermerge, does. - Let a tool do the mechanical part. Ruff's
UPrules rewrite old annotations and old string formatting for you, based on the oldest Python version you set as its target.
Check your understanding
A pull request replaces every datetime.utcnow() with datetime.now(UTC), and the tests pass. Is it safe to merge?
Show answer
Hide answer
Only if the tests reach the places where those values meet other
datetimes. The new value carries a time zone, and the old one did not.
Ordering the two kinds with < or subtracting one from the other raises
TypeError, and == quietly returns False. A value loaded from a
database column with no time zone is the usual case. Find the code that
compares, subtracts or stores those values, and test it.
Going further
The Python documentation's What's New pages list every change by version. The next article, "Write Code That Runs on Several Python Versions", covers what to do when your code has to run on an older Python too.