Modernization
Enterprise AI
Python modernization with Ruff, LibCST, and Droids
November 14, 2026 - 6 minute read
Modernization
Enterprise AI
November 14, 2026 - 6 minute read
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.
Ruff reimplements the pyupgrade rule set under the UP prefix, and most of those pyupgrade rules 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 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, 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 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.
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.
The LibCST codemod tutorial 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.
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.
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.
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 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.
{
"PostToolUse": [
{
"matcher": "Edit|Create",
"hooks": [
{
"type": "command",
"command": "\"$FACTORY_PROJECT_DIR\"/.factory/hooks/ruff-upgrade-check.sh",
"timeout": 60
}
]
}
]
}#!/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
fiA skill 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 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 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, which list brownfield migrations as a use case and validate progress milestone by milestone.
The loop for one repository looks like this.
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.
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.
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.
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.
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.
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.
Plan legacy modernization with private AI around behavior preservation, internal dependencies, and reviewable milestones. Keep code and validation inside the approved boundary.
COBOL modernization works when every step is checked by something deterministic. Compile with GnuCOBOL, inventory with a real parser, capture golden files from batch inputs, and let Droids handle the residue that translation tools leave behind.
Java modernization with OpenRewrite works as a loop. Recipes make the mechanical changes, Droids repair what the recipes leave and write new ones, and the compiler, tests, and a clean dry run verify every step. How to set it up with Factory.
Start building