Les nouveautés de Python 3.15

Découvrez toutes les nouveautés de la version 3.15 de Python.

Publié le par Gabriel Trouvé (mis à jour le )

43 minutes

La sortie de Python 3.15 est prévue pour le 1er octobre 2026, mais pas besoin d'attendre. La deuxième release candidate est disponible depuis quelques jours (1er septembre) et les fonctionnalités sont gelées, il est alors temps de s'intéresser aux nouvelles fonctionnalités qui arrivent avec cette dernière version.

Les imports paresseux

Certainement la plus grosse nouveauté de cette version : le lazy import.

Quand vous placez des imports en haut du fichier, chaque import est exécuté par Python dès qu'il le lit. Les modules sont donc chargés au démarrage, qu'ils soient utilisés ou non dans le script.

Avec un programme en ligne de commande où vous lancez plusieurs commandes, ce chargement peut commencer à se faire sentir. Je me suis amusé à lancer un interpréteur dans différentes conditions :

  • à vide démarre en 18 ms

  • avec import requests démarre en 73 ms

  • avec import pandas démarre en 211 ms

Voici le programme en question ci-dessous :

import subprocess
import sys
import time


def mesurer(code: str, repetitions: int = 5) -> float:
    """Retourne le meilleur temps (en ms) sur plusieurs lancements."""
    meilleur = float("inf")
    for _ in range(repetitions):
        debut = time.perf_counter()
        subprocess.run([sys.executable, "-c", code], check=True)
        meilleur = min(meilleur, time.perf_counter() - debut)
    return meilleur * 1000


cas = {
    "interpréteur vide": "pass",
    "import requests": "import requests",
    "import pandas": "import pandas",
}

for libelle, code in cas.items():
    print(f"{libelle:<24}{mesurer(code):>6.0f} ms")
PYTHON

La solution « classique » serait de déplacer l'import dans la fonction qui l'utilise : de cette manière, le module n'est chargé qu'au premier appel de la fonction. Personnellement, je n'aime pas faire ça, on disperse les imports un peu partout. La PEP 810 explique qu'environ 17 % des imports de la librairie standard sont placés dans des fonctions pour retarder leur exécution.

Python 3.15 permet d'utiliser le mot-clé lazy :

import sys

lazy import smtplib
lazy import csv

print("smtplib chargé ?", "smtplib" in sys.modules)
print("csv chargé ?", "csv" in sys.modules)


def exporter(lignes):
    with open("export.csv", "w", newline="", encoding="utf-8") as f:
        csv.writer(f).writerows(lignes)


exporter([["Patrick", "Lyon"], ["Sébastien", "Paris"]])

print("smtplib chargé ?", "smtplib" in sys.modules)
print("csv chargé ?", "csv" in sys.modules)

# Résultat :
# smtplib chargé ? False
# csv chargé ? False
# smtplib chargé ? False
# csv chargé ? True
PYTHON

Le lazy import ne charge rien, il utilise un proxy en attendant que le vrai chargement se fasse au premier accès. csv est bien utilisé dans la fonction, alors que smtplib ne l'est jamais.

On peut d'ailleurs afficher le proxy en passant par globals pour ne pas déclencher le chargement (méthode utilisée dans la PEP) :

lazy import smtplib

print(globals()["smtplib"])

smtplib.SMTP

print(globals()["smtplib"])

# <lazy_import 'smtplib'>
# <module 'smtplib' from '/Users/gabrieltrouve/.local/share/uv/python/cpython-3.15.0rc2-macos-aarch64-none/lib/python3.15/smtplib.py'>
PYTHON

Attention

Si le module n'existe pas, l'erreur n'est levée qu'à la première utilisation, avec des tracebacks qui pointent les deux endroits :

lazy import module_inexistant

print("Le script démarre sans problème")

module_inexistant.faire_un_truc()

"""
Le script démarre sans problème
Traceback (most recent call last):
  File "/Users/gabrieltrouve/Pro/py315/main.py", line 1, in <module>
    lazy import module_inexistant
ImportError: lazy import of 'module_inexistant' raised an exception during resolution

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/gabrieltrouve/Pro/py315/main.py", line 5, in <module>
    module_inexistant.faire_un_truc()
    ^^^^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'module_inexistant'
"""
PYTHON

