# Python modernization with Ruff, LibCST, and Droids

> Python modernization works best when Ruff, django-upgrade, and LibCST codemods make the mechanical changes and Droids handle the residue. A practical setup with hooks, tested codemods, type checking, and a rerun gate that must report zero changes.

URL: https://factory.com/articles/python-modernization-codemods
Published: 2026-11-14
Updated: 2026-11-14

Python modernization covers syntax upgrades for a newer interpreter, framework upgrades such as a new Django release, and replacements for APIs that the standard library deprecated or removed. Most of that work is mechanical. A small part needs judgment, and that part decides whether the upgrade ships.

The reliable approach pairs deterministic Python tools with a coding agent. Ruff, django-upgrade, and LibCST codemods make the repeatable changes, producing the same output from the same input every time. Factory's Droids handle what those tools leave behind, write new codemods when no rule exists, and rerun the tools until they report zero remaining changes.

## What the deterministic Python tools cover

Ruff reimplements the pyupgrade rule set under the `UP` prefix, and most of those [pyupgrade rules](https://docs.astral.sh/ruff/rules/#pyupgrade-up) carry automatic fixes. The `--target-version` flag sets the minimum interpreter to support, so `py312` rewrites `Optional[List[int]]` to `list[int] | None`, drops `super(Foo, self)` arguments, and removes code guarded for older versions. As of November 2026, Ruff accepts targets from `py37` through `py315`, and it reads `requires-python` from `pyproject.toml` when no target is set.

Ruff separates safe fixes from unsafe ones. Its [fix safety](https://docs.astral.sh/ruff/linter/#fix-safety) documentation says an unsafe fix may change runtime behavior or remove comments, so `--fix` applies only safe fixes unless `--unsafe-fixes` is passed. That split is a useful boundary for review. Safe fixes can land as one mechanical commit, and unsafe ones deserve the same scrutiny as hand-written code.

Select `F401` alongside `UP`. In a small test with Ruff 0.17, `--select UP --fix` rewrote the annotations but left `from typing import List, Optional` in place and still reported it. Adding `F401` removed the unused import in the same pass.

Django projects add [django-upgrade](https://django-upgrade.readthedocs.io/en/latest/), which rewrites code to fix Django deprecation warnings for a `--target-version` such as `5.2`. It does not recurse directories, so its documentation pipes `git ls-files` into it. Its `--check` flag writes nothing and exits non-zero when any file would change. Some of its fixers alter models, so a test for pending migrations belongs in the gate.

Pin the version of each tool in `pyproject.toml` or the pre-commit configuration. A newer Ruff release can add rules or change a fix, and an unpinned rerun would then disagree with the commit it is meant to verify.

LibCST is the tool for everything else. It parses Python into a concrete syntax tree that keeps comments and whitespace, ships prebuilt codemods such as `convert_format_to_fstring.ConvertFormatStringCommand`, and runs custom codemods across a repository through `python -m libcst.tool codemod`.

Python 2 code needs one extra note. The [Python 3.13 release notes](https://docs.python.org/3/whatsnew/3.13.html) record the removal of the `2to3` program and the `lib2to3` module, deprecated since 3.11. Any remaining 2-to-3 conversion has to run on Python 3.12 or earlier before the rest of the pipeline takes over.

These four commands cover most of the mechanical pass for a project targeting Python 3.12 and Django 5.2.

<div className="not-prose my-6 flex flex-col gap-3 [&_button]:!w-auto">
  <div className="grid grid-cols-1 gap-y-5 md:grid-cols-[max-content_1fr] md:gap-x-4">
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Upgrade syntax.</strong> Applies safe pyupgrade fixes and removes imports the rewrite left unused.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Upgrade Django code.</strong> Rewrites deprecated Django APIs up to the target release.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Run a custom codemod.</strong> Loads a team codemod module with -x and applies it across the tree.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Verify.</strong> Writes nothing and exits 0 only when no fixable changes remain.</span>
  </div>
</div>

## Where Droids take over in Python modernization

The deterministic pass ends with a residue that no rule covers. In a typical Python upgrade, that residue falls into a few recognizable groups.

Removed modules are the clearest case. Python 3.13 removed the "dead batteries" listed in PEP 594, including `cgi`, `telnetlib`, `crypt`, and `imghdr`. No rewrite rule can choose a replacement, because the right one depends on how the module was used. A Droid reads the call sites, picks a maintained alternative or a small local implementation, and adds tests for the behavior the old module provided.

Semantic choices are the second group. `datetime.utcnow()` has been deprecated since Python 3.12 and returns a naive datetime, while `datetime.now(UTC)` returns an aware one. Comparing the two raises `TypeError`, so a correct migration has to follow each value through serializers, database columns, and comparisons.

The third group is repair work. Changed annotations surface new mypy or pyright errors, Django upgrades break tests that relied on old defaults, and unsafe Ruff fixes need someone to confirm behavior is unchanged. A Droid can take each failure from the gate, fix it, and rerun the narrow command that failed.

The fourth is the most valuable. When the same change repeats across hundreds of files, the Droid should stop editing by hand and write a codemod. The codemod turns the pattern into something deterministic and reviewable, and it can be rerun after every rebase.

## Writing a LibCST codemod with tests

The [LibCST codemod tutorial](https://libcst.readthedocs.io/en/latest/codemods_tutorial.html) builds commands on `VisitorBasedCodemodCommand` and tests them with `CodemodTest`. The codemod below rewrites `datetime.utcnow()` calls and adds the `UTC` import. It runs under LibCST's matcher decorators, so it touches only exact matches.

```python title="codemods/replace_utcnow.py"
import libcst as cst
import libcst.matchers as m
from libcst.codemod import VisitorBasedCodemodCommand
from libcst.codemod.visitors import AddImportsVisitor

class ReplaceUtcnow(VisitorBasedCodemodCommand):
    DESCRIPTION = "Replace datetime.utcnow() with datetime.now(UTC)."

    @m.leave(m.Call(func=m.Attribute(value=m.Name("datetime"), attr=m.Name("utcnow")), args=[]))
    def replace(self, original: cst.Call, updated: cst.Call) -> cst.Call:
        AddImportsVisitor.add_needed_import(self.context, "datetime", "UTC")
        return updated.with_changes(
            func=updated.func.with_changes(attr=cst.Name("now")),
            args=[cst.Arg(cst.Name("UTC"))],
        )
```

The tests pin the exact output, including the merged import line, and confirm that unrelated calls stay untouched.

```python title="codemods/test_replace_utcnow.py"
from libcst.codemod import CodemodTest
from codemods.replace_utcnow import ReplaceUtcnow

class TestReplaceUtcnow(CodemodTest):
    TRANSFORM = ReplaceUtcnow

    def test_rewrites_call(self) -> None:
        before = "from datetime import datetime\nstamp = datetime.utcnow()\n"
        after = "from datetime import UTC, datetime\nstamp = datetime.now(UTC)\n"
        self.assertCodemod(before, after)

    def test_leaves_other_calls(self) -> None:
        code = "from datetime import datetime\nstamp = datetime.now()\n"
        self.assertCodemod(code, code)
```

Two details matter when running it. `python -m libcst.tool initialize .` writes a `.libcst.codemod.yaml` whose default formatter is Black, and a run fails per file if that formatter is missing, so set `formatter: ['ruff', 'format', '-']` or pass `--no-format`. The codemod also changes semantics on purpose. Its output is correct syntax, and the aware-versus-naive decision still needs the Droid and the tests to check every caller.

## How Factory keeps the tools in the loop

Factory does not ship a Python migration mode or a transpiler. Its Python support comes from running the ecosystem's own tools at the right moments, with Droids working between them.

[Hooks](https://docs.factory.com/harness/hooks) run deterministic shell commands at lifecycle events. A `PostToolUse` hook runs after every edit, and when it exits with code `2` its stderr goes back to the Droid as corrective feedback. This hook rechecks each edited Python file with Ruff, so the Droid cannot reintroduce old syntax while fixing something else.

```json title=".factory/hooks.json"
{
  "PostToolUse": [
    {
      "matcher": "Edit|Create",
      "hooks": [
        {
          "type": "command",
          "command": "\"$FACTORY_PROJECT_DIR\"/.factory/hooks/ruff-upgrade-check.sh",
          "timeout": 60
        }
      ]
    }
  ]
}
```

```bash title=".factory/hooks/ruff-upgrade-check.sh"
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
[[ "$file" == *.py ]] || exit 0
if ! ruff check --select UP,F401 --target-version py312 --diff "$file" >&2
then
  echo "Ruff reports pending upgrade fixes in $file. Apply them before continuing." >&2
  exit 2
fi
```

A [skill](https://docs.factory.com/harness/skills) in `.factory/skills/python-upgrade/SKILL.md` packages the playbook, including target versions, the order of tools, the codemod modules to run, and the verification commands. The repository's [AGENTS.md](https://docs.factory.com/harness/agents-md) holds the exact `pytest`, `mypy`, and `ruff` invocations, so every session and every headless run uses the same gate.

For fan-out, the [lint autofix recipe](https://docs.factory.com/droid-exec/recipes/lint-autofix) shows the pattern for Droid Exec. A deterministic tool lists the files that fail, one `droid exec --auto low` run fixes each file, and the tool checks each file again. The same script works with `mypy` output or a list of files that import a removed module. Upgrades that span many services fit [Missions](https://docs.factory.com/missions/overview), which list brownfield migrations as a use case and validate progress milestone by milestone.

The loop for one repository looks like this.

Deterministic pass</span><br/><span style='color:#8A8380 !important'>ruff UP fixes, django-upgrade, LibCST</span>"]
    B["<span style='color:#FAFAFA !important;font-weight:600'>Gate</span><br/><span style='color:#8A8380 !important'>pytest and mypy or pyright</span>"]
    C["<span style='color:#FAFAFA !important;font-weight:600'>Droid handles residue</span><br/><span style='color:#8A8380 !important'>removed modules, semantics, new codemods</span>"]
    D{"<span style='color:#FAFAFA !important;font-weight:600'>Rerun tools</span><br/><span style='color:#8A8380 !important'>ruff --diff and django-upgrade --check</span>"}
    E["<span style='color:#FAFAFA !important;font-weight:600'>Human review</span><br/><span style='color:#8A8380 !important'>tool commit and agent commit</span>"]
    A --> B --> C --> D
    D -- changes remain --> C
    D -- zero changes --> E
`}
/>

## Verification and review for Python upgrades

Every check in the gate is deterministic. The test suite confirms behavior, and mypy or pyright confirms that rewritten annotations still type-check. For code without good tests, characterization tests that record current outputs for representative inputs should come first, before any tool runs.

The rerun is the final check. `ruff check --diff` and `django-upgrade --check` must both report nothing, which proves the tree matches what the tools would produce and that no agent edit undid a mechanical change.

Keep commits separate. The first commit holds only tool output, generated by commands recorded in the pull request description, so a reviewer can regenerate it and compare. The second holds new codemods and their tests. The third holds the Droid's manual edits. Reviewers can trust the mechanical diff after spot checks and spend their attention on the last two commits, where judgment was involved.

Start with one package. Measure how much of the diff came from tools and how much from the Droid, then decide whether the next package needs a new codemod first.

## Frequently asked questions

### Which deterministic tools handle Python modernization?

Ruff implements the pyupgrade rules under the UP prefix and applies them with --fix for a chosen --target-version. django-upgrade rewrites Django code for a target Django version. LibCST runs prebuilt and custom codemods that preserve formatting and comments.

### Can 2to3 still be used for Python 2 code?

Python 3.13 removed the 2to3 program and the lib2to3 module, which were deprecated in Python 3.11. A team that still converts Python 2 code needs to run that step on Python 3.12 or earlier, then continue with Ruff and LibCST on a current interpreter.

### What does a Droid do that Ruff or LibCST cannot?

A Droid handles changes no rule covers. Examples include replacing standard library modules removed in Python 3.13, choosing between naive and timezone-aware datetimes, repairing tests and type errors after a rewrite, and writing new LibCST codemods with unit tests.

### How do you verify a Python modernization change?

Run the test suite and a type checker such as mypy or pyright, then rerun Ruff with --diff and django-upgrade with --check. Both reruns should report no remaining changes before the pull request goes to review.

### Does Factory include a built-in Python migration mode?

No. Factory runs the Python ecosystem tools through hooks, skills, AGENTS.md, Droid Exec, and Missions. Droids also write and test new codemods, which then run deterministically like any other tool.

## Further reading

- [Deterministic code modernization with Factory](https://factory.com/articles/deterministic-code-modernization)
- [Enterprise software modernization](https://factory.com/articles/enterprise-software-modernization)
- [Runtime upgrades with coding agents](https://factory.com/articles/coding-agent-runtime-upgrades)
- [Droid Exec docs](https://docs.factory.com/droid-exec/overview)
