Fixit lint rule that upgrades the legacy attrs API (@attr.s / attr.ib) to the modern API (@define / field).
Project description
fixit-attrs
A Fixit lint rule that upgrades the legacy
attrs API (@attr.s / attr.ib()) to the
modern API (@define / field()).
The legacy and modern APIs are documented in attrs' own "On The Core API Names" guide. The modern API has been available since attrs 20.1.0 (July 2020) and has been the documented default since November 2021. The legacy names will keep working forever — but new code should prefer the modern ones.
Install
pip install fixit-attrs
This pulls in fixit itself as a dependency, so the fixit CLI becomes
available in the same environment.
Standalone, one-off usage (no config)
If you just want to run the rule against a file or tree without
touching any configuration, pass --rules fixit_attrs.rules to the
fixit CLI. Note that --rules replaces the configured rule set
(rather than adding to it), so only this package's rule will fire —
which is usually what you want for a one-shot codemod:
# Show diagnostics
fixit --rules fixit_attrs.rules lint path/to/your/code
# Show diagnostics with the proposed diff
fixit --rules fixit_attrs.rules lint --diff path/to/your/code
# Apply the autofix non-interactively
fixit --rules fixit_attrs.rules fix --automatic path/to/your/code
# Apply interactively, file by file
fixit --rules fixit_attrs.rules fix path/to/your/code
This is the recommended way to use fixit-attrs for a one-time
migration of an existing codebase, since the rule mostly has no work to
do once you've upgraded.
Configured usage (every fixit run)
If you want this rule to run alongside Fixit's built-in lint rules on
every invocation — for example, to keep new code from accidentally
introducing legacy @attr.s usage — add fixit_attrs.rules to your
project's pyproject.toml (or fixit.toml):
[tool.fixit]
enable = [
"fixit.rules", # the built-in Fixit rules
"fixit_attrs.rules", # this package's rule(s)
]
Then just run fixit lint / fixit fix as usual:
fixit lint path/to/your/code
fixit fix --automatic path/to/your/code
Example
Before:
import attr
@attr.s
class Point:
x = attr.ib()
y = attr.ib(default=0)
After fixit fix --automatic:
from attrs import define, field
import attr
@define
class Point:
x = field()
y = field(default=0)
What the rule rewrites
| Old reference (any import style) | Becomes |
|---|---|
attr.s / attr.attrs / attr.attributes |
define |
attr.ib / attr.attrib / attr.attr |
field |
fixit-attrs understands every way the legacy names can be brought into
scope, because Fixit's QualifiedNameProvider follows aliases all the
way to the imported attribute:
import attr; @attr.s # rewritten
import attr as a; @a.s # rewritten
from attr import s; @s # rewritten
from attr import s as decorate; @decorate # rewritten
from attr import attrs as A, attrib as F; @A; F() # rewritten
A single from attrs import define, field is added to the top of any
file that gets rewritten, listing only the names actually used.
Limitations & edge cases
These are intentional design choices, not bugs. Read each before running the autofix at scale.
1. Behaviour is not preserved. @attr.s and @define are NOT drop-in equivalents.
The two decorators have different defaults. The most consequential differences:
| Option | @attr.s default |
@define default |
|---|---|---|
slots |
False |
True |
auto_detect |
False |
True |
auto_exc |
False |
True |
on_setattr |
None (no hook) |
runs converters & validators when not frozen |
force_kw_only |
True |
False |
The most likely-to-bite-you of these is slots=True. Slotted classes:
- can't have arbitrary attributes set on instances,
- can't be weakly referenced unless
weakref_slot=True(default indefine, but worth knowing), - have multiple-inheritance gotchas,
- can break monkey-patching in tests.
If your existing classes rely on dict-based behaviour, the autofix will
silently make them slotted. Review the diff per file, and run your
test suite after applying. If you need to preserve the old behaviour,
add slots=False (or whichever options matter) by hand after the fix.
This rule does not auto-inject slots=False, on_setattr=None, force_kw_only=True, ... to mechanically preserve the old behaviour
because doing so would defeat the point of the upgrade. The common case
is "I want the modern defaults"; the migration is the time to opt into
them.
2. @attr.dataclass is reported but NOT autofixed.
attr.dataclass is an undocumented easter egg equivalent to
attr.s(auto_attribs=True). Mapping it to plain @define is
semantically correct (because define's default auto_attribs=None
auto-detects annotated attributes), but attr.dataclass is also a
deliberate human-readable hint that "this is a dataclass-style attrs
class". The rule reports the use site so you can see it but does not
silently rewrite it. Decide per-file whether to switch to @define
manually.
3. The original attr imports are left in place.
The rule rewrites references to the legacy names, but does not modify
your import attr or from attr import s, ib statements. Two reasons:
- Other code in the same file may still reference unrelated
attr.*symbols (attr.Factory,attr.NOTHING,attr.validators, …) that this rule doesn't touch. - Removing unused imports is the responsibility of a different lint
(e.g.
flake8-pyflakes,ruff,usort/unimport).
After running the autofix, run your unused-import linter to clean up. Expected leftovers in a successfully migrated file look like:
from attrs import define, field # added by fixit-attrs
import attr # left behind, may now be unused
from attr import s, ib # left behind, definitely unused
4. Name shadowing is not detected.
The rule unconditionally adds from attrs import define, field and
rewrites references to bare define / field. If your file already
binds define or field as a local name (function, variable, class,
or import), the new import will shadow it and break your code.
Before running on a large codebase:
grep -rE '\b(define|field)\b\s*=' your_package/
grep -rE '\bdef\s+(define|field)\b' your_package/
If you have collisions, either rename the conflicting local symbols first, or run the rule file-by-file and inspect the diff.
5. Positional attr.ib(default_value) calls become invalid field() calls.
attr.ib() accepts default as a positional or keyword argument.
attrs.field() is keyword-only. So:
x = attr.ib(NOTHING) # legal
x = field(NOTHING) # TypeError at class creation time
In practice almost all real-world code passes default= as a keyword,
but if your codebase has positional uses they will break at import time
after the fix. Grep for them first:
grep -REn '\battr\.(ib|attrib)\(\s*[^=)]' your_package/
6. cmp= is silently dropped at runtime.
attr.ib() accepts cmp= (deprecated since 19.2.0 in favour of
eq= / order=). attrs.field() does not. The rule does NOT translate
cmp=... into eq= / order=; the rewritten field(cmp=...) will
raise TypeError. Grep first if your codebase is old:
grep -REn '\battr\.(ib|attrib)\([^)]*\bcmp=' your_package/
Either remove cmp= (it's been a no-op alias of eq since 21.1.0
anyway) or skip those files.
7. Diagnostic locations vs. the autofix.
For visibility, the rule reports a diagnostic at every legacy use site
(decorator, field call) so fixit lint shows you each spot. The
autofix itself, however, is a single consolidated module-level
replacement — Fixit's engine builds the rewritten module in one shot,
prepends the import, and replaces the file's AST as a unit. You will
see something like:
example.py@5:8 UpgradeAttrsAPI: Use the modern attrs API: ...
example.py@6:8 UpgradeAttrsAPI: Use the modern attrs API: ...
example.py@1:0 UpgradeAttrsAPI: Use the modern attrs API: ... (has autofix)
🛠️ 1 file checked, 1 file with errors, 1 auto-fix available, 1 fix applied 🛠️
The "1 auto-fix" / "1 fix applied" counters refer to the consolidated module patch, not to the per-site diagnostics. Every site is still covered.
8. Stacked / chained decorators.
@attr.s followed by other decorators is supported normally (the rule
only rewrites the attr.s decorator and leaves siblings alone). The
attribute-validator and default-value decorator forms (@x.validator,
@x.default) work identically in the modern API and need no rewriting.
9. No support for attr.make_class, attr.Factory, attr.NOTHING, validators / converters / setters modules.
The rule's scope is the @attr.s / attr.ib() rename only. Other
attr.* symbols are left alone. They all have direct attrs.*
equivalents — you can switch the import yourself if you want a fully
modern style.
Recommended workflow for an existing codebase
- Skim the limitations above. The most likely surprises are
#1 (slots), #4 (name shadowing), and #5/#6 (
attr.ibpositional /cmp=arguments). - Run the relevant
greps suggested in §4–§6 and triage the hits. - Run
fixitto see the full set of diagnostics. For a one-shot migration you don't need any config — just pass--rules:fixit --rules fixit_attrs.rules lint your_package/ - Run
fixit fix(interactive) on a small subset first and review the diffs:fixit --rules fixit_attrs.rules fix your_package/some_module.py - Run your test suite. Fix any slots-related breakage by adding
@define(slots=False)where needed. - Once you're confident, run non-interactively across the whole tree:
fixit --rules fixit_attrs.rules fix --automatic your_package/ - Clean up unused legacy imports with your import linter
(e.g.
ruff --select F401). - Optionally, change
from attrs import define, fieldblocks into the style you prefer.
Testing the rule
pip install fixit-attrs[dev]
python -m unittest tests.test_upgrade_attrs_api
The test module uses Fixit's add_lint_rule_tests_to_module helper to
materialise one unittest.TestCase per VALID / INVALID example
declared on the rule.
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fixit_attrs-0.1.0-py3-none-any.whl.
File metadata
- Download URL: fixit_attrs-0.1.0-py3-none-any.whl
- Upload date:
- Size: 11.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3579ffdc038732ac31779488b7cf4f84f0d19d357256de3d9f1842ddd9471802
|
|
| MD5 |
1430cf1897069f623993e22d5c1d7e02
|
|
| BLAKE2b-256 |
471884a9503f26e3d7a571d963066db978505c5fb13d57a8da85fa4bc4c0af7b
|