CodeSampleX

샘플

attrs 26.1.0: Decide whether attrs still buys anything over dataclasses on modern Python, and what changes in each direction

검증된 샘플 — pypi attrs 26.1.0: Decide whether attrs still buys anything over dataclasses on modern Python, and what changes in each direction. python 3.12 …

sha256:db459e2a5619e5bc96877e0fc6aefd6229be3342cc28685624eacd9e170bf4b7

이 네트워크가 제공하는 것은 하나입니다. 빌드되는 샘플. 샌드박스에서 돌리고 서명된 영수증을 보관합니다. 등급을 매기지 않고 무엇도 보증하지 않습니다 — 같은 코드가 당신 환경에서 빌드되는지는 측정한 적이 없습니다. 통과한 계약 영수증을 낸 서로 다른 서명 키의 수입니다. 하나면 작성자 혼자이고, 둘 이상이면 다른 사람도 빌드했다는 뜻입니다. 키는 스스로 만드는 것이고 뒤에 등록된 신원이 없으므로, 세는 것은 사람이 아니라 키입니다. MIT-0

실행 증거

선언된 환경과 서명된 실행을 분리해 두었습니다. 이 샘플이 무엇을 어디서 실행했는지 그대로 볼 수 있습니다.

증거 기준
서명된 컨트랙트 통과
검증 영수증
2
빌드한 서명 키
2
선언된 환경 python 3.12 linux x64 python 3.12 python pip

검증 실행 환경

환경 컨트랙트 단계 실행일
python 3.12 · linux alpine/x64 · docker ed25519:a2ec939a4c60e243 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · pypi@1
2026-08-14
python 3.12 · linux alpine/x64 · docker ed25519:d91480838ac982c9 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · pypi@1
2026-08-18

케이스

MIGRATION
목표
Decide whether attrs still buys anything over dataclasses on modern Python, and what changes in each direction
패키지
심벌
  • attrs.define
  • attrs.field
  • attrs.frozen
  • attrs.evolve
  • attrs.validators.instance_of
  • attrs.validators.disabled
  • attrs.setters.NO_OP
  • attrs.asdict
  • attrs.exceptions.FrozenInstanceError
  • attr.s
  • dataclasses.dataclass
  • dataclasses.replace
  • dataclasses.FrozenInstanceError
환경
python 3.12
생성일
2026-08-14T12:07:55Z

컨트랙트

  1. assert neither library enforces an annotation: a dataclass stores a str in an int field and so does an @define class with no validator
  2. assert an attrs validator runs at __init__, with instance_of raising TypeError and gt raising ValueError, and that ge, lt and le raise ValueError too
  3. assert the converter runs before the validator, so converter=int and instance_of(int) coexist and "9000" is stored as 9000
  4. assert @define runs converter and validator again on assignment, where the dataclass keeps whatever was assigned
  5. assert __post_init__ is not the equivalent hook: it runs at __init__ and again through dataclasses.replace, and never on assignment
  6. assert on_setattr=NO_OP keeps the __init__ conversion and drops the assignment one, so the field holds either type
  7. assert attrs.validators.disabled() switches off the validators of every class in the process, so a validator is not a boundary
  8. assert @define is slotted, so an undeclared attribute raises AttributeError and has no __dict__, while the dataclass accepts it
  9. assert @dataclass(slots=True) exists and that both decorators return a new class object, not the one that was decorated
  10. assert attrs puts __weakref__ in __slots__ and @dataclass(slots=True) does not, so weakref.ref works on the attrs class and raises TypeError on the dataclass
  11. assert the slotted dataclass breaks zero-argument super() on Python 3.12 with TypeError, and attrs' slotted subclass with the same body does not
  12. assert the cause is the __class__ closure cell, which attrs rebinds to the class it returns and the stdlib leaves on the discarded original, and that super(Child, self) works around it
  13. assert attrs' FrozenInstanceError reports module attr.exceptions and is a different class from dataclasses.FrozenInstanceError, with neither a subclass of the other
  14. assert catching dataclasses.FrozenInstanceError does not catch the attrs error and catching the attrs error does not catch the dataclass one
  15. assert both frozen errors subclass AttributeError, the narrowest single except clause that covers both
  16. assert a mandatory field after a defaulted one is refused at class definition by both, ValueError from attrs and TypeError from dataclasses
  17. assert kw_only=True lifts that rule in both libraries
  18. assert an attrs field named _token takes the init keyword token and refuses _token, while the dataclass field does the reverse
  19. assert attrs.evolve keys on the init parameter name and re-runs the validator, while dataclasses.replace keys on the field name and runs no validator of its own
  20. assert an init=False field is refused by replace with ValueError naming the field and by evolve with TypeError for an unknown keyword
  21. assert the libraries do not recognise each other: is_dataclass and attrs.has are False, dataclasses.fields raises TypeError and attrs.fields raises NotAnAttrsClassError
  22. assert a fresh interpreter run under -W error imports attr with no warning at all, and attr.__version__ matches the installed attrs
  23. assert the namespaces moved one way only: define, field, frozen, evolve and fields are the same objects on attr and attrs, while s, ib, attrib and attributes exist only on attr
  24. assert a shared name is not always a shared object: attr.asdict is not attrs.asdict and only the old one takes retain_collection_types, and attr.exceptions is not attrs.exceptions
  25. assert an @attr.s class is unslotted and converts at __init__ only, leaving object.__setattr__ in place where @define installs its own

