Migrar de Python 3.9 a 3.12 no es lo mismo que actualizar una librería: estás cambiando el intérprete que ejecuta todo tu código. Si lo haces con un big-bang —cambias la versión, cruzas los dedos y rezas— tarde o temprano encuentras una rotura en producción que tardaste tres días en entender. La alternativa es tratar la migración como un proceso con fases, señales tempranas y validación automatizada.
El modelo mental correcto es este: Python no rompe código silenciosamente. Primero avisa con deprecation warnings, luego elimina la funcionalidad en una versión posterior. Si tienes los warnings activados en tus tests, ya estás recibiendo la hoja de ruta de lo que va a romperse. Lo que necesitas además es modernizar la sintaxis de forma automática, ejecutar tu suite contra múltiples versiones simultáneamente y declarar explícitamente qué versiones soportas.
Las piezas del proceso
pyupgrade es un reformateador de código que toma tu base de código y aplica automáticamente construcciones modernas de Python. Con --py312-plus convierte literales de % y .format() a f-strings, cambia typing.List a list, typing.Optional[X] a X | None, y otras modernizaciones del modelo de tipos. No es una herramienta de análisis —transforma ficheros en disco. Úsala una vez por versión objetivo, revisa el diff con tu herramienta habitual y commitea.
tox orquesta la ejecución de tu suite de tests contra múltiples intérpretes. Cada entorno en tox.ini es un virtualenv aislado con una versión de Python distinta. Si tienes 3.10, 3.11 y 3.12 instalados en tu máquina (o en CI), tox los ejecuta en paralelo y te da una vista consolidada de qué falla en qué versión.
El campo requires-python en pyproject.toml es la declaración contractual: le dice a pip qué versiones acepta tu paquete antes de intentar instalarlo. Si lo omites, pip asume compatibilidad universal y tus usuarios descubren los problemas en tiempo de instalación o ejecución, no antes.
Los cambios que más rompen código entre versiones recientes son bien concretos: en 3.10 llegó match/case (sintaxis nueva que no rompe código existente, pero sí rompe si usas match como nombre de variable) y los union types con | en anotaciones. En 3.11 apareció ExceptionGroup y except* para manejar excepciones concurrentes —si capturas BaseException de forma genérica, puede que necesites revisar esa lógica. En 3.12, PEP 695 introduce una sintaxis nueva para genéricos: type Vector[T] = list[T] y def fn[T](x: T) -> T. En 3.13 llega el free-threading experimental (sin GIL con --disable-gil), que no rompe código single-threaded pero sí puede exponer condiciones de carrera en código que asumía la protección del GIL.
Si te rompe algo al pasar versiones, el lugar donde mirar primero es el módulo que quitaron o movieron —consulta el What’s New de la versión destino, sección Removed.
El proyecto de ejemplo
# pyproject.toml (fragmento relevante) # [build-system], [project], etc. omitidos para brevedad # ── tox.ini ────────────────────────────────────────────────────────────────── # Ejecutar: tox -p auto (-p auto = paralelo)
# pyproject.toml [project] name = "myservice" version = "0.5.0" requires-python = ">=3.11" # contrato explícito con pip [project.optional-dependencies] dev = ["tox", "pyupgrade", "pytest"]
# tox.ini
[tox]
envlist = py311, py312, py313
isolated_build = true
[testenv]
deps = pytest
commands = pytest {posargs:tests/}
[testenv:py313]
# 3.13 free-threading está disponible como intérprete separado (python3.13t)
# Si quieres probarlo explícitamente:
basepython = python3.13
setenv =
PYTHONWARNINGS = error # deprecation warnings fallan el build
# src/myservice/compat.py
"""
Capa de compatibilidad que encapsula las diferencias entre versiones.
Importa desde aquí en lugar de hacerlo directamente desde typing o builtins.
"""
from __future__ import annotations # retrasa la evaluación de anotaciones
import sys
import warnings
# ── Detección de versión en tiempo de importación ────────────────────────────
PY311 = sys.version_info >= (3, 11)
PY312 = sys.version_info >= (3, 12)
def warn_if_old_python(minimum: tuple[int, int] = (3, 11)) -> None:
"""Emite un DeprecationWarning si la versión activa es menor que minimum."""
if sys.version_info < minimum:
warnings.warn(
f"Python {'.'.join(map(str, sys.version_info[:2]))} alcanzará EOL pronto. "
f"Actualiza a {'.'.join(map(str, minimum))} o superior.",
DeprecationWarning,
stacklevel=2, # atribuye el warning al llamador, no a esta función
)
# src/myservice/models.py
from __future__ import annotations
from dataclasses import dataclass
# Antes de pyupgrade --py310-plus:
# from typing import Optional, Union
# def find(name: str) -> Optional["User"]: ...
# Después — sintaxis moderna de union types (3.10+)
@dataclass
class User:
name: str
email: str | None = None # Union[str, None] → str | None
def find_user(users: list[User], name: str) -> User | None:
# match/case es O(1) en legibilidad aquí; un if/elif sería igual de válido
match [u for u in users if u.name == name]:
case []:
return None
case [user]:
return user
case [first, *_]:
# más de un resultado: advertimos y devolvemos el primero
import warnings
warnings.warn(f"Múltiples usuarios con nombre {name!r}", stacklevel=2)
return first
# src/myservice/errors.py
"""
Manejo de ExceptionGroup (3.11+) para tareas concurrentes.
Si ejecutas en 3.10, la función alternativa hace lo mismo de forma manual.
"""
from __future__ import annotations
import sys
def handle_task_errors(errors: list[Exception]) -> None:
if not errors:
return
if sys.version_info >= (3, 11):
# ExceptionGroup agrupa múltiples excepciones; except* filtra por tipo
raise ExceptionGroup("errores de tarea", errors)
else:
# fallback para 3.10: relanza la primera, encadena las demás
raise errors[0] from ExceptionGroup if len(errors) == 1 else errors[0]
# tests/test_models.py
import warnings
import pytest
from myservice.models import User, find_user
from myservice.compat import warn_if_old_python
def test_find_single_user():
users = [User("alice", "alice@example.com"), User("bob")]
result = find_user(users, "alice")
assert result is not None
assert result.name == "alice"
def test_find_missing_user():
assert find_user([], "ghost") is None
def test_find_duplicate_warns():
users = [User("alice"), User("alice", "other@example.com")]
with pytest.warns(UserWarning, match="Múltiples"):
result = find_user(users, "alice")
assert result.name == "alice"
def test_deprecation_warning_on_old_python(monkeypatch):
# Simulamos que estamos en 3.10 para verificar que el warning se emite
import sys
monkeypatch.setattr(sys, "version_info", (3, 10, 0, "final", 0))
with pytest.warns(DeprecationWarning, match="EOL"):
warn_if_old_python((3, 11))
Lo que hace cada decisión
from __future__ import annotations al principio de cada módulo desacopla la evaluación de las anotaciones del runtime. Eso te permite escribir str | None incluso en Python 3.9 sin que explote en tiempo de importación —las anotaciones se evalúan como strings en lugar de objetos. Es la pieza que hace que la modernización de type hints sea retrocompatible mientras terminas de migrar.
El módulo compat.py es el único lugar donde vive sys.version_info. Centralizar esa lógica tiene una consecuencia práctica importante: cuando drops soporte para 3.11, eliminas la rama de ese único fichero, no vas cazando if sys.version_info dispersos por el código.
En tox.ini, la línea PYTHONWARNINGS = error es la más valiosa del fichero. Convierte los deprecation warnings en excepciones que fallan los tests. Así, cuando Python 3.12 depreca algo que usas, te enteras en CI antes de que 3.13 lo elimine directamente. Es el detector de humo de la migración.
El match/case en find_user ilustra algo no obvio: match no es solo un switch de otros lenguajes. El structural pattern matching descompone estructuras de datos. El patrón case [user] solo hace match si la lista tiene exactamente un elemento, y además vincula ese elemento a user. El patrón case [first, *_] captura el resto con un nombre desechable. Esto es parte del modelo de datos de Python, no azúcar sintáctico superficial.
El handle_task_errors muestra la estrategia de fork por versión cuando el comportamiento cambia fundamentalmente. ExceptionGroup no tiene backport oficial porque es un cambio en la jerarquía de excepciones —BaseExceptionGroup hereda de BaseException, no de Exception. Si capturas except Exception y en 3.11 lanzas un ExceptionGroup, no lo capturas. Ese es el tipo de cambio que rompe sin avisar si no tienes tests sobre el camino de error.
Errores que debes conocer
Error: usar match como nombre de variable en código que debe correr en 3.10+. A partir de 3.10, match es una soft keyword —solo es palabra clave en el contexto de la sentencia match— pero pyupgrade y algunos linters lo marcan como problemático porque genera confusión.
# ❌ Wrong import re match = re.search(r"\d+", text) # funciona pero choca con la nueva sintaxis print(match.group()) # ✅ Right import re result = re.search(r"\d+", text) print(result.group())
Renombrar elimina la ambigüedad visual y evita que futuras versiones del parser tengan comportamientos inesperados en ese contexto.
Error: confiar en PYTHONWARNINGS=default (el valor por defecto) durante los tests. Por defecto Python suprime los warnings duplicados, así que si un módulo emite el mismo DeprecationWarning en dos tests distintos, solo ves uno —o ninguno si pytest también los filtra.
# ❌ Wrong (tox.ini) [testenv] commands = pytest tests/ # ✅ Right [testenv] commands = pytest -W error::DeprecationWarning tests/
El flag -W error::DeprecationWarning hace que cualquier deprecation warning falle el test, independientemente de la configuración del entorno. Puedes añadirlo a pytest.ini como filterwarnings = error::DeprecationWarning para que aplique siempre.
Error: olvidar actualizar requires-python después de usar sintaxis de una versión nueva. Si escribes type Vector[T] = list[T] (PEP 695, 3.12+) pero requires-python = ">=3.10", pip permitirá la instalación en 3.10 y el usuario verá un SyntaxError críptico.
# ❌ Wrong [project] requires-python = ">=3.10" # ... pero el código usa PEP 695 # ✅ Right [project] requires-python = ">=3.12"
pip valida requires-python antes de descargar el paquete, así que el usuario recibe un mensaje claro de incompatibilidad en lugar de un error de sintaxis en tiempo de importación.