À noter

Les imports circulaires ne disparaissent pas totalement. Si l'un des deux modules utilise l'autre au niveau du module, l'erreur revient. La PEP précise elle-même qu'il faut éviter les imports circulaires. De plus, lazy n'est autorisé qu'au niveau du module.

Le dictionnaire immuable frozendict

Vous connaissez le frozenset ? Sinon, n'hésitez pas à aller lire notre glossaire sur les ensembles. Python 3.15 ajoute le type natif frozendict :

DATA = frozendict({"nom": "patrick", "age": 30})

print(DATA["nom"])  # Affiche : patrick
print(DATA["age"])  # Affiche : 30

DATA["nom"] = "sebastien"
# TypeError: 'frozendict' object does not support item assignment
PYTHON

Comme il est immuable, il est hashable tant que ses clés et ses valeurs le sont. Il est alors possible d'utiliser un frozendict en clé de dictionnaire ou dans un ensemble :

tarifs = {
    frozendict(zone="France", poids="lourd"): 12.5,
    frozendict(zone="Europe", poids="lourd"): 19.9,
}

print(tarifs[frozendict(zone="France", poids="lourd")])  # Affiche : 12.5
PYTHON

Attention

frozendict n'hérite pas de dict, il s'agit d'un type natif, tout comme dict.

DATA = frozendict({"nom": "patrick", "age": 30})


print(isinstance(DATA, dict)) # False
PYTHON

Pour avoir une version modifiable d'un frozendict, vous pouvez toujours le convertir en dictionnaire avec dict(DATA) qui en fera une copie, et l'opérateur | renverra un nouveau frozendict.

DATA = frozendict({"nom": "patrick", "age": 30})


NEW_DATA = DATA | {"ville": "Ons en Bray"}

print(type(NEW_DATA))  # <class 'frozendict'>
PYTHON

Savoir si un argument a été fourni avec sentinel

Imaginons que Patrick mette à jour les numéros de téléphone de ses clients :

  • l'effacer

  • ne pas y toucher

  • le modifier

Nous avons 3 cas, mais avec None par défaut nous n'en distinguons que deux :

def modifier_client(client, telephone=None):
    if telephone is None:
        return  # Ne pas toucher au numéro ou l'effacer ?
    client["telephone"] = telephone
PYTHON

modifier_client(sebastien) et modifier_client(sebastien, telephone=None) donnent None à la fonction. Patrick n'a-t-il rien précisé ou a-t-il effacé le numéro ?

Aujourd'hui, la documentation officielle utilise un objet unique que personne ne passe par accident comme valeur par défaut :

_NON_FOURNI = object()


def modifier_client(client, telephone=_NON_FOURNI):
    if telephone is _NON_FOURNI:
        return  # L'argument n'a pas été fourni, on ne touche à rien
    client["telephone"] = telephone  # None, pour effacer


sebastien = {"nom": "sebastien", "telephone": "04 72 00 00 00"}

modifier_client(sebastien)
print(sebastien["telephone"])  # Résultat : 04 72 00 00 00

modifier_client(sebastien, telephone=None)
print(sebastien["telephone"])  # Résultat : None
PYTHON

Cependant, la valeur par défaut de l'objet _NON_FOURNI affiche l'adresse mémoire <object object at 0x...> et ne se type pas proprement, car il est de type object, la classe mère de tout ce qui existe en Python.

Python 3.15 intervient en ajoutant sentinel, en reprenant la même idée, mais cette fois l'objet a un nom :

NON_FOURNI = sentinel("NON_FOURNI")


def modifier_client(client: dict, telephone: str | None | NON_FOURNI = NON_FOURNI) -> None:
    if telephone is NON_FOURNI:
        return
    client["telephone"] = telephone


print(NON_FOURNI)  # Résultat : NON_FOURNI
PYTHON

Le nom s'affiche tel quel, et la sentinelle s'utilise directement dans l'annotation.

L'unpacking dans les compréhensions

Jusqu'à aujourd'hui, on ne pouvait pas utiliser l'opérateur * pour déballer une liste dans une compréhension. Il fallait utiliser le double for ou itertools :

import itertools

equipes = [["Patrick", "Ely"], ["Sébastien"], ["Mélanie", "Ana", "Geneviève"]]