파일

  • csx.json
  • requirements.txt
  • src/__init__.py
  • src/models.py
  • test/contract.py

소스 아티팩트 내려받기 (tar.gz)

소스

csx.json
{"case":{"caseId":"case:sha256:7d6291b9cfd14b9a13421b8e3e3f420c2c22ff96fc6f483576df4a7bdd847f2d","constraints":{"runtime":"python"},"contract":["assert neither library enforces an annotation: a dataclass stores a str in an int field and so does an @define class with no validator","assert an attrs validator runs at __init__, with instance_of raising TypeError and gt raising ValueError, and that ge, lt and le raise ValueError too","assert the converter runs before the validator, so converter=int and instance_of(int) coexist and \"9000\" is stored as 9000","assert @define runs converter and validator again on assignment, where the dataclass keeps whatever was assigned","assert __post_init__ is not the equivalent hook: it runs at __init__ and again through dataclasses.replace, and never on assignment","assert on_setattr=NO_OP keeps the __init__ conversion and drops the assignment one, so the field holds either type","assert attrs.validators.disabled() switches off the validators of every class in the process, so a validator is not a boundary","assert @define is slotted, so an undeclared attribute raises AttributeError and has no __dict__, while the dataclass accepts it","assert @dataclass(slots=True) exists and that both decorators return a new class object, not the one that was decorated","assert attrs puts __weakref__ in __slots__ and @dataclass(slots=True) does not, so weakref.ref works on the attrs class and raises TypeError on the dataclass","assert the slotted dataclass breaks zero-argument super() on Python 3.12 with TypeError, and attrs' slotted subclass with the same body does not","assert the cause is the __class__ closure cell, which attrs rebinds to the class it returns and the stdlib leaves on the discarded original, and that super(Child, self) works around it","assert attrs' FrozenInstanceError reports module attr.exceptions and is a different class from dataclasses.FrozenInstanceError, with neither a subclass of the other","assert catching dataclasses.FrozenInstanceError does not catch the attrs error and catching the attrs error does not catch the dataclass one","assert both frozen errors subclass AttributeError, the narrowest single except clause that covers both","assert a mandatory field after a defaulted one is refused at class definition by both, ValueError from attrs and TypeError from dataclasses","assert kw_only=True lifts that rule in both libraries","assert an attrs field named _token takes the init keyword token and refuses _token, while the dataclass field does the reverse","assert attrs.evolve keys on the init parameter name and re-runs the validator, while dataclasses.replace keys on the field name and runs no validator of its own","assert an init=False field is refused by replace with ValueError naming the field and by evolve with TypeError for an unknown keyword","assert the libraries do not recognise each other: is_dataclass and attrs.has are False, dataclasses.fields raises TypeError and attrs.fields raises NotAnAttrsClassError","assert a fresh interpreter run under -W error imports attr with no warning at all, and attr.__version__ matches the installed attrs","assert the namespaces moved one way only: define, field, frozen, evolve and fields are the same objects on attr and attrs, while s, ib, attrib and attributes exist only on attr","assert a shared name is not always a shared object: attr.asdict is not attrs.asdict and only the old one takes retain_collection_types, and attr.exceptions is not attrs.exceptions","assert an @attr.s class is unslotted and converts at __init__ only, leaving object.__setattr__ in place where @define installs its own"],"goal":"Decide whether attrs still buys anything over dataclasses on modern Python, and what changes in each direction","kind":"MIGRATION","packages":["pkg:pypi/attrs@26.1.0"],"schemaVersion":1,"symbols":["attrs.define","attrs.field","attrs.frozen","attrs.evolve","attrs.validators.instance_of","attrs.validators.disabled","attrs.setters.NO_OP","attrs.asdict","attrs.exceptions.FrozenInstanceError","attr.s","dataclasses.dataclass","dataclasses.replace","dataclasses.FrozenInstanceError"]},"contractCommand":["python","test/contract.py"],"environment":{"arch":"x64","ecosystem":"pypi","executionContext":"python","language":"python","os":"linux","packageManager":"pip","runtime":"python","runtimeVersion":"3.12","schemaVersion":1},"license":"MIT-0","packages":["pkg:pypi/attrs@26.1.0"],"schemaVersion":1,"symbols":["attrs.define","attrs.field","attrs.frozen","attrs.evolve","attrs.validators.instance_of","attrs.validators.disabled","attrs.setters.NO_OP","attrs.asdict","attrs.exceptions.FrozenInstanceError","attr.s","dataclasses.dataclass","dataclasses.replace","dataclasses.FrozenInstanceError"],"verifierAdapter":"pypi@1"}
requirements.txt
attrs==26.1.0
src/__init__.py
src/models.py
"""attrs 26 next to the standard library dataclasses, on Python 3.12.

"Do I still need attrs?" has an answer that is smaller than the folklore and
sharper. dataclasses have absorbed most of the shape: keyword-only fields,
slots, frozen, replace. What they have not absorbed is the part that runs
code on your data. attrs has validators and converters, wired into __init__
AND into assignment; dataclasses have neither, and their only hook is a
__post_init__ you write by hand. That hook runs at __init__ and again when
replace() goes back through __init__, and never on assignment, which is the
one moment a converter would have earned its keep.

The correction that matters before any of this: attrs does not type-check
annotations either. `n: int` on an @define class is documentation, exactly as
it is on a dataclass, and `Unchecked("nope")` below stores the string. attrs
gives you a place to put the check and runs it at the two moments that
matter. Enforcing the annotation itself is what pydantic is for. If the
reason you are reaching for attrs is "so the types are real", neither of
these libraries is the answer.

The differences that bite during a migration, all measured on this image:

  - Slots. @define is slots=True, so an undeclared attribute is an
    AttributeError instead of a typo that persists. @dataclass is not, and
    @dataclass(slots=True) since 3.10 is — but not on the same terms. attrs
    puts __weakref__ in __slots__ and the stdlib does not unless you also
    pass weakref_slot=True, so a slotted dataclass cannot be the target of a
    weakref.ref(). See also the closure note below.
  - Namespaces. The move went one way only. `attr` gained every modern name,
    as the same object — attr.define is attrs.define — while `attrs` never
    gained the old ones, so a half-migrated file keeps working and only the
    reverse reach fails. What does not survive is the assumption that a
    shared name means a shared object: attr.asdict and
    attrs.asdict are two different functions with two different signatures,
    and attr.exceptions is not attrs.exceptions even though the exception
    classes inside them are identical.
  - Assignment. @define installs on_setattr = pipe(convert, validate). The
    converter that normalised __init__ runs again on `obj.port = "1234"`.
    Nothing in dataclasses does, and neither does the old @attr.s.
  - Frozen errors are unrelated classes. attrs raises the one whose module
    is attr.exceptions — the OLD namespace, even when imported from
    attrs.exceptions — and dataclasses raises dataclasses.FrozenInstanceError.
    Neither catches the other. Both derive from AttributeError, which is the
    narrowest single clause that covers a codebase holding both.
  - Private fields. attrs strips the leading underscore to build the
    __init__ parameter: field `_token` is `Session(token=...)`, and
    attrs.evolve keys on that parameter name too. dataclasses keep `_token`
    verbatim in both __init__ and replace(). This is the migration bug that
    survives a find-and-replace, because both spellings look right.
  - Ordering. Same rule, different exception: a mandatory field after a
    defaulted one is a ValueError from attrs and a TypeError from
    dataclasses, both at class-definition time. kw_only=True lifts it in
    both.

Measured, and it refuted the hypothesis this sample started with: on Python
3.12, @dataclass(slots=True) breaks zero-argument super(). Both decorators
have to build a NEW class object to add __slots__ — the returned class is not
the one you wrote — but attrs repairs the __class__ closure cell that methods
capture and the stdlib does not, so a method calling super() in a slotted
dataclass raises "TypeError: super(type, obj): obj must be an instance or
subtype of type". attrs' slotted subclass with the identical body works. If
you are adding slots=True to dataclasses that inherit from anything, that is
the failure to expect, and passing the class explicitly to super() is the
workaround.

One thing validators are not: a boundary. attrs.validators.disabled() turns
every validator in the process off, so a validator states an invariant for
the code that constructs the object, not a guarantee for code that receives
it.
"""

