Skip to content

Commit 2798d86

Browse files
Technologicatclaude
andcommitted
llist: FrozenAttributeError shim, raised by cons
New `FrozenAttributeError` multiply-inherits from `TypeError` (legacy unpythonic <= 2.x base) and `dataclasses.FrozenInstanceError` (stdlib convention since Python 3.7, itself an `AttributeError`). All three `except` clauses catch it, so existing user code keeps working *and* new code can use the standard idiom. This closes the part of #35 that was previously held back as #102 (the TypeError → FrozenInstanceError swap). The shim provides stdlib alignment now without breaking 2.x compatibility. The TypeError base will be dropped in 3.0.0 (issue #102 retained for that step). CHANGELOG: New entry for the shim, Fixed entry updated to reflect the new exception type. Brief updated to record the resolution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 770adc1 commit 2798d86

4 files changed

Lines changed: 40 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
- `unpythonic.dialects.bf`: a dialect that accepts a brainfuck program in place of Python source. Compiles `bf` to Python and runs it, or — via `bf_compile(src)` — returns the generated Python as a string for inspection. The tape is a `defaultdict[int, int]` subclass with 8-bit wrapping cells; comments in the `bf` source are preserved as Python comments; a `reset` line clears the tape between programs.
1010
- Demonstrates the `mcpyrate` `Dialect.transform_source` hook (full-module source-to-source transformer), which the other dialects in this package do not use.
1111
- Uses the new `mcpyrate.dialects.split_at_dialectimport` helper (requires `mcpyrate >= 4.1.0`), which correctly handles the case where the bf dialect-import shares a `from X import dialects, A, B` line with other dialects.
12+
- `unpythonic.llist.FrozenAttributeError`: compatibility shim that multiply-inherits from `TypeError` (legacy unpythonic <= 2.x) and `dataclasses.FrozenInstanceError` (Python 3.7+ stdlib convention). Raised by `cons` on attribute write/delete attempts. Either `except TypeError` or `except FrozenInstanceError` catches it. The `TypeError` base will be dropped in 3.0.0; new code should catch `FrozenInstanceError` (or `AttributeError`).
1213

1314
**Fixed**:
1415

1516
- `unpythonic.misc.callsite_filename`: walks the call stack via `sys._getframe` instead of `inspect.stack()`. Latent PyPy-3.11 / macOS / Windows bug: `inspect.stack()` reads source context around `f_lineno` for every frame, and on those targets at least one frame reachable from a `test[]` invocation reports `f_lineno = None`, which raises `TypeError` from `inspect.getframeinfo`. The new path reads only `f_code.co_filename`. CPython unaffected; PyPy on Linux unaffected.
16-
- `unpythonic.llist.cons`: `__delattr__` now raises `TypeError`. Latent bug: `del c.car` previously worked and corrupted the cell; only `__setattr__` was intercepted. The error message for `__setattr__` also now correctly says "attribute" (not "item") assignment.
17+
- `unpythonic.llist.cons`: `__delattr__` now raises `FrozenAttributeError` (catchable as `TypeError`, `FrozenInstanceError`, or `AttributeError`). Latent bug: `del c.car` previously worked and corrupted the cell; only `__setattr__` was intercepted. The error message for `__setattr__` also now correctly says "attribute" (not "item") assignment.
1718
- `unpythonic.assignonce`: `del e.foo` on a defined name now raises `AttributeError`. Latent bug: the assign-once contract could be bypassed via `del e.foo; e.foo = new_value`, since `__delattr__` was inherited unrestricted from `env`. Use `e.set("foo", value)` for explicit rebinding instead.
1819

1920
**Changed**:

briefs/2.2.0-remaining-issues.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,12 @@ to 3.0.0 and remains the tracking ticket; no new issue needed.
128128
`e._env = ...` is now rejected. Test added.
129129
- `frozendict` docstring clarifies that "frozen" refers to the mapping,
130130
not instance attributes.
131-
- **Held back**: `TypeError``dataclasses.FrozenInstanceError` swap
132-
for `cons` mutation errors. `FrozenInstanceError` subclasses
133-
`AttributeError`, which would break user code catching `TypeError`.
134-
Spun off as **#102** against the 3.0.0 milestone.
131+
- Stdlib alignment achieved via shim: `cons` now raises
132+
`FrozenAttributeError`, which multiply-inherits from `TypeError`
133+
(legacy) and `dataclasses.FrozenInstanceError` (stdlib convention).
134+
Either catch path works. `TypeError` base scheduled for removal
135+
in 3.0.0 (issue **#102** still tracks that step; closed for now
136+
with the shim resolution).
135137