print([nom for equipe in equipes for nom in equipe])
print(list(itertools.chain.from_iterable(equipes)))
PYTHON
Un instant

Créez un compte pour exécuter ce code

Inscrivez-vous gratuitement pour modifier et exécuter du code Python directement dans votre navigateur.

Avec Python 3.15 :

equipes = [["Patrick", "Ely"], ["Sébastien"], ["Mélanie", "Ana", "Geneviève"]]

print([*equipe for equipe in equipes])
# ['Patrick', 'Ely', 'Sébastien', 'Mélanie', 'Ana', 'Geneviève']
PYTHON

Ce que je trouve bien plus lisible personnellement. Et ça fonctionne aussi avec les dictionnaires :

defauts = {"nom": "patrick", "age": "60"}
utilisateur = {"ville": "Ons en Bray"}

print({**d for d in [defauts, utilisateur]})
# Affiche : {'nom': 'patrick', 'age': '60', 'ville': 'Ons en Bray'}
PYTHON

UTF-8 par défaut

Avec la PEP 686, l'encodage par défaut devient l'UTF-8. Car sans argument, Python utilisait l'encodage système jusqu'ici.

À noter

Si UTF-8 est utilisé sur Mac et Linux, on retrouve souvent le cp1252 sur Windows.

Attention

Ce changement ne concerne que les appels sans argument encoding : continuez à passer cet argument tant que votre code tourne sur des versions antérieures.

Le profiler Tachyon

Ajouté par la PEP 799, Tachyon est un profiler statistique qui permet d'analyser ce qui prend le plus de temps dans un script.

Prenons un exemple :

import time


def charger_donnees():
    time.sleep(0.4)  # Simule une lecture en base
    return list(range(200_000))


def calculer_tva(montants):
    return [m * 1.2 for m in montants]


def generer_rapport():
    montants = charger_donnees()
    for _ in range(20):
        calculer_tva(montants)


generer_rapport()
PYTHON

Lançons-le avec le profiler : python -m profiling.sampling run main.py. Comme Tachyon lit la mémoire du processus, il faut des droits particuliers. Sur mon Mac, je suis passé par sudo "$(which python)" -m profiling.sampling run main.py.

Résultat du profiler

Résultat du profiler

Il est même possible de générer un rapport au format HTML avec sudo "$(which python)" -m profiling.sampling run --flamegraph -o rapport.html main.py.

Flame graph généré par Tachyon

Flame graph généré par Tachyon

Les messages des AttributeError

Python 3.14 nous simplifiait la vie avec ses messages d'erreur. Python 3.15 simplifie la vie aux personnes venant d'un autre langage en s'attaquant aux AttributeError.

L'interpréteur possède maintenant une table des méthodes les plus courantes des autres langages pour proposer une équivalence en Python :

[1, 2, 3].push(4)
# AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?

"patrick".toUpperCase()
# AttributeError: 'str' object has no attribute 'toUpperCase'. Did you mean '.upper'?

{}.put("cle", 1)
# AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v.
PYTHON

Même principe si vous confondez un tuple et une liste :

(1, 2, 3).append(4)
# AttributeError: 'tuple' object has no attribute 'append'. Did you mean to use a 'list' object?
PYTHON

Pour vos propres classes, Python regarde si l'attribut demandé existe sur l'un des attributs de votre objet :

from dataclasses import dataclass


@dataclass
class Facture:
    montant: float

    @property
    def ttc(self) -> float:
        return self.montant * 1.2


class Dossier:
    def __init__(self, facture: Facture) -> None:
        self.facture = facture


dossier = Dossier(Facture(montant=100.0))
print(dossier.ttc)
# AttributeError: 'Dossier' object has no attribute 'ttc'. Did you mean '.facture.ttc' instead of '.ttc'?
PYTHON

Ça ne s'arrête pas là, je ne cite pas toutes les modifications et tous les ajouts (vous comprendrez 😅). Mais n'hésitez pas à lire la release note de cette nouvelle version pour découvrir toutes les nouveautés.

Bravo, tu es prêt à passer à la suite

Rechercher sur le site

Inscris-toi à Docstring

Pour commencer ton apprentissage.

Tu as déjà un compte ? Connecte-toi.