import dataclasses
from dataclasses import dataclass

import attr
import attrs
from attrs import define, field, frozen, validators


@define
class Listener:
    """The whole attrs case in one class: check at init, coerce on assignment.

    converter runs before validator, which is why an int-annotated field with
    validators.instance_of(int) still accepts "9000" — by the time the
    validator sees it, it is an int. Reverse that order and the pair is
    useless together.

    The two validators raise different builtin types: instance_of raises
    TypeError, the comparison validators (gt/ge/lt/le) raise ValueError. An
    `except ValueError` around construction catches half of them.
    """

    host: str = field(validator=validators.instance_of(str))
    port: int = field(
        default=8080,
        converter=int,
        validator=[validators.instance_of(int), validators.gt(0)],
    )


@dataclass
class DataclassListener:
    """The same fields with no place to put a check. Accepts anything."""

    host: str
    port: int = 8080


@define
class Unchecked:
    """@define with nothing to run: the annotation alone enforces no more here
    than it does on a dataclass, and this stores a str in an int field."""

    n: int = 0


@define(on_setattr=attrs.setters.NO_OP)
class QuietListener:
    """on_setattr is the switch people flip for speed, or to allow a plain
    __setattr__, and it silently takes the converter with it. __init__ still
    converts; assignment stops. Same class, two different notions of what
    `port` contains, depending on how it got there."""

    port: int = field(default=8080, converter=int)


