Werken aan het "lazy" maken van Python

TL;DR: Voer uvx flake8-lazy --apply=list uit om je code magisch sneller te maken op Python 3.15!

Wat is een lazy import?

Stel je voor dat je een bestand hebt zoals dit, met een standaard Python argparse CLI:

import argparse
import numpy

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--foo", action="store_true")
    args = parser.parse_args()
    if args.foo:
        print(numpy.array([1, 2, 3]))

Wat gebeurt er als je dit uitvoert met --help? De numpy-bibliotheek wordt geïmporteerd, ook al wordt deze nooit gebruikt. Als je moderne uv-tooling gebruikt, kan dit nog erger zijn, aangezien uv geen bytecode vooraf compileert tenzij je daarom vraagt; dat maakt de installatie sneller, maar imports zijn de eerste keer trager.

Bovenstaande is slechts één voorbeeld; dit kan ook gebeuren bij dit veelvoorkomende patroon:

# __init__.py
from . import a
from . import b
__all__ = ["a", "b"]

Het idee hierachter is dat een gebruiker gewoon lib.a.stuff kan gebruiken met enkel import lib, in plaats van import lib.a, maar je betaalt de prijs van de import zelfs als ze niet alle imports gebruiken. Sommige bibliotheken, zoals rich, zijn voorzichtig om dit te vermijden en vragen gebruikers om expliciet te importeren, maar veel oudere bibliotheken deden dit wel.

