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/4] 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/4] 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] From 123228dd6327d8575f4849486db971acc560c81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Zaczy=C5=84ski?= Date: Fri, 18 Sep 2026 13:33:56 +0000 Subject: [PATCH 3/4] Show amount in dedupe output and correct the 1003 explanation The docstring said orders 1002 and 1003 both survive twice because their fetched_at timestamps differ. That only holds for 1002. The 1003 rows also disagree on amount (42.00 vs 99.00), so ignoring fetched_at merges 1002 but leaves 1003 split, which is what the script already prints. The output omitted the amount column, so the field that explains the result was the one field readers couldn't see. Matches the tutorial's output block. Co-Authored-By: Claude Opus 5 (1M context) --- python315-frozendict/dedupe_csv.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/python315-frozendict/dedupe_csv.py b/python315-frozendict/dedupe_csv.py index b5a2f5487b..e528cdb3e7 100644 --- a/python315-frozendict/dedupe_csv.py +++ b/python315-frozendict/dedupe_csv.py @@ -3,9 +3,11 @@ 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. +Watch orders 1002 and 1003: they survive as two entries each, but for different +reasons. The 1002 rows differ only in fetched_at, while the 1003 rows also +differ in amount. Ignoring fetched_at therefore merges 1002 and leaves 1003 +split. That's a lesson about picking the fields that define identity, not a +bug. Run with Python 3.15 or later: @@ -35,7 +37,10 @@ def main(): 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']}") + print( + f" {row['order_id']} {row['customer']:<6} " + f"{row['amount']:>7} {row['fetched_at']}" + ) identity = itemgetter("order_id", "customer", "amount") by_order = { From 85613be2ff9b55fc1d4fb4ac9ab1d793781b4c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Zaczy=C5=84ski?= Date: Fri, 18 Sep 2026 15:39:27 +0200 Subject: [PATCH 4/4] Fix formatting in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 90d53c0c80..127679e962 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ exclude = [ "migrations", "how-to-indent-in-python/sample_code.py", "agents-md/run1_main.py", - "python315-frozendict/" + "python315-frozendict/", "python315-lazy-imports", "ai-benchmark" ]