Metadata-Version: 2.4
Name: fixit-attrs
Version: 0.1.0
Summary: Fixit lint rule that upgrades the legacy attrs API (@attr.s / attr.ib) to the modern API (@define / field).
Project-URL: Homepage, https://github.com/filbranden/fixit-attrs
Project-URL: Issues, https://github.com/filbranden/fixit-attrs/issues
Project-URL: Changelog, https://github.com/filbranden/fixit-attrs/blob/main/CHANGELOG.md
Author: fixit-attrs contributors
License-Expression: MIT
License-File: LICENSE
Keywords: attrs,codemod,fixit,libcst,lint
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: fixit>=2.0.0
Requires-Dist: libcst>=0.4
Provides-Extra: dev
Requires-Dist: pytest; extra == 'dev'
Description-Content-Type: text/markdown

# fixit-attrs

A [Fixit](https://fixit.rtfd.io) lint rule that upgrades the legacy
[`attrs`](https://www.attrs.org) 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"](https://www.attrs.org/en/stable/names.html)
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

```console
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:

```console
# 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`):

```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:

```console
fixit lint path/to/your/code
fixit fix --automatic path/to/your/code
```

### Example

Before:

```python
import attr

@attr.s
class Point:
    x = attr.ib()
    y = attr.ib(default=0)
```

After `fixit fix --automatic`:

```python
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:

```python
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:

```python
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:

```console
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:

```python
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:

```console
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:

```console
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 `grep`s 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`:
   ```console
   fixit --rules fixit_attrs.rules lint your_package/
   ```
4. Run `fixit fix` (interactive) on a small subset first and review
   the diffs:
   ```console
   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:
   ```console
   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

```console
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.
