diff --git a/pyproject.toml b/pyproject.toml index e3137153e2..861e06af8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/python315-lazy-imports/README.md b/python315-lazy-imports/README.md new file mode 100644 index 0000000000..a1c8ad649d --- /dev/null +++ b/python315-lazy-imports/README.md @@ -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. diff --git a/python315-lazy-imports/allmode.py b/python315-lazy-imports/allmode.py new file mode 100644 index 0000000000..45ad32cb10 --- /dev/null +++ b/python315-lazy-imports/allmode.py @@ -0,0 +1,6 @@ +import sys + +import json + +print("json deferred?", "json" in sys.lazy_modules) +print(json.dumps({"ok": True})) diff --git a/python315-lazy-imports/badfunc.py b/python315-lazy-imports/badfunc.py new file mode 100644 index 0000000000..78511605e6 --- /dev/null +++ b/python315-lazy-imports/badfunc.py @@ -0,0 +1,2 @@ +def load(): + lazy import json diff --git a/python315-lazy-imports/bridge.py b/python315-lazy-imports/bridge.py new file mode 100644 index 0000000000..49ce7d3eac --- /dev/null +++ b/python315-lazy-imports/bridge.py @@ -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})) diff --git a/python315-lazy-imports/circular/eager/a.py b/python315-lazy-imports/circular/eager/a.py new file mode 100644 index 0000000000..38597d264f --- /dev/null +++ b/python315-lazy-imports/circular/eager/a.py @@ -0,0 +1,5 @@ +from b import B + +class A: + def make_b(self): + return B() diff --git a/python315-lazy-imports/circular/eager/b.py b/python315-lazy-imports/circular/eager/b.py new file mode 100644 index 0000000000..7be83d4167 --- /dev/null +++ b/python315-lazy-imports/circular/eager/b.py @@ -0,0 +1,5 @@ +from a import A + +class B: + def make_a(self): + return A() diff --git a/python315-lazy-imports/circular/eager/main.py b/python315-lazy-imports/circular/eager/main.py new file mode 100644 index 0000000000..b5ea761880 --- /dev/null +++ b/python315-lazy-imports/circular/eager/main.py @@ -0,0 +1,3 @@ +from a import A + +print(A().make_b()) diff --git a/python315-lazy-imports/circular/init_eager/main.py b/python315-lazy-imports/circular/init_eager/main.py new file mode 100644 index 0000000000..67a142eb3b --- /dev/null +++ b/python315-lazy-imports/circular/init_eager/main.py @@ -0,0 +1,3 @@ +import pricing + +print(pricing.TOTAL) diff --git a/python315-lazy-imports/circular/init_eager/pricing.py b/python315-lazy-imports/circular/init_eager/pricing.py new file mode 100644 index 0000000000..773bd96cc8 --- /dev/null +++ b/python315-lazy-imports/circular/init_eager/pricing.py @@ -0,0 +1,3 @@ +from tax import RATE + +TOTAL = 100 * (1 + RATE) diff --git a/python315-lazy-imports/circular/init_eager/tax.py b/python315-lazy-imports/circular/init_eager/tax.py new file mode 100644 index 0000000000..91aff7f805 --- /dev/null +++ b/python315-lazy-imports/circular/init_eager/tax.py @@ -0,0 +1,4 @@ +from pricing import TOTAL + +RATE = 0.2 +BUDGET = TOTAL / 2 diff --git a/python315-lazy-imports/circular/init_lazy/main.py b/python315-lazy-imports/circular/init_lazy/main.py new file mode 100644 index 0000000000..67a142eb3b --- /dev/null +++ b/python315-lazy-imports/circular/init_lazy/main.py @@ -0,0 +1,3 @@ +import pricing + +print(pricing.TOTAL) diff --git a/python315-lazy-imports/circular/init_lazy/pricing.py b/python315-lazy-imports/circular/init_lazy/pricing.py new file mode 100644 index 0000000000..215e1be2dc --- /dev/null +++ b/python315-lazy-imports/circular/init_lazy/pricing.py @@ -0,0 +1,3 @@ +lazy from tax import RATE + +TOTAL = 100 * (1 + RATE) diff --git a/python315-lazy-imports/circular/init_lazy/tax.py b/python315-lazy-imports/circular/init_lazy/tax.py new file mode 100644 index 0000000000..91aff7f805 --- /dev/null +++ b/python315-lazy-imports/circular/init_lazy/tax.py @@ -0,0 +1,4 @@ +from pricing import TOTAL + +RATE = 0.2 +BUDGET = TOTAL / 2 diff --git a/python315-lazy-imports/circular/lazy/a.py b/python315-lazy-imports/circular/lazy/a.py new file mode 100644 index 0000000000..32bdbb3361 --- /dev/null +++ b/python315-lazy-imports/circular/lazy/a.py @@ -0,0 +1,5 @@ +lazy from b import B + +class A: + def make_b(self): + return B() diff --git a/python315-lazy-imports/circular/lazy/b.py b/python315-lazy-imports/circular/lazy/b.py new file mode 100644 index 0000000000..7be83d4167 --- /dev/null +++ b/python315-lazy-imports/circular/lazy/b.py @@ -0,0 +1,5 @@ +from a import A + +class B: + def make_a(self): + return A() diff --git a/python315-lazy-imports/circular/lazy/main.py b/python315-lazy-imports/circular/lazy/main.py new file mode 100644 index 0000000000..b5ea761880 --- /dev/null +++ b/python315-lazy-imports/circular/lazy/main.py @@ -0,0 +1,3 @@ +from a import A + +print(A().make_b()) diff --git a/python315-lazy-imports/fail.py b/python315-lazy-imports/fail.py new file mode 100644 index 0000000000..51bc91c95e --- /dev/null +++ b/python315-lazy-imports/fail.py @@ -0,0 +1,4 @@ +lazy import missing_mod + +print("Still running.") +print(missing_mod.value) diff --git a/python315-lazy-imports/lazy_annotation.py b/python315-lazy-imports/lazy_annotation.py new file mode 100644 index 0000000000..001a85d58c --- /dev/null +++ b/python315-lazy-imports/lazy_annotation.py @@ -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) diff --git a/python315-lazy-imports/noisy_module.py b/python315-lazy-imports/noisy_module.py new file mode 100644 index 0000000000..b2c5e0ba2b --- /dev/null +++ b/python315-lazy-imports/noisy_module.py @@ -0,0 +1,3 @@ +print("noisy_module is loading now") + +VALUE = 42 diff --git a/python315-lazy-imports/partial.py b/python315-lazy-imports/partial.py new file mode 100644 index 0000000000..566bef3a97 --- /dev/null +++ b/python315-lazy-imports/partial.py @@ -0,0 +1,5 @@ +lazy from shapes import CIRCLE, SQUARE + +print(CIRCLE) +print(type(globals()["CIRCLE"])) +print(type(globals()["SQUARE"])) diff --git a/python315-lazy-imports/probe.py b/python315-lazy-imports/probe.py new file mode 100644 index 0000000000..43f19478b5 --- /dev/null +++ b/python315-lazy-imports/probe.py @@ -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) diff --git a/python315-lazy-imports/report_cli/bench.py b/python315-lazy-imports/report_cli/bench.py new file mode 100644 index 0000000000..c7e040e20e --- /dev/null +++ b/python315-lazy-imports/report_cli/bench.py @@ -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 [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() diff --git a/python315-lazy-imports/report_cli/cli_eager.py b/python315-lazy-imports/report_cli/cli_eager.py new file mode 100644 index 0000000000..fb0c05f391 --- /dev/null +++ b/python315-lazy-imports/report_cli/cli_eager.py @@ -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() diff --git a/python315-lazy-imports/report_cli/cli_lazy.py b/python315-lazy-imports/report_cli/cli_lazy.py new file mode 100644 index 0000000000..95d6a9e17d --- /dev/null +++ b/python315-lazy-imports/report_cli/cli_lazy.py @@ -0,0 +1,105 @@ +"""Summarize a CSV of sales figures, with a handful of optional modes. + +This is the fast version. Five imports carry the `lazy` keyword, so +the HTTP server, the async runtime, and the GUI toolkit only load if +the code path you take actually reaches them. +""" + +import argparse +import csv + +lazy import asyncio +lazy import http.server +lazy import statistics +lazy import tkinter +lazy 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() diff --git a/python315-lazy-imports/report_cli/cli_too_lazy.py b/python315-lazy-imports/report_cli/cli_too_lazy.py new file mode 100644 index 0000000000..2a4e90d078 --- /dev/null +++ b/python315-lazy-imports/report_cli/cli_too_lazy.py @@ -0,0 +1,105 @@ +"""Summarize a CSV of sales figures, with a handful of optional modes. + +This version goes one step too far: the two plug-in imports are lazy +as well. They exist purely for their import-time side effect, so +deferring them means they never run and the registry stays empty. +""" + +import argparse +import csv + +lazy import asyncio +lazy import http.server +lazy import statistics +lazy import tkinter +lazy import xml.etree.ElementTree as ET + +import handlers +lazy import handlers.csv_out +lazy 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() diff --git a/python315-lazy-imports/report_cli/handlers/__init__.py b/python315-lazy-imports/report_cli/handlers/__init__.py new file mode 100644 index 0000000000..e41838da43 --- /dev/null +++ b/python315-lazy-imports/report_cli/handlers/__init__.py @@ -0,0 +1,17 @@ +"""A tiny plug-in registry for the report tool's output formats. + +Each format module registers itself at import time, which makes these +imports the ones that must stay eager. +""" + +FORMATS = {} + +def register(name): + def decorator(func): + FORMATS[name] = func + return func + + return decorator + +def available(): + return ", ".join(sorted(FORMATS)) diff --git a/python315-lazy-imports/report_cli/handlers/csv_out.py b/python315-lazy-imports/report_cli/handlers/csv_out.py new file mode 100644 index 0000000000..86133b8a73 --- /dev/null +++ b/python315-lazy-imports/report_cli/handlers/csv_out.py @@ -0,0 +1,5 @@ +from handlers import register + +@register("csv") +def emit(rows): + return ",".join(str(row) for row in rows) diff --git a/python315-lazy-imports/report_cli/handlers/json_out.py b/python315-lazy-imports/report_cli/handlers/json_out.py new file mode 100644 index 0000000000..c2976c187a --- /dev/null +++ b/python315-lazy-imports/report_cli/handlers/json_out.py @@ -0,0 +1,5 @@ +from handlers import register + +@register("json") +def emit(rows): + return "[" + ", ".join(f'"{row}"' for row in rows) + "]" diff --git a/python315-lazy-imports/report_cli/sales.csv b/python315-lazy-imports/report_cli/sales.csv new file mode 100644 index 0000000000..20c3bd8179 --- /dev/null +++ b/python315-lazy-imports/report_cli/sales.csv @@ -0,0 +1,25 @@ +region,amount +north,938.77 +north,147.21 +north,944.81 +north,322.07 +north,471.38 +north,338.41 +south,309.94 +south,241.29 +south,315.74 +south,709.05 +south,234.01 +south,749.42 +east,944.23 +east,448.39 +east,491.84 +east,807.18 +east,363.36 +east,876.74 +west,482.47 +west,622.49 +west,763.19 +west,415.96 +west,693.11 +west,139.17 diff --git a/python315-lazy-imports/shapes.py b/python315-lazy-imports/shapes.py new file mode 100644 index 0000000000..576cade5c1 --- /dev/null +++ b/python315-lazy-imports/shapes.py @@ -0,0 +1,4 @@ +print("shapes is loading now") + +CIRCLE = "circle" +SQUARE = "square" diff --git a/python315-lazy-imports/type_checking_guard.py b/python315-lazy-imports/type_checking_guard.py new file mode 100644 index 0000000000..b6d5213cf9 --- /dev/null +++ b/python315-lazy-imports/type_checking_guard.py @@ -0,0 +1,9 @@ +from typing import TYPE_CHECKING, get_type_hints + +if TYPE_CHECKING: + from decimal import Decimal + +def to_pennies(amount: Decimal) -> int: + return int(amount * 100) + +print(get_type_hints(to_pennies)) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..a5bc51476b --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.14"