Daarnaast zijn er bibliotheken die meerdere dingen kunnen (zoals CLI-bibliotheken met subcommando's), maar je hebt niet voor elk subcommando de afhankelijkheden nodig.

Hoe gebruik je de lazy imports van Python 3.15

Neem het eerste voorbeeld. In Python 3.15 kun je nu schrijven:

lazy import argparse
lazy import numpy

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--foo", action="store_true")
    args = parser.parse_args()
    if args.foo:
        print(numpy.array([1, 2, 3]))

Nu zijn beide imports "lazy", wat betekent dat er helemaal niets gebeurt wanneer je ze importeert. Ze hoeven misschien niet eens geïnstalleerd te zijn. De eerste keer dat je echter probeert het object te gebruiken, wordt het een echt, geïmporteerd object. Dus als je --help uitvoert, wordt numpy nooit aangeroepen en nooit geïmporteerd.

Er is ook een achterwaarts compatibele syntax:

__lazy_modules__ = ["argparse", "numpy"]
import argparse
import numpy

Dit werkt op oudere Python-versies (het is daar gewoon niet lazy), en je kunt die lijst ook dynamisch genereren of manipuleren als je dat wilt. Linters zoals Ruff zijn al bijgewerkt om toe te staan dat dit boven je imports wordt geplaatst zonder een lint-fout te veroorzaken.

Ik moet vermelden dat er een vlag en een variabele zijn om Python alle imports als lazy te laten behandelen: -X lazyimports=all en PYTHONLAZY_IMPORTS=all (evenzo normal en none). Dat is voornamelijk bedoeld voor testen.

In Python 3.15a7 schakelt none de lazymodules niet uit; het schakelt alleen de syntax-versie uit! Dit zal worden opgelost. Momenteel zorgde het uitschakelen van lazy imports ervoor dat het syntax-trefwoord werd uitgeschakeld, wat betekent dat je het niet kunt gebruiken voor circulaire imports, type-checking, etc. Ik denk persoonlijk dat none alleen de backward-compatibele lazymodules-variant zou moeten uitschakelen; lazy import zou een gegarandeerde lazy import moeten zijn. Het bestaan van deze optie in huidige staat blokkeert momenteel het gebruik in de standaardbibliotheek.

Waarom niet lazy?

Zou je niet gewoon alles als lazy moeten markeren? Dat hoeft niet. Er zijn modules die bijdragen aan neveneffecten (side effects) bij het importeren; als die neveneffecten op de plek van de import moeten gebeuren, kunnen die niet lazy zijn. Dit patroon kan bijvoorbeeld niet lazy zijn:

try:
    import numpy
except ModuleNotFoundError:
    ...

De fout zal hier verschuiven naar het eerste gebruik van iets uit numpy. Er is een semi-lazy alternatief:

import importlib.util
if importlib.util.find_spec("numpy") is None:
    ... # wat je ook wilde doen als numpy ontbreekt
lazy import numpy

Dit is iets duurder dan helemaal niets doen ( reden waarom lazy importing dit niet doet), zal pakketten importeren om bij subpakketten te komen (a.b importeert a), en sommige soorten importfouten worden niet getriggerd bij het enkel zoeken naar de spec (in het bovenstaande voorbeeld zou numpy._core kunnen ontbreken/defect zijn als iemand numpy niet correct heeft gecompileerd — dit is echter zeldzaam). Desalniettemin is dit een vrij goede manier om te controleren of een pakket is geïnstalleerd.

Een ander geval waarin lazy niet nodig is, is wanneer je iets op het hoogste niveau (top level) gebruikt. Bijvoorbeeld:

lazy import re
REGEX = re.compile(...) # hier is het niet lazy

Hier is de lazy import niet nodig, aangezien je het bestand sowieso niet kunt verwerken zonder dit te importeren. Je kunt dit omzeilen door te cachen:

Python 3.15+

import functools
lazy import re

@functools.cache
def regex() -> re.Pattern:
    return re.compile(...)

Ouder

from __future__ import annotations
__lazy_modules__ = ["re"]
import functools
import re

@functools.cache
def regex() -> re.Pattern:
    return re.compile(...)

Merk op dat ik from future import annotations niet nodig heb om dit te laten werken; de annotatie veroorzaakt niet dat de re-module wordt geladen, omdat annotaties in Python 3.14 standaard lazy werden.

Je kunt dit soort imports lazy maken, maar je verplaatst dan alleen de importfouten zonder goede reden, dus het is beter om dat niet te doen.

Als je alles in een bestand lazy wilt maken, kun je dat als volgt doen:

class AllLazy:
    @staticmethod
    def __contains__(_: str) -> bool:
        return True

__lazy_modules__ = AllLazy()

Dit wordt simpelweg gebruikt door te testen met in op volledige modulenamen, en je kunt hier je eigen object in zetten. (De statische tool hieronder zoekt hier nog niet naar.)

Een tool om te helpen

Ideaal gezien zouden bibliotheken deze lazy_modules moeten gaan toevoegen, maar het is iets complexer dan gewoon alle modules erin zetten. Daarom heb ik een tool geschreven, flake8-lazy, om te helpen bepalen wat er precies moet worden toegevoegd en om het netjes te houden. Dit is de eerste bibliotheek die ik zwaar met AI-tools heb ontwikkeld (ik ben ze begonnen te gebruiken om te helpen bij het onderhouden van plumbum, maar dat was niet vanaf nul), dus ik zal eindigen met een sectie over hoe dat ging (zeer goed). Ik heb in het verleden flake8-errmsg ontwikkeld, dus dit is niet mijn eerste flake8-plugin. Net als bij dat project is er ook een ingebouwde standalone runner; vroeg in de 3.15-levenscyclus verwacht ik dat dit de belangrijkste manier van gebruik zal zijn.

Om het te gebruiken:

Via uv:

# Toon flake8-stijl fouten
uvx flake8-lazy <filenames>
# Toon de regels die je moet toevoegen
uvx flake8-lazy --format=lazy-modules
# Voeg ze direct toe!
uvx flake8-lazy --apply=list <filenames>

Via pipx:

# Toon flake8-stijl fouten
pipx run flake8-lazy <filenames>
# Toon de regels die je moet toevoegen
pipx run flake8-lazy --format=lazy-modules
# Voeg ze direct toe!
pipx run flake8-lazy --apply=list <filenames>

Dit zal de fouten rapporteren (noqa werkt niet met de eenvoudige runner).

Hier zijn de momenteel geïmplementeerde fouten (0.6.0):

Code 1xx: Ontbrekende lazy declaraties

  • LZY101: stdlib module zou in lazy_modules moeten staan.
  • LZY102: third-party of lokale module zou in lazy_modules moeten staan.

Deze proberen zaken te vinden die niet op het hoogste niveau worden gebruikt, en suggereren dat ze aan je lazy_modules worden toegevoegd (de lazy syntax werkt ook). Momenteel gaan ze ervan uit dat annotaties geen import triggeren (aangezien flake8, in tegenstelling tot Ruff, niet weet welke minimale Python-versie je target en dus niet kan bepalen of dit 3.14+ is).

Code 2xx: lazy_modules validatie

  • LZY201: lazy_modules is niet gesorteerd.
  • LZY202: module in lazy_modules wordt nooit geïmporteerd.
  • LZY203: module in lazy_modules is gedupliceerd.
  • LZY204: lazy_modules wordt toegewezen na het importeren van de modules die het benoemt.
  • LZY205: module in lazy_modules moet een absolute naam zijn.

Deze zoeken naar algemene problemen specifiek met lazy_modules.

Code 3xx: Native lazy keyword (Python 3.15+)

  • LZY301: lazy import binnen suppress(ImportError) is misleidend.
  • LZY302: module is gedeclareerd als lazy via zowel het lazy keyword als lazy_modules.
  • LZY303: module is zowel eager als lazy geïmporteerd.

Deze zoeken naar problemen specifiek voor de nieuwe syntax van Python 3.15+. Deze werken alleen als de host Python 3.15+ is. Je kunt uv al vertellen dit te gebruiken met --python=3.15.

Code 4xx: Lazy import veiligheid en semantiek

  • LZY401: module is gedeclareerd als lazy maar wordt op het hoogste niveau benaderd.
  • LZY402: module is een omhullend pakket voor dit bestand en zou niet lazy moeten zijn.

LZY401 is in feite het tegenovergestelde van de LZY101/LZY102 controles; als je iets op het hoogste niveau benadert, kun je het net zo goed niet lazy maken. Dit zou kunnen worden verplaatst naar een 9xx-controle, omdat het niet problematisch is om het te doen en het controlesysteem het fout zou kunnen hebben.

Tips

  • Pas dit niet toe op test-suites.
  • Zoek naar kansen om dingen lazy te maken als ze hier niet worden vermeld. Het re-voorbeeld hierboven is daar een voorbeeld van. Controleer ook de daadwerkelijk geïmporteerde bibliotheken — één bibliotheek kan een andere importeren (vrij veel bibliotheken importeren re, inclusief typing, wat dat lastig maakt om te vermijden! re is helaas ook vrij traag). Je kunt dit doen met -X importtime. Alles wat lazy is en nooit wordt geïmporteerd, zal hier niet meer verschijnen. Je kunt lazy imports geforceerd uitschakelen om het verschil te zien. Je kunt ze ook geforceerd inschakelen om te zien hoeveel tijd je zou kunnen besparen voordat je begint.
  • Type-checkers behandelen TYPE_CHECKING altijd als True, dus je kunt het importeren van typing vermijden met deze truc:

``python TYPECHECKING = False if TYPECHECKING: ... ` Met Ruff kun je dit zelfs afdwingen met de TID251-check: `toml [tool.ruff.lint.flake8-tidy-imports.banned-api] "typing.TYPECHECKING".msg = "Use TYPECHECKING=False instead" ``

  • Het lazy_modules systeem is volledig dynamisch (het heeft alleen een contains methode nodig voor absolute modulenamen); de checks behandelen niets dynamisch hier. De meest voorkomende use-case, relatieve imports, kan statisch worden gelaten:

``python lazy_modules = [f"{spec.parent}.thing"] from . import thing ` Let op dat package de oudere vorm is van spec.parent. Gebruik dit niet in main.py, gebruik daar absolute imports (mypy zal merken dat spec in dat bestand None` kan zijn).

