Skip to content

Commit 1f752c6

Browse files
bzaczynskiclaude
andauthored
Materials for Python 3.15 Preview: frozendict (#812)
* Materials for Python 3.15 Preview: frozendict * Ignore syntax specific to Python 3.15 * 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) <noreply@anthropic.com> * Fix formatting in pyproject.toml --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d867710 commit 1f752c6

11 files changed

Lines changed: 219 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ exclude = [
77
"migrations",
88
"how-to-indent-in-python/sample_code.py",
99
"agents-md/run1_main.py",
10-
"ai-benchmark",
11-
"python315-lazy-imports"
10+
"python315-frozendict/",
11+
"python315-lazy-imports",
12+
"ai-benchmark"
1213
]
1314

1415
[tool.ruff.lint]

python315-frozendict/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Python 3.15 Preview: `frozendict`
2+
3+
Supporting code for the Real Python tutorial [Python 3.15 Preview: `frozendict`](https://realpython.com/python315-frozendict/).

python315-frozendict/cache.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from functools import cache
2+
3+
4+
@cache
5+
def render_report(options):
6+
print(f"computing report for {options}")
7+
return f"<report {sorted(options.items())}>"
8+
9+
10+
print(render_report(frozendict(theme="dark", rows=50)))
11+
print(render_report(frozendict(rows=50, theme="dark")))
12+
print(render_report.cache_info())

python315-frozendict/const.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
ROLE_PERMISSIONS = frozendict(
2+
viewer=frozenset({"read"}),
3+
editor=frozenset({"read", "write"}),
4+
admin=frozenset({"read", "write", "delete", "manage_users"}),
5+
)
6+
7+
print(ROLE_PERMISSIONS)

python315-frozendict/dedupe_csv.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Collapse duplicate CSV rows by putting them in a set of frozendicts.
2+
3+
Each row that csv.DictReader yields is a dict, which is unhashable and so can't
4+
go in a set. Freezing each row makes the whole deduplication one expression.
5+
6+
Watch orders 1002 and 1003: they survive as two entries each, but for different
7+
reasons. The 1002 rows differ only in fetched_at, while the 1003 rows also
8+
differ in amount. Ignoring fetched_at therefore merges 1002 and leaves 1003
9+
split. That's a lesson about picking the fields that define identity, not a
10+
bug.
11+
12+
Run with Python 3.15 or later:
13+
14+
python dedupe_csv.py
15+
"""
16+
17+
import csv
18+
import io
19+
from operator import itemgetter
20+
21+
ORDERS = """\
22+
order_id,customer,amount,fetched_at
23+
1001,Ada,250.00,2026-08-13T09:00:00
24+
1002,Grace,80.50,2026-08-13T09:00:00
25+
1001,Ada,250.00,2026-08-13T09:00:00
26+
1003,Linus,42.00,2026-08-13T09:00:00
27+
1002,Grace,80.50,2026-08-13T09:05:00
28+
1003,Linus,99.00,2026-08-13T09:05:00
29+
"""
30+
31+
32+
def main():
33+
rows = list(csv.DictReader(io.StringIO(ORDERS)))
34+
unique_rows = {frozendict(row) for row in rows}
35+
36+
print(
37+
f"Read {len(rows)} rows, kept {len(unique_rows)} after deduplication."
38+
)
39+
for row in sorted(unique_rows, key=itemgetter("order_id", "fetched_at")):
40+
print(
41+
f" {row['order_id']} {row['customer']:<6} "
42+
f"{row['amount']:>7} {row['fetched_at']}"
43+
)
44+
45+
identity = itemgetter("order_id", "customer", "amount")
46+
by_order = {
47+
frozendict(zip(("order_id", "customer", "amount"), identity(row)))
48+
for row in rows
49+
}
50+
print(f"Ignoring fetched_at leaves {len(by_order)} orders.")
51+
52+
53+
if __name__ == "__main__":
54+
main()

python315-frozendict/events.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from collections import Counter
2+
3+
stats = Counter()
4+
5+
6+
def record_event(**labels):
7+
stats[frozendict(labels)] += 1
8+
9+
10+
record_event(endpoint="/login", outcome="failure", reason="bad_password")
11+
record_event(reason="bad_password", endpoint="/login", outcome="failure")
12+
record_event(endpoint="/login", outcome="success")
13+
record_event(outcome="success", endpoint="/checkout")
14+
15+
num_failures = sum(
16+
count
17+
for labels, count in stats.items()
18+
if labels.get("outcome") == "failure"
19+
)
20+
21+
print("Number of failed outcomes:", num_failures)

python315-frozendict/exposure.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from decimal import Decimal
2+
3+
4+
class BankAccount:
5+
def __init__(self):
6+
self._balances = {"USD": Decimal("0"), "EUR": Decimal("0")}
7+
8+
@property
9+
def balances(self):
10+
return frozendict(self._balances)
11+
12+
13+
account = BankAccount()
14+
account.balances["USD"] = Decimal("1_000_000")

python315-frozendict/memoize.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Cache a function that takes a mapping argument.
2+
3+
A plain dict is unhashable, so @cache rejects it. A frozendict hashes, so the
4+
same call signature becomes cacheable.
5+
6+
Run with Python 3.15 or later:
7+
8+
python memoize.py
9+
"""
10+
11+
from functools import cache
12+
13+
14+
@cache
15+
def render_report(options):
16+
print(f"computing report for {options}")
17+
return f"<report {sorted(options.items())}>"
18+
19+
20+
def main():
21+
settings = frozendict(theme="dark", rows=50)
22+
23+
print("First call, nothing cached yet:")
24+
render_report(settings)
25+
26+
print("Second call with an equal frozendict:")
27+
render_report(frozendict(rows=50, theme="dark"))
28+
29+
print(f"Cache statistics: {render_report.cache_info()}")
30+
31+
print("The same call with a plain dict:")
32+
try:
33+
render_report({"theme": "dark", "rows": 50})
34+
except TypeError as error:
35+
print(f" TypeError: {error}")
36+
37+
38+
if __name__ == "__main__":
39+
main()

python315-frozendict/planets.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
planets = frozendict(
2+
{
3+
"Mercury": 57_910_000,
4+
"Venus": 108_200_000,
5+
"Earth": 149_600_000,
6+
"Mars": 227_900_000,
7+
"Jupiter": 778_500_000,
8+
"Saturn": 1_434_000_000,
9+
"Uranus": 2_871_000_000,
10+
"Neptune": 4_495_000_000,
11+
}
12+
)
13+
14+
for name, distance in planets.items():
15+
scaled = round(60 * distance / max(planets.values()))
16+
print(" " * scaled + "\N{RINGED PLANET}", name)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Show the mutable default argument bug, then fix it with a frozendict.
2+
3+
The buggy version keeps one dict alive across every call, so an auth token
4+
supplied to one host leaks into an unrelated request. The frozendict version
5+
builds a fresh mapping each time.
6+
7+
Run with Python 3.15 or later:
8+
9+
python safe_defaults.py
10+
"""
11+
12+
13+
def fetch_buggy(url, headers={}, token=None):
14+
headers.setdefault("User-Agent", "acme/1.0")
15+
if token:
16+
headers["Authorization"] = f"Bearer {token}"
17+
print(f"GET {url}")
18+
print(f" {headers}")
19+
20+
21+
def fetch(url, headers=frozendict(), token=None):
22+
headers = frozendict({"User-Agent": "acme/1.0"}) | headers
23+
if token:
24+
headers |= {"Authorization": f"Bearer {token}"}
25+
print(f"GET {url}")
26+
print(f" {headers}")
27+
28+
29+
def main():
30+
print("With a mutable default argument:")
31+
fetch_buggy("https://acme.test/me", token="admin-key")
32+
fetch_buggy("https://partner.example/ping")
33+
34+
print()
35+
print("With a frozendict default argument:")
36+
fetch("https://acme.test/me", token="admin-key")
37+
fetch("https://partner.example/ping")
38+
39+
40+
if __name__ == "__main__":
41+
main()

0 commit comments

Comments
 (0)