@frozen
class FrozenListener:
    host: str = field(validator=validators.instance_of(str))


@dataclass(frozen=True)
class FrozenDataclassListener:
    host: str


@define
class Session:
    """A private field. The init parameter is `token`, not `_token`."""

    _token: str = field(validator=validators.instance_of(str))
    label: str = "anon"


@dataclass
class DataclassSession:
    """The same fields, where the init parameter really is `_token`."""

    _token: str
    label: str = "anon"


@define
class AttrsDerived:
    a: int = 0
    b: int = field(default=7, init=False)


@dataclass
class DataclassDerived:
    a: int = 0
    b: int = dataclasses.field(default=7, init=False)


@define(kw_only=True)
class KwListener:
    """kw_only is the escape hatch from the ordering rule, in both libraries:
    a mandatory field after a defaulted one is legal once nothing is
    positional."""

    host: str = "localhost"
    port: int


@dataclass(kw_only=True)
class KwDataclassListener:
    host: str = "localhost"
    port: int


class Base:
    def describe(self) -> str:
        return "base"


class _Mark:
    """Undecorated on purpose, so both decorators below can be applied to a
    class we still hold a reference to."""

    n: int = 0


SlottedDataclassMark = dataclass(slots=True)(_Mark)


class _OtherMark:
    n: int = 0


