- Book: RAG Pocket Guide
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Python's logger.warn() has been deprecated since Python 3.3. CPython removed it on its main branch during the 3.13 cycle, then put it back with a one-line reason: "Keep the deprecated logging warn() method in Python 3.13." The current deprecations page still lists it under "Pending removal in future versions", with no version attached. The old spelling still works. It just has an end date nobody has picked yet.
Say your repo has a few hundred of those calls. The tempting move is one prompt to a coding agent: replace logger.warn with logger.warning everywhere. The agent has to find every call, open every file and edit them one by one, and every file it reads goes into its context. Along the way it meets warnings.warn(), a different function with the same last name that must not change. And every file it opens is one more file it can decide to tidy while it's there.
Split the work instead. A codemod does every call a pattern can decide. The agent gets only the calls that need judgment, on its own branch, and you review its part apart from the codemod.
The codemod tool here is ast-grep, which matches code by its syntax tree instead of by text. If you already run Ruff, its rule G010 flags logging.warn and Logger.warn and always offers a fix. Use that for this exact rename. The steps below are for the renames no linter knows about: your own client, your own wrapper, the method your team renamed last sprint. logger.warn is a public example where you can check every step.
Step 1: a one-line rewrite, previewed first
Install ast-grep with brew install ast-grep, pip install ast-grep-cli or npm i @ast-grep/cli -g (quick start). The short command is sg, but on Linux sg is already the setgroups command, so this post uses the full name.
ast-grep run -l python \
-p '$OBJ.warn($$$ARGS)' \
-r '$OBJ.warning($$$ARGS)'
$OBJ matches any single node: logger, self.log, logging.getLogger(__name__). $$$ARGS matches zero or more nodes, here every argument (pattern syntax). Without -U or -i, run --rewrite only shows the diff it would apply (rewrite guide). Read that diff before you write anything.
You'll find the problem in it. warnings.warn("old API", DeprecationWarning) is in there too, about to become warnings.warning(...), which doesn't exist. The pattern matched exactly what you wrote. Now you have to say which receivers count.
Step 2: a YAML rule that only trusts known receivers
Put the rule in a file, with a constraint on $OBJ:
# codemods/warn-to-warning.yml
id: logger-warn-to-warning
language: python
rule:
pattern: $OBJ.warn($$$ARGS)
constraints:
OBJ:
regex: ^((self\.)?_?(log|logger|LOGGER)|logging)$
fix: $OBJ.warning($$$ARGS)
ast-grep matches rule first, then filters the matches with constraints (YAML reference). Constraints work on single meta variables like $OBJ, not on $$$ARGS. A regex matches anywhere in the node's text unless you anchor it with ^ and $ (rule reference), so the anchors matter here: without them, catalog.warn(...) would pass, because "catalog" contains "log".
This regex trusts your naming. If log is ever a list or a file handle somewhere in the repo, the rule rewrites that call too. Search for where log and logger get assigned before you apply it.
Then preview, apply and commit on a branch of its own:
git switch -c rename/warn-codemod
ast-grep scan --rule codemods/warn-to-warning.yml
ast-grep scan --rule codemods/warn-to-warning.yml -U
pytest
git add codemods/warn-to-warning.yml
git commit -am "logging: warn() -> warning() via ast-grep"
--rule scans with one rule file and needs no project setup, and -U applies every rewrite without asking (scan reference). The first scan reports the matches and changes nothing. The second, with -U, writes the fixes. Commit the rule with the change, so a reviewer can rerun it and get the same diff instead of reading hundreds of identical hunks.
Step 3: list what the rule left behind
Run the broad pattern again on the codemod branch. Anything still calling .warn( on a receiver other than warnings is a leftover:
ast-grep run -l python -p '$OBJ.warn($$$ARGS)' \
--json=stream \
| jq -r 'select(.text | startswith("warnings.") | not)
| .file' \
| sed 's|^\./||' | sort -u > ~/warn-leftovers.txt
--json=stream prints one match object per line, and each object carries the matched text and the file it came from (JSON output). jq drops the warnings.warn calls and keeps the file path, and sort -u leaves one line per file. The list lives outside the repo on purpose, so it never ends up in a commit.
Here is the kind of code that ends up on it:
class Exporter:
def __init__(self, sink, audit):
self.sink = sink
self.audit = audit
def flush(self):
self.sink.warn("buffer full")
logging.getLogger(__name__).warn("flushed")
self.audit.warn("export", user_id=7)
A pattern can't tell you whether self.sink is a logging.Logger, a LoggerAdapter or a metrics client whose warn() means something else. logging.getLogger(__name__) is certainly a logger, but your regex didn't list it. Each leftover is a type question, and reading the code around a call to answer a type question is work an agent does well.
Calls aren't the only leftovers. git grep -nw warn -- '*.py' also turns up getattr(log, "warn") and handler = logger.warn, which no call pattern sees. Skim that output and add any such file to the list by hand.
Step 4: hand the agent only that list, on its own branch
I build Hermes IDE, so weigh this step with that in mind. Plain git commands for the same split are at the end of the step.
Press ⌘N (Ctrl+Shift+N on Windows and Linux) and pick Claude Code or Codex. Hermes starts the agent in a real terminal, on its own hermes/ branch in its own worktree, with your task as its first prompt (README). Before you send the task, check that it starts from rename/warn-codemod. That way the files the codemod fixed are already fixed when the agent opens them, and Land later merges back into that same branch.
The task text names the files and nothing else:
Finish renaming logger.warn( to logger.warning(.
Only edit the files listed below. Do not touch any
other file. Do not reformat or refactor.
In each file, rename a .warn( call only when the
receiver is a logging.Logger or LoggerAdapter.
If it is anything else, leave it, and list it with
file:line and your reason in your summary.
Run pytest, then commit with the message
"logging: finish warn() -> warning() by hand".
Files:
(paste the contents of ~/warn-leftovers.txt)

The ⌘N New task sheet: agent, project, a new hermes/ worktree, approval mode, model and effort, and a preview of the command Hermes will run.
Without Hermes, the same split is a worktree and one command:
git worktree add -b agent/warn-leftovers \
../warn-leftovers rename/warn-codemod
cd ../warn-leftovers
claude "$(cat ~/warn-task.txt)"
Here ~/warn-task.txt holds the task text above with the list pasted in. claude "query" starts an interactive Claude Code session with that prompt. For Codex, codex takes an optional prompt argument the same way.
Step 5: review turn by turn, then prove the scope
Each agent reply shows up as a chip in the "Agent replies" strip under the session, with the lines it added and removed. Click one to see its changes or restore your files to that point (changelog). ⌘G (Ctrl+Shift+G) opens the Review Desk, where you read the task by turn or by file, and a line comment goes back to the agent that wrote the line.
Two questions per turn: did it touch only listed files, and for each call it left alone, does its reason hold up? A metrics client's warn() is a good reason. "Unclear" means you go read that receiver yourself.

The Review Desk (⌘G) in By turn view: each turn with its files and lines, the selected turn's diff, and a commit message drafted from the turns.
Then check the scope with git, once the agent's commit is on its branch. Set TASK to the branch name Hermes shows for the session:
TASK=hermes/finish-renaming-logger-warn
git diff --name-only "rename/warn-codemod...$TASK" \
| grep -vxFf ~/warn-leftovers.txt
The three-dot diff lists only what the task branch changed since it left the codemod commit (git diff). grep -vxFf prints every path that isn't a whole line in your list. No output means the agent stayed inside the list. Any line printed is a file you didn't hand it: restore that turn, or ask the agent to revert it.
Next, rerun the Step 3 pipeline, but end it with > ~/warn-remaining.txt so your original list stays intact for the scope check. The new file should hold only files whose receivers the agent left on purpose and named in its summary. Last, run pytest -W error::DeprecationWarning, which turns those warnings into test failures (pytest docs), so any warn() call your tests reach fails loudly. It fails on other libraries' deprecations too, which you may want to know about anyway.
When it's clean, Land merges the task into the branch it started from, which you checked was rename/warn-codemod, and it can be undone. The pull request then carries two commits of work: the codemod, which anyone can rerun from the rule file, and the agent's hand edits, small enough to read line by line.
Where this split breaks
ast-grep matches syntax, not types. The regex constraint trusts names, so a misnamed variable gets rewritten, and only your tests catch it. Computed attribute access like getattr(obj, name) slips past both the pattern and git grep.
Nothing in this setup fences the agent to the list. The prompt asks, and the git check afterwards is what tells you whether it listened. If the leftover list comes out at half the call sites, the pattern is wrong: tighten the rule before you hand anything over.
Hermes doesn't fence the agent either. It observes what agents report and what git shows, and it never answers a permission prompt for you (README). It's free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. I haven't run this exact rename through it on a real codebase, so if a step behaves differently for you, open an issue.
If this was useful
If you want the Step 4 flow without wiring worktrees by hand, Hermes IDE is a free download and built in the open on GitHub.
The move under this whole post is retrieval: find the exact slice of a codebase that needs a model, and send only that slice. The RAG Pocket Guide is about doing that step well for documents and code, with the chunking, retrieval and reranking patterns that decide what a model gets to see before it answers.

Top comments (0)