From 586cdf41155200eba86ccc86d30feb0e7fd23f30 Mon Sep 17 00:00:00 2001 From: Stephen Gruppetta Date: Fri, 21 Aug 2026 10:18:22 +0000 Subject: [PATCH 1/2] Sample code for: Python 3.15 Preview: Lazy Imports --- pyproject.toml | 3 +- python315-lazy-imports/README.md | 47 ++++++++ python315-lazy-imports/allmode.py | 6 + python315-lazy-imports/badfunc.py | 2 + python315-lazy-imports/bridge.py | 10 ++ python315-lazy-imports/circular/eager/a.py | 5 + python315-lazy-imports/circular/eager/b.py | 5 + python315-lazy-imports/circular/eager/main.py | 3 + .../circular/init_eager/main.py | 3 + .../circular/init_eager/pricing.py | 3 + .../circular/init_eager/tax.py | 4 + .../circular/init_lazy/main.py | 3 + .../circular/init_lazy/pricing.py | 3 + .../circular/init_lazy/tax.py | 4 + python315-lazy-imports/circular/lazy/a.py | 5 + python315-lazy-imports/circular/lazy/b.py | 5 + python315-lazy-imports/circular/lazy/main.py | 3 + python315-lazy-imports/fail.py | 4 + python315-lazy-imports/lazy_annotation.py | 11 ++ python315-lazy-imports/noisy_module.py | 3 + python315-lazy-imports/partial.py | 5 + python315-lazy-imports/probe.py | 9 ++ python315-lazy-imports/report_cli/bench.py | 33 ++++++ .../report_cli/cli_eager.py | 105 ++++++++++++++++++ python315-lazy-imports/report_cli/cli_lazy.py | 105 ++++++++++++++++++ .../report_cli/cli_too_lazy.py | 105 ++++++++++++++++++ .../report_cli/handlers/__init__.py | 17 +++ .../report_cli/handlers/csv_out.py | 5 + .../report_cli/handlers/json_out.py | 5 + python315-lazy-imports/report_cli/sales.csv | 25 +++++ python315-lazy-imports/shapes.py | 4 + python315-lazy-imports/type_checking_guard.py | 9 ++ 32 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 python315-lazy-imports/README.md create mode 100644 python315-lazy-imports/allmode.py create mode 100644 python315-lazy-imports/badfunc.py create mode 100644 python315-lazy-imports/bridge.py create mode 100644 python315-lazy-imports/circular/eager/a.py create mode 100644 python315-lazy-imports/circular/eager/b.py create mode 100644 python315-lazy-imports/circular/eager/main.py create mode 100644 python315-lazy-imports/circular/init_eager/main.py create mode 100644 python315-lazy-imports/circular/init_eager/pricing.py create mode 100644 python315-lazy-imports/circular/init_eager/tax.py create mode 100644 python315-lazy-imports/circular/init_lazy/main.py create mode 100644 python315-lazy-imports/circular/init_lazy/pricing.py create mode 100644 python315-lazy-imports/circular/init_lazy/tax.py create mode 100644 python315-lazy-imports/circular/lazy/a.py create mode 100644 python315-lazy-imports/circular/lazy/b.py create mode 100644 python315-lazy-imports/circular/lazy/main.py create mode 100644 python315-lazy-imports/fail.py create mode 100644 python315-lazy-imports/lazy_annotation.py create mode 100644 python315-lazy-imports/noisy_module.py create mode 100644 python315-lazy-imports/partial.py create mode 100644 python315-lazy-imports/probe.py create mode 100644 python315-lazy-imports/report_cli/bench.py create mode 100644 python315-lazy-imports/report_cli/cli_eager.py create mode 100644 python315-lazy-imports/report_cli/cli_lazy.py create mode 100644 python315-lazy-imports/report_cli/cli_too_lazy.py create mode 100644 python315-lazy-imports/report_cli/handlers/__init__.py create mode 100644 python315-lazy-imports/report_cli/handlers/csv_out.py create mode 100644 python315-lazy-imports/report_cli/handlers/json_out.py create mode 100644 python315-lazy-imports/report_cli/sales.csv create mode 100644 python315-lazy-imports/shapes.py create mode 100644 python315-lazy-imports/type_checking_guard.py 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..4882f051e4 --- /dev/null +++ b/python315-lazy-imports/README.md @@ -0,0 +1,47 @@ +# 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`. + +## 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..9f59ac9d11 --- /dev/null +++ b/python315-lazy-imports/report_cli/bench.py @@ -0,0 +1,33 @@ +"""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 + +def time_once(command): + start = time.perf_counter() + subprocess.run(command, capture_output=True, check=False) + return time.perf_counter() - start + +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)) From f8de43708028076433bd7377b90a82da4a183590 Mon Sep 17 00:00:00 2001 From: Stephen Gruppetta Date: Fri, 21 Aug 2026 10:58:59 +0000 Subject: [PATCH 2/2] Fail loudly in bench.py, and document the failing examples bench.py ignored the subprocess return code, so a script that crashed was timed as if it had succeeded. A reader whose python isn't 3.15 would have seen cli_lazy.py die with a SyntaxError and bench.py report it as a 2.5x speedup: $ python3.14 bench.py cli_lazy.py --help cli_lazy.py: 50 ms (best of 10) That's the same silent, plausible-looking wrongness the tutorial warns about in its own side-effects section. bench.py now checks the return code, surfaces the captured stderr, and refuses to run at all below 3.15. Also note in the README which files raise on purpose, and that a pyenv build needs Tk headers or cli_eager.py won't start. --- python315-lazy-imports/README.md | 14 ++++++++++++++ python315-lazy-imports/report_cli/bench.py | 16 ++++++++++++++-- uv.lock | 3 +++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 uv.lock diff --git a/python315-lazy-imports/README.md b/python315-lazy-imports/README.md index 4882f051e4..a1c8ad649d 100644 --- a/python315-lazy-imports/README.md +++ b/python315-lazy-imports/README.md @@ -42,6 +42,20 @@ Each subfolder of `circular/` is self-contained. Run `python main.py` from insid - `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/report_cli/bench.py b/python315-lazy-imports/report_cli/bench.py index 9f59ac9d11..c7e040e20e 100644 --- a/python315-lazy-imports/report_cli/bench.py +++ b/python315-lazy-imports/report_cli/bench.py @@ -14,10 +14,22 @@ 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() - subprocess.run(command, capture_output=True, check=False) - return time.perf_counter() - start + 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: 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"