SlottedAttrsMark = define(_OtherMark)


def slotted_dataclass_subclass():
    """Defined inside a function so the methods carry a __class__ cell, which
    is what zero-argument super() reads."""

    @dataclass(slots=True)
    class Child(Base):
        n: int = 0

        def describe(self) -> str:
            return "child of " + super().describe()

    return Child


def slotted_attrs_subclass():
    @define
    class Child(Base):
        n: int = 0

        def describe(self) -> str:
            return "child of " + super().describe()

    return Child


def slotted_dataclass_explicit_super():
    """The workaround for the stdlib case: name the class, so the method never
    reads the __class__ cell that the rebuilt class left stale."""

    @dataclass(slots=True)
    class Child(Base):
        n: int = 0

        def describe(self) -> str:
            return "child of " + super(Child, self).describe()

    return Child


POST_INIT_CALLS = []


@dataclass
class PostInitDataclass:
    """The hook people reach for when told dataclasses cannot validate.

    It is not the same offer. __post_init__ fires at __init__ and again from
    dataclasses.replace(), because replace() constructs a new instance — but
    an ordinary assignment never touches it, so a dataclass that is correct at
    construction can stop being correct one line later.
    """

    port: int = 8080

    def __post_init__(self) -> None:
        POST_INIT_CALLS.append(self.port)


@attr.s(auto_attribs=True)
class LegacyListener:
    """The class you already have, in the pre-2020 namespace, still supported.

    @attr.s is not @define with an older spelling. It leaves on_setattr unset
    and slots off, so this class behaves like a dataclass on both counts:
    the converter runs at __init__ and never again, and an undeclared
    attribute lands in an ordinary __dict__.

    That is the actual content of the migration. Rewriting @attr.s to @define
    is not cosmetic — it turns on slots and starts running converters and
    validators on every assignment, which is where a codebase that was quietly
    assigning strings to int fields finds out.
    """

    port: int = attr.ib(default=8080, converter=int)


def attrs_default_before_mandatory():
    """Return the exception attrs raises at class definition, or None."""
    try:

        @define
        class Bad:
            a: int = 0
            b: int

    except Exception as exc:
        return exc
    return None


def dataclass_default_before_mandatory():
    try:

        @dataclass
        class Bad:
            a: int = 0
            b: int

    except Exception as exc:
        return exc
    return None
test/contract.py
import dataclasses
import importlib.metadata
import inspect
import subprocess
import sys
import weakref
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

import attr
import attrs

from src.models import (
    POST_INIT_CALLS,
    AttrsDerived,
    Base,
    DataclassDerived,
    DataclassListener,
    DataclassSession,
    FrozenDataclassListener,
    FrozenListener,
    KwDataclassListener,
    KwListener,
    LegacyListener,
    Listener,
    PostInitDataclass,
    QuietListener,
    Session,
    SlottedAttrsMark,
    SlottedDataclassMark,
    Unchecked,
    _Mark,
    _OtherMark,
    attrs_default_before_mandatory,
    dataclass_default_before_mandatory,
    slotted_attrs_subclass,
    slotted_dataclass_explicit_super,
    slotted_dataclass_subclass,
)

# Neither library enforces the annotation. State it first, because every
# claim below is only interesting once this one is out of the way.
assert DataclassListener(host=123, port="8080").port == "8080"
assert Unchecked("nope").n == "nope"

# What attrs adds is a place to put the check that __init__ actually runs.
# The two builtin exception types differ: instance_of raises TypeError,
# the comparison validators raise ValueError.
try:
    Listener(123)
    raise AssertionError("instance_of should reject an int host")
except TypeError as exc:
    assert "must be <class 'str'>" in str(exc)

try:
    Listener("h", 0)
    raise AssertionError("gt(0) should reject 0")
except ValueError as exc:
    assert "must be > 0" in str(exc)