136138
## #80 — Document multi-shot generators
137139

unpythonic/llist.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from abc import ABCMeta, abstractmethod
88
from collections.abc import Callable, Generator, Iterable, Iterator
9+
from dataclasses import FrozenInstanceError
910
from itertools import zip_longest
1011
from typing import Any
1112

@@ -18,7 +19,8 @@
1819
_fill = gensym("fill")
1920

2021
# explicit list better for tooling support
21-
_exports = ["cons", "nil",
22+
_exports = ["FrozenAttributeError",
23+
"cons", "nil",
2224
"LinkedListIterator", "LinkedListOrCellIterator", "TailIterator",
2325
"BinaryTreeIterator", "ConsIterator",
2426
"car", "cdr",
@@ -37,6 +39,20 @@
3739
#_exports.extend(_c4r)
3840
__all__ = _exports
3941

42+
class FrozenAttributeError(TypeError, FrozenInstanceError):
43+
"""Raised on a write/delete attempt against a frozen-instance type.
44+
45+
Multiply-inherits from `TypeError` (the legacy unpythonic <= 2.x base)
46+
and `dataclasses.FrozenInstanceError` (the standard-library convention
47+
since Python 3.7, itself a subclass of `AttributeError`). Either
48+
`except` clause catches it.
49+
50+
Compatibility shim: lets unpythonic align with the stdlib idiom
51+
without breaking user code that catches `TypeError`. The `TypeError`
52+
base will be dropped in 3.0.0 (see issue #35), at which point this
53+
class becomes a plain `FrozenInstanceError` and likely goes away.
54+
"""
55+
4056
class Nil(Singleton):
4157
"""The empty linked list. Singleton."""
4258
# support the iterator protocol so we can say tuple(nil) --> ()
@@ -220,9 +236,9 @@ def __init__(self, v1: Any, v2: Any) -> None:
220236
object.__setattr__(self, "car", v1)
221237
object.__setattr__(self, "cdr", v2)
222238
def __setattr__(self, k: str, v: Any) -> None:
223-
raise TypeError(f"'cons' object does not support attribute assignment; tried to set {k!r}")
239+
raise FrozenAttributeError(f"'cons' object does not support attribute assignment; tried to set {k!r}")
224240
def __delattr__(self, k: str) -> None:
225-
raise TypeError(f"'cons' object does not support attribute deletion; tried to delete {k!r}")
241+
raise FrozenAttributeError(f"'cons' object does not support attribute deletion; tried to delete {k!r}")
226242
def __iter__(self) -> LinkedListOrCellIterator:
227243
"""Return iterator with default iteration scheme: single cell or list."""
228244
return LinkedListOrCellIterator(self)

unpythonic/tests/test_llist.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55

66
from pickle import dumps, loads
77

8-
from ..llist import (cons, car, cdr, nil, ll, llist,
8+
from dataclasses import FrozenInstanceError
9+
10+
from ..llist import (FrozenAttributeError,
11+
cons, car, cdr, nil, ll, llist,
912
caar, cdar, cadr, cddr, caddr, cdddr,
1013
member, lreverse, lappend, lzip,
1114
BinaryTreeIterator, JackOfAllTradesIterator,
@@ -28,6 +31,15 @@ def runtests():
2831
del c.car
2932
with test_raises[TypeError, "cons cells should be immutable (no new attributes)"]:
3033
c.extra = "nope"
34+
# The exception is `FrozenAttributeError`, a shim that inherits from both
35+
# `TypeError` (legacy unpythonic <= 2.x) and `dataclasses.FrozenInstanceError`
36+
# (stdlib convention). All three `except` clauses catch.
37+
with test_raises[FrozenAttributeError, "should be a FrozenAttributeError"]:
38+
cons(1, 2).car = 3
39+
with test_raises[FrozenInstanceError, "should also be catchable as the stdlib FrozenInstanceError"]:
40+
cons(1, 2).car = 3
41+
with test_raises[AttributeError, "should also be catchable as AttributeError (via FrozenInstanceError)"]:
42+
cons(1, 2).car = 3
3143

3244
test[the[c == c]]
3345
test[the[cons(1, 2) == cons(1, 2)]]

0 commit comments

Comments
 (0)