From 4cde4a4dceb2fcb1d06375451a5303dd4536fcae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Zaczy=C5=84ski?= Date: Sun, 30 Aug 2026 12:42:23 +0200 Subject: [PATCH 1/2] Materials for Python 3.15 Preview: frozendict --- python315-frozendict/README.md | 3 ++ python315-frozendict/cache.py | 12 +++++++ python315-frozendict/const.py | 7 ++++ python315-frozendict/dedupe_csv.py | 49 +++++++++++++++++++++++++++ python315-frozendict/events.py | 21 ++++++++++++ python315-frozendict/exposure.py | 14 ++++++++ python315-frozendict/memoize.py | 39 +++++++++++++++++++++ python315-frozendict/planets.py | 16 +++++++++ python315-frozendict/safe_defaults.py | 41 ++++++++++++++++++++++ python315-frozendict/settings.py | 9 +++++ 10 files changed, 211 insertions(+) create mode 100644 python315-frozendict/README.md create mode 100644 python315-frozendict/cache.py create mode 100644 python315-frozendict/const.py create mode 100644 python315-frozendict/dedupe_csv.py create mode 100644 python315-frozendict/events.py create mode 100644 python315-frozendict/exposure.py create mode 100644 python315-frozendict/memoize.py create mode 100644 python315-frozendict/planets.py create mode 100644 python315-frozendict/safe_defaults.py create mode 100644 python315-frozendict/settings.py diff --git a/python315-frozendict/README.md b/python315-frozendict/README.md new file mode 100644 index 0000000000..fa7f768c18 --- /dev/null +++ b/python315-frozendict/README.md @@ -0,0 +1,3 @@ +# Python 3.15 Preview: `frozendict` + +Supporting code for the Real Python tutorial [Python 3.15 Preview: `frozendict`](https://realpython.com/python315-frozendict/). diff --git a/python315-frozendict/cache.py b/python315-frozendict/cache.py new file mode 100644 index 0000000000..c6fc359139 --- /dev/null +++ b/python315-frozendict/cache.py @@ -0,0 +1,12 @@ +from functools import cache + + +@cache +def render_report(options): + print(f"computing report for {options}") + return f"" + + +print(render_report(frozendict(theme="dark", rows=50))) +print(render_report(frozendict(rows=50, theme="dark"))) +print(render_report.cache_info()) diff --git a/python315-frozendict/const.py b/python315-frozendict/const.py new file mode 100644 index 0000000000..586e6c6583 --- /dev/null +++ b/python315-frozendict/const.py @@ -0,0 +1,7 @@ +ROLE_PERMISSIONS = frozendict( + viewer=frozenset({"read"}), + editor=frozenset({"read", "write"}), + admin=frozenset({"read", "write", "delete", "manage_users"}), +) + +print(ROLE_PERMISSIONS) diff --git a/python315-frozendict/dedupe_csv.py b/python315-frozendict/dedupe_csv.py new file mode 100644 index 0000000000..b5a2f5487b --- /dev/null +++ b/python315-frozendict/dedupe_csv.py @@ -0,0 +1,49 @@ +"""Collapse duplicate CSV rows by putting them in a set of frozendicts. + +Each row that csv.DictReader yields is a dict, which is unhashable and so can't +go in a set. Freezing each row makes the whole deduplication one expression. + +Watch the orders 1002 and 1003: they survive as two entries each, because their +fetched_at timestamps differ. That's a lesson about picking the fields that +define identity, not a bug. + +Run with Python 3.15 or later: + + python dedupe_csv.py +""" + +import csv +import io +from operator import itemgetter + +ORDERS = """\ +order_id,customer,amount,fetched_at +1001,Ada,250.00,2026-08-13T09:00:00 +1002,Grace,80.50,2026-08-13T09:00:00 +1001,Ada,250.00,2026-08-13T09:00:00 +1003,Linus,42.00,2026-08-13T09:00:00 +1002,Grace,80.50,2026-08-13T09:05:00 +1003,Linus,99.00,2026-08-13T09:05:00 +""" + + +def main(): + rows = list(csv.DictReader(io.StringIO(ORDERS))) + unique_rows = {frozendict(row) for row in rows} + + print( + f"Read {len(rows)} rows, kept {len(unique_rows)} after deduplication." + ) + for row in sorted(unique_rows, key=itemgetter("order_id", "fetched_at")): + print(f" {row['order_id']} {row['customer']:<6} {row['fetched_at']}") + + identity = itemgetter("order_id", "customer", "amount") + by_order = { + frozendict(zip(("order_id", "customer", "amount"), identity(row))) + for row in rows + } + print(f"Ignoring fetched_at leaves {len(by_order)} orders.") + + +if __name__ == "__main__": + main() diff --git a/python315-frozendict/events.py b/python315-frozendict/events.py new file mode 100644 index 0000000000..ee063eb304 --- /dev/null +++ b/python315-frozendict/events.py @@ -0,0 +1,21 @@ +from collections import Counter + +stats = Counter() + + +def record_event(**labels): + stats[frozendict(labels)] += 1 + + +record_event(endpoint="/login", outcome="failure", reason="bad_password") +record_event(reason="bad_password", endpoint="/login", outcome="failure") +record_event(endpoint="/login", outcome="success") +record_event(outcome="success", endpoint="/checkout") + +num_failures = sum( + count + for labels, count in stats.items() + if labels.get("outcome") == "failure" +) + +print("Number of failed outcomes:", num_failures) diff --git a/python315-frozendict/exposure.py b/python315-frozendict/exposure.py new file mode 100644 index 0000000000..0df8dbf1ba --- /dev/null +++ b/python315-frozendict/exposure.py @@ -0,0 +1,14 @@ +from decimal import Decimal + + +class BankAccount: + def __init__(self): + self._balances = {"USD": Decimal("0"), "EUR": Decimal("0")} + + @property + def balances(self): + return frozendict(self._balances) + + +account = BankAccount() +account.balances["USD"] = Decimal("1_000_000") diff --git a/python315-frozendict/memoize.py b/python315-frozendict/memoize.py new file mode 100644 index 0000000000..449fbcdcd1 --- /dev/null +++ b/python315-frozendict/memoize.py @@ -0,0 +1,39 @@ +"""Cache a function that takes a mapping argument. + +A plain dict is unhashable, so @cache rejects it. A frozendict hashes, so the +same call signature becomes cacheable. + +Run with Python 3.15 or later: + + python memoize.py +""" + +from functools import cache + + +@cache +def render_report(options): + print(f"computing report for {options}") + return f"" + + +def main(): + settings = frozendict(theme="dark", rows=50) + + print("First call, nothing cached yet:") + render_report(settings) + + print("Second call with an equal frozendict:") + render_report(frozendict(rows=50, theme="dark")) + + print(f"Cache statistics: {render_report.cache_info()}") + + print("The same call with a plain dict:") + try: + render_report({"theme": "dark", "rows": 50}) + except TypeError as error: + print(f" TypeError: {error}") + + +if __name__ == "__main__": + main() diff --git a/python315-frozendict/planets.py b/python315-frozendict/planets.py new file mode 100644 index 0000000000..0e17c9a178 --- /dev/null +++ b/python315-frozendict/planets.py @@ -0,0 +1,16 @@ +planets = frozendict( + { + "Mercury": 57_910_000, + "Venus": 108_200_000, + "Earth": 149_600_000, + "Mars": 227_900_000, + "Jupiter": 778_500_000, + "Saturn": 1_434_000_000, + "Uranus": 2_871_000_000, + "Neptune": 4_495_000_000, + } +) + +for name, distance in planets.items(): + scaled = round(60 * distance / max(planets.values())) + print(" " * scaled + "\N{RINGED PLANET}", name) diff --git a/python315-frozendict/safe_defaults.py b/python315-frozendict/safe_defaults.py new file mode 100644 index 0000000000..a4b0b9513c --- /dev/null +++ b/python315-frozendict/safe_defaults.py @@ -0,0 +1,41 @@ +"""Show the mutable default argument bug, then fix it with a frozendict. + +The buggy version keeps one dict alive across every call, so an auth token +supplied to one host leaks into an unrelated request. The frozendict version +builds a fresh mapping each time. + +Run with Python 3.15 or later: + + python safe_defaults.py +""" + + +def fetch_buggy(url, headers={}, token=None): + headers.setdefault("User-Agent", "acme/1.0") + if token: + headers["Authorization"] = f"Bearer {token}" + print(f"GET {url}") + print(f" {headers}") + + +def fetch(url, headers=frozendict(), token=None): + headers = frozendict({"User-Agent": "acme/1.0"}) | headers + if token: + headers |= {"Authorization": f"Bearer {token}"} + print(f"GET {url}") + print(f" {headers}") + + +def main(): + print("With a mutable default argument:") + fetch_buggy("https://acme.test/me", token="admin-key") + fetch_buggy("https://partner.example/ping") + + print() + print("With a frozendict default argument:") + fetch("https://acme.test/me", token="admin-key") + fetch("https://partner.example/ping") + + +if __name__ == "__main__": + main() diff --git a/python315-frozendict/settings.py b/python315-frozendict/settings.py new file mode 100644 index 0000000000..649ea9f6da --- /dev/null +++ b/python315-frozendict/settings.py @@ -0,0 +1,9 @@ +from functools import reduce +from operator import or_ + +global_settings = frozendict(theme="light", editor="vim", telemetry=True) +user_settings = frozendict(theme="dark") +project_settings = frozendict(editor="code", telemetry=False) + +layers = [global_settings, user_settings, project_settings] +print(reduce(or_, layers, frozendict())) From 0f1bf42b4b79ab9d15647457183a3bf1c4a02625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Zaczy=C5=84ski?= Date: Sun, 30 Aug 2026 12:55:48 +0200 Subject: [PATCH 2/2] Ignore syntax specific to Python 3.15 --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e3137153e2..63a834fcf2 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-frozendict/" ] [tool.ruff.lint]