Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ exclude = [
".github",
"migrations",
"how-to-indent-in-python/sample_code.py",
"agents-md/run1_main.py"
"agents-md/run1_main.py",
"python315-lazy-imports"
]

[tool.ruff.lint]
Expand Down
61 changes: 61 additions & 0 deletions python315-lazy-imports/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Python 3.15 Preview: Lazy Imports

This folder provides the code examples for the Real Python tutorial [Python 3.15 Preview: Lazy Imports](https://realpython.com/python315-lazy-imports/)

Everything here is standard library only, but it needs **Python 3.15** or later, because most of the files use the `lazy` keyword from [PEP 810](https://peps.python.org/pep-0810/). On earlier versions those files raise a `SyntaxError` before they run.

## What's Here

| Path | Section |
| --- | --- |
| `noisy_module.py`, `probe.py` | Defer a Whole Module |
| `shapes.py`, `partial.py` | Defer a Name From a Module |
| `badfunc.py` | Find Out Where `lazy` Isn't Allowed |
| `report_cli/` | Speed Up a Real CLI |
| `type_checking_guard.py`, `lazy_annotation.py` | Retire the `if TYPE_CHECKING` Dance |
| `fail.py` | Read a Deferred Import Error |
| `circular/` | Don't Expect a Circular Import Fix |
| `bridge.py`, `allmode.py` | Go Lazy Without the Keyword |

## The Report CLI

`report_cli/` holds three versions of the same command-line tool:

- `cli_eager.py` imports everything eagerly.
- `cli_lazy.py` marks five heavy standard library imports `lazy` and is otherwise identical.
- `cli_too_lazy.py` also defers the two plug-in imports, which silently empties the format registry.

Run the benchmark from inside that folder:

```console
$ python bench.py cli_eager.py --help
$ python bench.py cli_lazy.py --help
```

The `--load-all` flag reads all five deferred names, so you can check that deferral costs nothing once the modules are actually used.

## Circular Imports

Each subfolder of `circular/` is self-contained. Run `python main.py` from inside it:

- `eager/` — a two-module cycle that fails.
- `lazy/` — the same cycle with one import deferred, which fixes it.
- `init_eager/` and `init_lazy/` — a cycle that needs a value during module initialization, which deferral does not fix. Both fail with the same `ImportError`.

## Files That Fail on Purpose

Three files here are meant to raise. If you run them and see a traceback, that's the point:

- `badfunc.py` — `SyntaxError`, because `lazy` isn't allowed inside a function.
- `fail.py` — a chained `ImportError` from a deferred import of a module that doesn't exist.
- `type_checking_guard.py` — `NameError`, which is the problem the lazy version solves.

The `circular/eager/`, `circular/init_eager/`, and `circular/init_lazy/` folders fail on purpose too.

## Getting a 3.15 With tkinter

`report_cli/cli_eager.py` imports `tkinter` at module level, because it's the heaviest import in the demo. A `uv`-managed 3.15 has it. If you build 3.15 with `pyenv` instead, install your platform's Tk development headers first, or the build produces an interpreter without `tkinter` and the CLI won't start.

## Note on Formatting

These files follow the Real Python style guide's blank-line rules rather than PEP 8, so they're excluded from the repository's `ruff` checks. `ruff` also can't parse the `lazy` keyword yet.
6 changes: 6 additions & 0 deletions python315-lazy-imports/allmode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import sys

import json

print("json deferred?", "json" in sys.lazy_modules)
print(json.dumps({"ok": True}))
2 changes: 2 additions & 0 deletions python315-lazy-imports/badfunc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def load():
lazy import json
10 changes: 10 additions & 0 deletions python315-lazy-imports/bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import sys

__lazy_modules__ = {"json"}

import json

major, minor = sys.version_info[:2]
deferred = "json" in getattr(sys, "lazy_modules", ())
print(f"Python {major}.{minor}: json deferred? {deferred}")
print(json.dumps({"ok": True}))
5 changes: 5 additions & 0 deletions python315-lazy-imports/circular/eager/a.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from b import B

class A:
def make_b(self):
return B()
5 changes: 5 additions & 0 deletions python315-lazy-imports/circular/eager/b.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from a import A

class B:
def make_a(self):
return A()
3 changes: 3 additions & 0 deletions python315-lazy-imports/circular/eager/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from a import A

print(A().make_b())
3 changes: 3 additions & 0 deletions python315-lazy-imports/circular/init_eager/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pricing

print(pricing.TOTAL)
3 changes: 3 additions & 0 deletions python315-lazy-imports/circular/init_eager/pricing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from tax import RATE

TOTAL = 100 * (1 + RATE)
4 changes: 4 additions & 0 deletions python315-lazy-imports/circular/init_eager/tax.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from pricing import TOTAL

RATE = 0.2
BUDGET = TOTAL / 2
3 changes: 3 additions & 0 deletions python315-lazy-imports/circular/init_lazy/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pricing

print(pricing.TOTAL)
3 changes: 3 additions & 0 deletions python315-lazy-imports/circular/init_lazy/pricing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
lazy from tax import RATE

TOTAL = 100 * (1 + RATE)
4 changes: 4 additions & 0 deletions python315-lazy-imports/circular/init_lazy/tax.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from pricing import TOTAL

RATE = 0.2
BUDGET = TOTAL / 2
5 changes: 5 additions & 0 deletions python315-lazy-imports/circular/lazy/a.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
lazy from b import B

class A:
def make_b(self):
return B()
5 changes: 5 additions & 0 deletions python315-lazy-imports/circular/lazy/b.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from a import A

class B:
def make_a(self):
return A()
3 changes: 3 additions & 0 deletions python315-lazy-imports/circular/lazy/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from a import A

print(A().make_b())
4 changes: 4 additions & 0 deletions python315-lazy-imports/fail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
lazy import missing_mod

print("Still running.")
print(missing_mod.value)
11 changes: 11 additions & 0 deletions python315-lazy-imports/lazy_annotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import sys
from typing import get_type_hints

lazy from decimal import Decimal

def to_pennies(amount: Decimal) -> int:
return int(amount * 100)

print("decimal loaded?", "decimal" in sys.modules)
print(get_type_hints(to_pennies))
print("decimal loaded?", "decimal" in sys.modules)
3 changes: 3 additions & 0 deletions python315-lazy-imports/noisy_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
print("noisy_module is loading now")

VALUE = 42
5 changes: 5 additions & 0 deletions python315-lazy-imports/partial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
lazy from shapes import CIRCLE, SQUARE

print(CIRCLE)
print(type(globals()["CIRCLE"]))
print(type(globals()["SQUARE"]))
9 changes: 9 additions & 0 deletions python315-lazy-imports/probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import sys

lazy import noisy_module

print("The lazy import statement has run.")
print("Loaded?", "noisy_module" in sys.modules)

print(noisy_module.VALUE)
print("Loaded?", "noisy_module" in sys.modules)
45 changes: 45 additions & 0 deletions python315-lazy-imports/report_cli/bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Time how long a script takes to start, run, and exit.

Usage:

python bench.py cli_eager.py --help

Runs the script ten times and reports the fastest run, which is the
measurement least polluted by whatever else the machine is doing.
"""

import subprocess
import sys
import time

RUNS = 10

if sys.version_info < (3, 15):
sys.exit(
"The report CLI needs Python 3.15 or later for the lazy "
f"keyword, but this is {sys.version.split()[0]}."
)

def time_once(command):
start = time.perf_counter()
process = subprocess.run(command, capture_output=True, text=True)
elapsed = time.perf_counter() - start
if process.returncode != 0:
sys.exit(
f"{command[1]} exited with code {process.returncode}, so "
f"there's nothing meaningful to time:\n{process.stderr}"
)
return elapsed

def main():
if len(sys.argv) < 2:
sys.exit("usage: python bench.py <script.py> [args...]")

script, *script_args = sys.argv[1:]
command = [sys.executable, script, *script_args]

best = min(time_once(command) for _ in range(RUNS))
print(f"{script}: {best * 1000:.0f} ms (best of {RUNS})")

if __name__ == "__main__":
main()
105 changes: 105 additions & 0 deletions python315-lazy-imports/report_cli/cli_eager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Summarize a CSV of sales figures, with a handful of optional modes.

This is the baseline version: every import is eager, so running
``--help`` pays for the HTTP server, the async runtime, and the GUI
toolkit even though none of them are used on that code path.
"""

import argparse
import csv

import asyncio
import http.server
import statistics
import tkinter
import xml.etree.ElementTree as ET

import handlers
import handlers.csv_out
import handlers.json_out

def load_rows(path):
with open(path, newline="", encoding="utf-8") as csv_file:
return [float(row["amount"]) for row in csv.DictReader(csv_file)]

def summarize(rows):
return (
f"count={len(rows)} "
f"mean={statistics.mean(rows):.2f} "
f"median={statistics.median(rows):.2f}"
)

def export_xml(rows):
root = ET.Element("report")
for row in rows:
ET.SubElement(root, "amount").text = f"{row:.2f}"
return ET.tostring(root, encoding="unicode")

def serve(rows, port):
body = summarize(rows).encode("utf-8")

class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(body)

with http.server.HTTPServer(("", port), Handler) as httpd:
httpd.serve_forever()

def show_window(rows):
root = tkinter.Tk()
tkinter.Label(root, text=summarize(rows)).pack()
root.mainloop()

async def _fetch(url):
await asyncio.sleep(0)
return f"pretending to fetch {url}"

def fetch(url):
return asyncio.run(_fetch(url))

def build_parser():
parser = argparse.ArgumentParser(
prog="report", description="Summarize a CSV of sales figures."
)
parser.add_argument("path", nargs="?", default="sales.csv")
parser.add_argument("--serve", type=int, metavar="PORT")
parser.add_argument("--gui", action="store_true")
parser.add_argument("--fetch", metavar="URL")
parser.add_argument("--export-xml", action="store_true")
parser.add_argument("--list-formats", action="store_true")
parser.add_argument(
"--load-all",
action="store_true",
help="touch every optional import, for benchmarking",
)
return parser

def main():
args = build_parser().parse_args()

if args.list_formats:
print(handlers.available())
return

if args.load_all:
loaded = [asyncio, http.server, statistics, tkinter, ET]
print(f"loaded {len(loaded)} optional modules")
return

rows = load_rows(args.path)

if args.fetch:
print(fetch(args.fetch))
if args.export_xml:
print(export_xml(rows))
if args.gui:
show_window(rows)
if args.serve:
serve(rows, args.serve)

print(summarize(rows))

if __name__ == "__main__":
main()
Loading
Loading