Skip to main content

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 in define, 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

  1. Skim the limitations above. The most likely surprises are #1 (slots), #4 (name shadowing), and #5/#6 (attr.ib positional / cmp= arguments).
  2. Run the relevant greps suggested in §4–§6 and triage the hits.
  3. Run fixit to 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/
    
  4. Run fixit fix (interactive) on a small subset first and review the diffs:
    fixit --rules fixit_attrs.rules fix your_package/some_module.py
    
  5. Run your test suite. Fix any slots-related breakage by adding @define(slots=False) where needed.
  6. Once you're confident, run non-interactively across the whole tree:
    fixit --rules fixit_attrs.rules fix --automatic your_package/
    
  7. Clean up unused legacy imports with your import linter (e.g. ruff --select F401).
  8. Optionally, change from attrs import define, field blocks 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fixit_attrs-0.1.0-py3-none-any.whl (11.5 kB view details)

Uploaded Python 3

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

Hashes for fixit_attrs-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3579ffdc038732ac31779488b7cf4f84f0d19d357256de3d9f1842ddd9471802
MD5 1430cf1897069f623993e22d5c1d7e02
BLAKE2b-256 471884a9503f26e3d7a571d963066db978505c5fb13d57a8da85fa4bc4c0af7b

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page