# The whole comparison family behaves that way, called directly with the
# Attribute they would receive from __init__.
port_attribute = attrs.fields(Listener)[1]
for name, bad in (("ge", 0), ("lt", 100), ("le", 100)):
    validator = getattr(attrs.validators, name)(3)
    try:
        validator(None, port_attribute, bad)
        raise AssertionError(name + " should reject " + str(bad))
    except ValueError:
        pass

# converter before validator, which is the only order that lets converter=int
# and validators.instance_of(int) coexist on one field.
assert Listener("h", "9000").port == 9000
assert type(Listener("h", "9000").port) is int

# The difference dataclasses cannot reproduce at all: assignment. @define
# installs on_setattr = pipe(convert, validate), so both run again here.
listener = Listener("h")
listener.port = "1234"
assert listener.port == 1234
try:
    listener.port = 0
    raise AssertionError("the validator should run on assignment too")
except ValueError:
    pass

plain = DataclassListener("h")
plain.port = "1234"
assert plain.port == "1234"

# __post_init__ is the hook people offer as the answer to that, and it is not
# one: it fires at __init__ and again through replace(), because replace()
# builds a new instance, but assignment goes nowhere near it.
POST_INIT_CALLS.clear()
hooked = PostInitDataclass(1)
hooked.port = "not an int"
assert POST_INIT_CALLS == [1]
dataclasses.replace(hooked, port=2)
assert POST_INIT_CALLS == [1, 2]
assert hooked.port == "not an int"

# on_setattr=NO_OP is a real hole rather than a tuning knob: __init__ still
# converts, assignment does not, so the same field holds either type.
quiet = QuietListener("5")
assert quiet.port == 5
quiet.port = "5"
assert quiet.port == "5"

# And validators are not a boundary. Any caller can switch all of them off,
# for every class in the process at once and not just the one being called.
with attrs.validators.disabled():
    assert Listener(123).host == 123
    assert Session(token=123)._token == 123
assert attrs.validators.get_disabled() is False

# Slots. @define is slots=True, so a typo is an error instead of a new
# attribute; the dataclass takes it.
try:
    listener.hsot = "typo"
    raise AssertionError("@define should be slotted")
except AttributeError as exc:
    assert "hsot" in str(exc)
plain.hsot = "typo"
assert plain.hsot == "typo"
assert not hasattr(listener, "__dict__")
assert hasattr(plain, "__dict__")

# dataclasses got slots in 3.10, and it is opt-in. In both libraries adding
# slots means the decorator returns a NEW class object — the name you
# decorated is rebound, and any reference taken before it is stale.
assert SlottedDataclassMark is not _Mark
assert SlottedAttrsMark is not _OtherMark
assert SlottedDataclassMark.__slots__ == ("n",)

# The two slot layouts are not the same layout. attrs reserves __weakref__ and
# the stdlib does not, so anything that keeps weak references to your objects
# — caches, registries, observers — stops working the day slots=True lands,
# unless weakref_slot=True goes with it.
assert SlottedAttrsMark.__slots__ == ("n", "__weakref__")
attrs_mark = SlottedAttrsMark()
assert weakref.ref(attrs_mark)() is attrs_mark
dataclass_mark = SlottedDataclassMark()
try:
    weakref.ref(dataclass_mark)
    raise AssertionError("a slotted dataclass has no __weakref__ by default")
except TypeError as exc:
    assert "cannot create weak reference" in str(exc)

# Measured, and it contradicts the hypothesis this sample started from:
# CPython 3.12 does not repair the __class__ closure cell when it rebuilds
# the class, so zero-argument super() in a slotted dataclass fails at call
# time — not at definition, which is what makes it expensive to find. attrs
# repairs the cell, and the identical class body works.
slotted_dataclass_child = slotted_dataclass_subclass()
assert issubclass(slotted_dataclass_child, Base)
try:
    slotted_dataclass_child().describe()
    raise AssertionError("expected the stdlib slots/super() failure on 3.12")
except TypeError as exc:
    assert "obj must be an instance or subtype of type" in str(exc)
assert slotted_attrs_subclass()().describe() == "child of base"