Resultaten

Ik heb geprobeerd deze tool op zijn eigen broncode uit te voeren, en slaagde erin de --help vlag 2x sneller te maken op Python 3.15. Op cibuildwheel resulteerde dit in een versnelling van 3-4x voor zaken als --help en --print-build-identifiers. Hugo, de release manager voor Python 3.14, kon iets meer snelheid behalen in een PR naar mijn PR.

Hier zijn enkele van de resultaten die ik tot nu toe heb behaald; voor elk geval controleer ik --help, hoewel andere zaken ook sneller kunnen worden. Vanwege de bug vermeld in 3.15a7, gebruikt de "Before" timing soms 3.14.

PackageBeforeAfterSpeedupNotes
flake8-lazy100+ ms50 ms2xOriginele versnelling
repo-review113 ms35 ms3xPR
cibuildwheel179 ms61 ms3xPR
build50 ms45 ms10%Nog niet publiek, al snel
packagingN/AN/AN/APR, geen --help om te testen
check-sdist45.3 ms34.3 ms30%PR

100+ ms is merkbaar; onder die grens komen je apps "snappier" over. Python zelf neemt ongeveer 15 ms in beslag (op mijn M1), dus je kunt niet sneller worden dan dat (en je hebt waarschijnlijk tenminste een paar dingen nodig, zoals argparse).

