Python 3.9 has reached its end of life 26 days ago. However, this time around, various tools and libraries have already started removing its support. If you have the option to upgrade to the latest Python version, take it. But especially library maintainers don’t have that luxury and still have to keep 3.10 support around. It’s been a while since all the „What’s new in Python 3.10” articles came out. So let’s recap what you can refactor.
Before we go into the details of what’s changing, I would recommend trying the pyupgrade (UP) rules of ruff first to see what Python 3.10+ changes can be done for you automatically. For cases where ruff is not sufficient, you can use AI assistance with a master prompt at the end of this article.
Type Unions, Aliases, and Guards
Python 3.10 introduces a new union type syntax that’s more readable and concise. You can now refactor your type hints to use the cleaner | operator:
from typing import Union, Optional, List, Dict
def process_data(items: List[Union[str, int]]) -> Optional[Dict[str, Union[str, int]]]:
if not items:
return None
return {"count": len(items), "first": items[0]}def process_data(items: list[str | int]) -> dict[str, str | int] | None:
if not items:
return None
return {"count": len(items), "first": items[0]}You can also use the new TypeAlias for better type documentation:
UserId = int
def get_user(user_id: UserId) -> None:
...from typing import TypeAlias
UserId: TypeAlias = int
def get_user(user_id: UserId) -> None:
...ParamSpec and Concatenate
Another new typing feature of Python 3.10 is a parameter specification variable ParamSpec. It can be used in annotations of Callable instead of .... Existing TypeVar cannot be used here because it represents a single variable, while the new ParamSpec represents multiple variables. Additionally, you can use Concatenate to add parameters to this set.
# Before ParamSpec, you could only use `...`, which is not type-checkable
from collections.abc import Callable
from threading import Lock
from typing import TypeVar
R = TypeVar('R')
def with_lock(f: Callable[..., R]) -> Callable[..., R]:
...# With ParamSpec, you can annotate the parameters as well
# The return value drops the `Lock` parameter and keeps the rest.
from collections.abc import Callable
from threading import Lock
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec('P')
R = TypeVar('R')
def with_lock(f: Callable[Concatenate[Lock, P], R]) -> Callable[P, R]:
...Structural Pattern Matching (PEP 634)
Python 3.10 introduces structural pattern matching, which allows you to refactor complex if-elif chains into more readable match statements:
def handle_response(response):
if isinstance(response, dict):
if response.get("status") == 200:
data = response.get("data")
return f"Success: {data}"
elif response.get("status") == 404:
return "Not found"
elif response.get("status") == 500:
error = response.get("error")
return f"Server error: {error}"
else:
return "Unknown response"
else:
return "Invalid response"def handle_response(response):
match response:
case {"status": 200, "data": data}:
return f"Success: {data}"
case {"status": 404}:
return "Not found"
case {"status": 500, "error": error}:
return f"Server error: {error}"
case _:
return "Unknown response"You can also refactor class-based conditionals:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def describe_point(point):
if point.x == 0 and point.y == 0:
return "Origin"
elif point.x == 0:
return f"On Y-axis at {point.y}"
elif point.y == 0:
return f"On X-axis at {point.x}"
else:
return f"Point at ({point.x}, {point.y})"class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def describe_point(point):
match point:
case Point(0, 0):
return "Origin"
case Point(0, y):
return f"On Y-axis at {y}"
case Point(x, 0):
return f"On X-axis at {x}"
case Point(x, y):
return f"Point at ({x}, {y})"Stricter Zipping of Sequences
Python 3.10 / PEP-618 adds a strict flag to zip(). When enabled, zip will raise a ValueError when the zipped sequences are not of equal length. This can help catch bugs. If not supplied, the behaviour is remains the same – to ignore the extra elements.
# Old way - this silently truncates, which might be a bug
print(list(zip(['a', 'b', 'c'], [1, 2])))
>>> [('a', 1), ('b', 2)]# Same as in 3.9 but intentionally truncating
print(list(zip(['a', 'b', 'c'], [1, 2], strict=False)))
>>> [('a', 1), ('b', 2)]# Raises ValueError, non-equal length explicitly forbidden print(list(zip(['a', 'b', 'c'], [1, 2], strict=True))) >>> ValueError
Context Manager Syntax
You can refactor nested context managers to use the cleaner syntax:
def process_files():
with open('file1.txt') as f1:
with open('file2.txt') as f2:
with open('output.txt', 'w') as out:
# process files
passdef process_files():
with (
open('file1.txt') as f1,
open('file2.txt') as f2,
open('output.txt', 'w') as out,
):
# process files
passAI Prompt
You can try passing the following AI prompt to an AI agent of your choice for automatic refactoring that ruff may have missed. Some refactoring opportunities can be identified even in code bases that have been at Python 3.10+ for a long time.
You are a Python expert helping to refactor code to use Python 3.10+ features. Follow these specific refactoring patterns.
# Process
1. Check out the git trunk branch (master/main).
2. Pull the latest changes.
3. Switch to a new git branch for this refactoring.
4. Create a todo list with one item for each pattern, then tackle them one by one.
5. Separate each pattern changes to a new commit. Assume you are in the repository root and run just `git -a -m "<MESSAGE>"`.
## For each change
- Maintain the same functionality
- Ensure backward compatibility if needed
# Refactoring patterns
## Type Hints
Replace `typing.Union[X, Y]` with `X | Y`, `typing.Optional[X]` with `X | None`, and `typing.List/Dict/Tuple` with `list/dict/tuple` generics.
```
# OLD
from typing import Union, Optional, List, Dict, Tuple
def func(x: Union[int, str]) -> Optional[List[Dict[str, Union[str, int]]]]:
# NEW
def func(x: int | str) -> list[dict[str, str | int]] | None:
```
Also works for class and instance checks.
```
isinstance("mypy", str | int)
issubclass(str, int | float | bytes)
```
## ParamSpec and Concatenate
Use `typing.ParamSpec` to annotate `...` previously used inside `Callable`. Use `typing.Concatenate` if any parameters are dropped or added in the return value.
```
# OLD
from collections.abc import Callable
from typing import TypeVar
R = TypeVar('R')
def func(f: Callable[..., R]) -> Callable[..., R]:
...
# NEW
from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar
O = ParamSpec('O')
P = ParamSpec('P')
R = TypeVar('R')
def func(f: Callable[Concatenate[O, P], R]) -> Callable[P, R]: # Result drops parameter `O`
...
def func(f: Callable[P, R]) -> Callable[Concatenate[O, P], R]: # Result adds parameter `O`
...
```
## Structural Pattern Matching
Convert complex if-elif chains to match statements where appropriate, especially for data validation and handling different response types.
```
# OLD
if isinstance(data, dict):
if data.get("status") == 200:
return f"Success: {data.get('data')}"
elif data.get("status") == 404:
return "Not found"
else:
return "Unknown"
# NEW
match data:
case {"status": 200, "data": data}:
return f"Success: {data}"
case {"status": 404}:
return "Not found"
case _:
return "Unknown"
```
## Dictionary Operations
Replace `{**d1, **d2}` with `d1 | d2` and `d1.update(d2)` with `d1 |= d2`.
```
# OLD → NEW
result = {**dict1, **dict2}
# NEW
result = dict1 | dict2
```
```
# OLD
dict1.update(dict2)
# NEW
dict1 |= dict2
```
## Zip Strictness
Add a `strict` flag to `zip()` calls. Ask user if they prefer `strict=True` to ensure equal-length sequences, or `strict=False` for the old, truncating and backward compatible behavior.
```
# OLD
list(zip([1, 2, 3], ['a', 'b']))
# NEW
list(zip([1, 2, 3], ['a', 'b'], strict=False)) # for truncation and backward compability
list(zip([1, 2, 3], ['a', 'b'], strict=True)) # for error on mismatch
```
## Context Managers
Refactor nested with statements to use the cleaner multi-context manager syntax.
```
# OLD
with open('file1.txt') as f1:
with open('file2.txt') as f2:
with open('output.txt', 'w') as out:
# process
# NEW
with (
open('file1.txt') as f1,
open('file2.txt') as f2,
open('output.txt', 'w') as out,
):
# process
```
Leave a Reply