# And this is the mechanism, not a guess about it. Both decorators return a
# class the method's __class__ cell was never told about; attrs rebinds the
# cell to the class it returns and the stdlib leaves it pointing at the
# discarded original, which is precisely what zero-argument super() reads.
dataclass_cell = slotted_dataclass_child.describe.__closure__[0].cell_contents
assert dataclass_cell is not slotted_dataclass_child
attrs_child = slotted_attrs_subclass()
assert attrs_child.describe.__closure__[0].cell_contents is attrs_child

# Naming the class explicitly is the workaround, because super(Child, self)
# never reads the cell.
assert slotted_dataclass_explicit_super()().describe() == "child of base"

# The measurement above is version-specific by nature. Pin the version it was
# taken on, so an image where CPython has fixed this fails here loudly instead
# of leaving a stale claim in the sample.
assert sys.version_info[:2] == (3, 12)

# Frozen. The two errors are unrelated classes from different modules, and
# attrs' one reports the OLD namespace as its module even though it is
# imported from attrs.exceptions.
assert attrs.exceptions.FrozenInstanceError.__module__ == "attr.exceptions"
assert dataclasses.FrozenInstanceError.__module__ == "dataclasses"
assert attrs.exceptions.FrozenInstanceError is attr.exceptions.FrozenInstanceError
assert attrs.exceptions.FrozenInstanceError is not dataclasses.FrozenInstanceError
assert not issubclass(
    attrs.exceptions.FrozenInstanceError, dataclasses.FrozenInstanceError
)
assert not issubclass(
    dataclasses.FrozenInstanceError, attrs.exceptions.FrozenInstanceError
)

# So the obvious except clause silently misses half the codebase.
frozen_attrs = FrozenListener("h")
try:
    try:
        frozen_attrs.host = "other"
        raise AssertionError("frozen should refuse assignment")
    except dataclasses.FrozenInstanceError:
        raise AssertionError("dataclasses.FrozenInstanceError caught attrs'")
except attrs.exceptions.FrozenInstanceError:
    pass

frozen_dc = FrozenDataclassListener("h")
try:
    frozen_dc.host = "other"
    raise AssertionError("frozen dataclass should refuse assignment")
except attrs.exceptions.FrozenInstanceError:
    raise AssertionError("attrs' FrozenInstanceError caught the dataclass one")
except dataclasses.FrozenInstanceError:
    pass

# Both derive from AttributeError, the narrowest single clause covering both.
for error in (attrs.exceptions.FrozenInstanceError, dataclasses.FrozenInstanceError):
    assert issubclass(error, AttributeError)

# Field ordering: same rule, different exception type, both at class
# definition time rather than at first use.
attrs_order_error = attrs_default_before_mandatory()
dataclass_order_error = dataclass_default_before_mandatory()
assert type(attrs_order_error) is ValueError
assert "No mandatory attributes allowed after" in str(attrs_order_error)
assert type(dataclass_order_error) is TypeError
assert "non-default argument 'b' follows default argument" in str(
    dataclass_order_error
)

# kw_only=True lifts the rule in both, and dataclasses have had it since 3.10.
assert KwListener(port=1).host == "localhost"
assert KwDataclassListener(port=1).host == "localhost"

# Private fields. attrs strips the underscore to name the __init__ parameter;
# dataclasses keep it. Both spellings read as correct at a call site, which is
# what makes this the migration bug that survives review.
assert Session(token="t")._token == "t"
assert attrs.fields(Session)[0].name == "_token"
assert attrs.fields(Session)[0].alias == "token"
try:
    Session(_token="t")
    raise AssertionError("attrs renames the parameter to token")
except TypeError as exc:
    assert "unexpected keyword argument '_token'" in str(exc)

assert DataclassSession(_token="t")._token == "t"
try:
    DataclassSession(token="t")
    raise AssertionError("dataclasses keep the underscore")
except TypeError as exc:
    assert "unexpected keyword argument 'token'" in str(exc)