Je kunt de impact van elke bibliotheek, en je succes of falen om imports te verminderen, zien met -X importtime. Als je dit zelf wilt timen, gebruik dan hyperfine:

hyperfine --warmup 10 \
-n "main" --prepare "git checkout main"        "python3.15 -m <pkg> --help" \
-n "PR"   --prepare "git checkout some-branch" "python3.15 -m <pkg> --help"

Je kunt ook één run doen, en -X lazy_modules=none of all meegeven. Houd er rekening mee dat uv en sommige andere tools standaard geen bytecode compileren, wat betekent dat je voor de eerste run veel meer zou kunnen besparen dan de bovenstaande metingen aangeven. Sommige resultaten zouden kunnen verbeteren als third-party bibliotheken of de standaardbibliotheek lazy imports toevoegen.

Er is nog een weg te gaan — er zijn veel edge cases bij het detecteren of iets wordt opgelost. Bijvoorbeeld, dataclasses lossen type-hints op om te zien of typing.ClassVar wordt gebruikt, wat de laziness verbreekt. Het is beter om te veel in lazy te zetten dan te weinig.

Er is ook een groot probleem met deze syntax: from a import b. Is a.b een module of niet? Alleen een type-checker weet dit (als het getypeerd is). Dit is hetzelfde geval: from . import b. Ik moest ervan uitgaan dat de rechterkant geen module is, maar als dat wel zo is, wordt het gemist. Je kunt as gebruiken om deze ambigue syntax te vermijden.

Ook is het wellicht duidelijk, maar de meeste grote, trage imports zoals numpy worden nog niet gebouwd voor CPython 3.15 (rond de eerste RC kunnen gecompileerde wheels worden gepubliceerd), dus sommige van de spannendste verbeteringen in tijd kunnen nog niet worden getest.

De tool ontwikkelen met AI

Dit was een echt interessant project om AI op te proberen, mede omdat dit nog nooit eerder was gedaan. Lazy imports werden zeer recent toegevoegd, zijn pas ongeveer een week geleden voor het eerst uitgebracht in een alpha-build van CPython, en zijn pas drie dagen geleden gemakkelijk beschikbaar in uv. De AI kan dus niet gewoon bestaande code kopiëren omdat die niet bestaat. De AI moet mijn input nemen, tests draaien, de PEP lezen en daarop "redeneren". En dat doet het. Ik heb het gebruikt voor meer dan 40 taken, en het is nooit misgegaan in het begrijpen van wat ik vroeg. Het heeft me niet "overtroffen" door iets slimmers te doen dan ik zou hebben gedaan, maar het volgde instructies perfect op. Niet alleen heb ik niet meer dan ongeveer 5% van de code handmatig geschreven (voornamelijk tweaks en configuratie), maar ik heb zelfs niet alle implementatiedetails gevolgd. De initiële versie kostte minder dan een dag (ik deed andere dingen terwijl de AI werkte), en het in een bruikbare vorm brengen (door het op bibliotheken te testen) gebeurde over de volgende paar dagen. Dit is waarschijnlijk 5-7x sneller dan ik het handmatig had kunnen doen. De code is iets langer dan een handgeschreven oplossing, vooral door duplicatie.

