Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions python315-frozendict/README.md
Original file line number Diff line number Diff line change
@@ -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/).
12 changes: 12 additions & 0 deletions python315-frozendict/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from functools import cache


@cache
def render_report(options):
print(f"computing report for {options}")
return f"<report {sorted(options.items())}>"


print(render_report(frozendict(theme="dark", rows=50)))
print(render_report(frozendict(rows=50, theme="dark")))
print(render_report.cache_info())
7 changes: 7 additions & 0 deletions python315-frozendict/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ROLE_PERMISSIONS = frozendict(
viewer=frozenset({"read"}),
editor=frozenset({"read", "write"}),
admin=frozenset({"read", "write", "delete", "manage_users"}),
)

print(ROLE_PERMISSIONS)
49 changes: 49 additions & 0 deletions python315-frozendict/dedupe_csv.py
Original file line number Diff line number Diff line change
@@ -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()
21 changes: 21 additions & 0 deletions python315-frozendict/events.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions python315-frozendict/exposure.py
Original file line number Diff line number Diff line change
@@ -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")
39 changes: 39 additions & 0 deletions python315-frozendict/memoize.py
Original file line number Diff line number Diff line change
@@ -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"<report {sorted(options.items())}>"


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()
16 changes: 16 additions & 0 deletions python315-frozendict/planets.py
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 41 additions & 0 deletions python315-frozendict/safe_defaults.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 9 additions & 0 deletions python315-frozendict/settings.py
Original file line number Diff line number Diff line change
@@ -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()))
Loading