# evolve/replace inherit that difference, because both go back through
# __init__ — which also means attrs re-runs the validator and replace has
# nothing of its own to re-run.
session = Session(token="t")
assert attrs.evolve(session, token="new")._token == "new"
try:
    attrs.evolve(session, _token="new")
    raise AssertionError("evolve keys on the init parameter name")
except TypeError:
    pass
try:
    attrs.evolve(session, token=123)
    raise AssertionError("evolve should re-run the validator")
except TypeError as exc:
    assert "must be <class 'str'>" in str(exc)

dc_session = DataclassSession(_token="t")
assert dataclasses.replace(dc_session, _token=123)._token == 123

# init=False fields are refused by both, with a message worth reading in only
# one of them: replace names the problem, evolve reports an unknown keyword.
try:
    dataclasses.replace(DataclassDerived(1), b=9)
    raise AssertionError("replace should refuse an init=False field")
except ValueError as exc:
    assert "cannot be specified with replace()" in str(exc)
try:
    attrs.evolve(AttrsDerived(1), b=9)
    raise AssertionError("evolve should refuse an init=False field")
except TypeError as exc:
    assert "unexpected keyword argument 'b'" in str(exc)

# The two libraries do not recognise each other. Anything dispatching on
# dataclasses.is_dataclass — serializers, adapters, editors — sees an attrs
# class as a plain object.
assert dataclasses.is_dataclass(Listener) is False
assert attrs.has(DataclassListener) is False
try:
    dataclasses.fields(Listener)
    raise AssertionError("attrs classes are not dataclasses")
except TypeError:
    pass
try:
    attrs.fields(DataclassListener)
    raise AssertionError("dataclasses are not attrs classes")
except attrs.exceptions.NotAnAttrsClassError:
    pass

# The migration question underneath: the old namespace still imports, and it
# is the same package seen twice. Checked in a fresh interpreter, because by
# this line `attr` is already in sys.modules and a re-import would execute
# nothing — the usual way this gets "verified" and proves nothing. -W error
# turns every warning into a failure, so rc 0 means not one was raised.
probe = subprocess.run(
    [sys.executable, "-W", "error", "-c", "import attr"],
    capture_output=True,
    text=True,
)
assert probe.returncode == 0, probe.stderr
assert attr.s is attr.attrs and attr.ib is attr.attrib
assert attr.__version__ == importlib.metadata.version("attrs")

# The move went one way only, which is the opposite of what "attr is the old
# name for attrs" suggests. Every modern name was added to `attr` as the same
# object, so a half-migrated file works; none of the old ones were ever added
# to `attrs`, so only the reverse reach fails.
assert attr.define is attrs.define
assert attr.field is attrs.field and attr.frozen is attrs.frozen
assert attr.evolve is attrs.evolve and attr.fields is attrs.fields
assert not hasattr(attrs, "s") and not hasattr(attrs, "ib")
assert not hasattr(attrs, "attrib") and not hasattr(attrs, "attributes")

# But a shared name is not always a shared object, and that is where a
# find-and-replace across the two namespaces actually breaks: attrs.asdict is
# a different function with a keyword-only signature that dropped
# retain_collection_types, and the two exceptions modules are distinct objects
# that happen to expose the same classes.
assert attr.asdict is not attrs.asdict
assert "retain_collection_types" in inspect.signature(attr.asdict).parameters
assert "retain_collection_types" not in inspect.signature(attrs.asdict).parameters
assert attr.exceptions is not attrs.exceptions
assert attr.validators.instance_of is attrs.validators.instance_of

# And @attr.s is not @define with an older spelling. No slots, and the
# converter runs at __init__ only — the dataclass behaviour, which is exactly
# what changes the day someone rewrites it to @define.
legacy = LegacyListener("5")
assert legacy.port == 5
legacy.port = "5"
assert legacy.port == "5"
legacy.hsot = "typo"
assert hasattr(legacy, "__dict__")
# The difference is visible on the class: @define writes a __setattr__ to run
# the pipeline, @attr.s leaves the object's own.
assert LegacyListener.__setattr__ is object.__setattr__
assert Listener.__setattr__ is not object.__setattr__

print("contract ok:", importlib.metadata.version("attrs"))

오리진 시더

anonymous