Ik begon met het template van mijn Scientific Python Development Guide, dat al sterke linting, formatting en test-setup heeft, perfect voor AI-gebruik. Ik probeerde een paar opties die ik nog niet eerder had gebruikt, zoals uv_build voor de backend, en de nieuwe Zensical documentatie-engine. Ik ontdekte ook een paar zaken die verbeterd konden worden en heb deze teruggezet in het template. Ik heb ook de linting-checks verhoogd naar ALL en vervolgens uvx --from sp-repo-review[cli] sp-ruff-checks . gebruikt om een lijst van checks te krijgen die altijd goed zijn om te negeren. Ik denk dat veel van het succes van de AI te danken is aan hoe goed deze setup is.

Voor AI gebruik ik GitHub Copilot, met automatische modelselectie, primair in VSCode. Het model leek voornamelijk GPT-5.3-codex te zijn, hoewel Claude Sonnet 4.6 soms automatisch werd geselecteerd. Ik voegde in eerste instantie geen configuratie toe, maar toen ik mijn eerste context-window vulde en een nieuwe chat wilde starten, voegde ik een handgeschreven AGENTS.md en een copilot CI-configuratie toe. De focus hiervan was om uv, prek, en nox beschikbaar te maken en de tools te instrueren deze te gebruiken. Dit verminderde de noodzaak om deze handmatig uit te voeren of de agent erover te informeren.

Ik heb uiteindelijk heel weinig handmatig gecodeerd — de meeste van mijn handmatige bewerkingen waren setup of configuratie. Als ik de output van het model niet leuk vond, vroeg ik simpelweg om wijzigingen. Ik was echter heel expliciet in mijn instructies; ik heb eerder een plugin voor flake8 met een manual runner geschreven, dus ik wist wat ik wilde. En ik itereerde veel. Bijvoorbeeld, bij het toevoegen van betere foutmeldingen voor defecte bestanden, dacht het model aan het toevoegen van Python 3.11 exception notes, maar deed het vervolgens op een andere manier vanwege Python 3.10 als minimum. Ik vroeg het model om alsnog de notes te gebruiken, maar deze af te schermen voor 3.11.

De documentatie werd aanvankelijk ook door het model geschreven, hoewel ik daar ook behoorlijk wat bewerkingen heb gedaan. Ik ben niet gek op de herhaling tussen de docs en de README, maar de agent is vrij goed in het in balans houden hiervan; zelfs als ik er één bewerk, kan hij de andere aanpassen om overeen te komen.

Ik vroeg de AI zelfs om te rebasen en merge-conflicten op te lossen. Het is nergens echt in gefaald. Het ergste was dat het niet altijd de style-checks uitvoerde, wat betekende dat ik nog één ding moest doen zodra de CI de falende checks oppikte. Maar zelfs dat was zeldzaam. Ik vroeg het uiteindelijk om het erg lange init.py bestand te refactoren, en dat deed hij perfect; het enige wat hij niet deed was de lazy_modules opnieuw toepassen.

Ik zou echt niet gelukkiger kunnen zijn met de resultaten. De agent was geweldig in het schrijven van tests voor alles wat hij toevoegde, zelfs zonder dat ik erom vroeg. Hij werkte door fouten en waarschuwingen heen — ik hoefde dat niet te doen, wat fantastisch was. Ik draaide het op de CPython-broncode en vond een probleem (de encoding werd niet afgehandeld), dus ik vertelde de agent hoe hij het moest runnen, en hij vond het probleem en paste de correcte fix toe (gebruik de tokenizer in plaats van het bestand handmatig openen, wat ik veel langer had geduurd om te vinden). Ik vroeg hem om relatieve imports correct af te handelen (.), en hij generaliseerde dit voor .., etc.

Ik vroeg hem ook om PEP 810 te lezen en te zoeken naar mogelijke checks gebaseerd op de tekst, wat hij heel goed deed; sommige van de checks komen daadwerkelijk voort uit die suggesties. Ook vroeg ik hem om een beter nummeringsschema te bedenken, wat hij ook deed.

Refactors waren geweldig. Ik kon grote wijzigingen maken, zoals het reorganiseren van het nummeringsschema, en dan zou hij een tijdje prutsen en dan was het klaar. Ik probeerde de CLI-runner multithreaded te maken op free-threaded Python, maar kreeg vrij slechte resultaten; iets creëert een single-threaded bottleneck, en ik kon dit niet snel vinden. Maar in staat zijn om grote dingen als dat heel gemakkelijk te proberen was geweldig. En dit was nog steeds geen "fout", hij deed precies wat ik wilde.

Iteratie op zaken die tijd kosten was een sterk punt. Als je een tool hebt die iets outputt, is de agent erg goed in het toepassen van een fix (inclusief complexe zaken als typing) en het opnieuw uitvoeren. Dit maakt linting-tools alleen maar waardevoller.

De codekwaliteit is niet verschrikkelijk, maar handgeschreven zou waarschijnlijk beter zijn. Ik heb in het verleden AI gebruikt voor een snelle eerste draft om te zien of iets performant is, maar maak daarna een handgeschreven implementatie voor de uiteindelijke PR. AI kan ook opruimen als je daarom vraagt; het aangeven van de minimale Python-versie en dat je waarde hecht aan moderne leesbare code helpt allemaal. Maar dat roept de vraag op: als de tests en linting sterk zijn, en als je in de toekomst AI gebruikt om het te bewerken, doet menselijke leesbaarheid dan nog wel zoveel uit? En sluit dat je dan op in het gebruik van AI-tools? (Ik heb ervoor gezorgd dat de kwaliteit niet te slecht was, maar het zijn interessante filosofische vragen.)

Over het algemeen is het verbazingwekkend, vergeleken met mijn pogingen met AI begin vorig jaar (die waren gewoon slecht), de laatste Advent of Code in TypeScript (geweldig om een taal te leren en eigenlijk best goed in refactoren en helpen), en nu, slechts 3 maanden later, waar het echt, echt goed is. Ik heb eerder geprobeerd pattern matching te doen; dat was verschrikkelijk, en nu doet hij het correct (hoewel hij er nog steeds om gevraagd moet worden). Hij schrijft niet altijd ideale patronen, maar dat is makkelijk op te ruimen als het correct is. Het is nog steeds een tool die doet wat hem verteld wordt, maar hij is erg goed geworden in het doen van wat hem verteld wordt. Gecombineerd met proper linting en testing setups (kritiek!), is het een zeer goede helper.

De vaardigheden die nodig zijn om ermee te werken, zijn naar mijn mening hetzelfde. Ik doe nog steeds de soort hoogwaardige zaken die ik zou doen bij het ontwerpen van een bibliotheek. Toen ik generieke typing toevoegde aan boost-histogram, deed ik er één met de hand en vertelde AI vervolgens de rest te doen volgens mijn voorbeeld. Ik neem de beslissingen, het is alleen nu veel sneller (minder van mijn tijd, ik doe andere dingen terwijl hij werkt) om het resultaat van die beslissingen te zien.

Overigens werken de 0x token modellen (ik probeerde GPT 5 mini) prima bij het nemen van de output van flake8-lazy --format=lazy-modules en het automatisch toepassen daarvan op een niet-triviale codebase. Het is een beetje traag, maar het werkt; ik gebruikte dat aanvankelijk op cibuildwheel. Daarom heb ik in 0.4.0 een --apply feature aan de CLI toegevoegd om de regels te injecteren. Nu (in 0.6.0) ondersteunt het verschillende formaten: list, set, native, en dynamic.