Skip to content

Upgrading a dependency

obelize can move a dependency to a newer release when no one has written a pack for it. It reads two releases of the library, writes a pack from what changed, plans the move, tries it with your own tests and, if they agree, keeps it. This page is the whole path. A command that reads the package index says which host it will contact first, and none of them needs an account; --agent uses the login of your own claude.

1. See what is behind

uvx obelize outdated

It reads the dependency files of the repository (nothing is installed) and asks the index about each library. A row says the release you use, the newest, how far apart they are, and what the index knows about the release you use: that the library marks itself inactive, that the release was withdrawn, that it has an advisory. The last column says what obelize can migrate it with: a pack that ships with it, or two releases to compare.

2. Plan one move

uvx obelize upgrade pydantic

upgrade takes the release your dependency files name and the newest release, and uses the pack that suits the move. If obelize has none, it reads the two releases (and the newest of each minor line between them, at most six, since a library warns before it removes) and writes one, as pack synth does. Then it plans the migration like fix and prints the diff. No file of your code is written; its run folder goes under .obelize/.

What the made pack does is deliberately small:

  • A module-level name that the library's own deprecation message says to replace, with one name beside it, is renamed. A method or another member is flagged instead.
  • A name that moved up out of a submodule is moved up.
  • Everything else that changed is flagged and never rewritten, with the library's own message where it gave one: what was removed or moved, a parameter that was renamed, removed or made required, a default that changed, a class whose bases changed, a name that is deprecated.
  • The dependency line moves to the new release.

A pack made this way says in its limitations that a change in what a function does is not in it. Your tests are what show that.

3. Try it before it is written

uvx obelize upgrade pydantic --apply --yes

--apply does not write at once. In a copy of the repository, outside it, obelize makes two environments with uv: one with your dependencies as they are, one with the new release over them. It runs your tests in the first, writes the move to the copy, and runs them in the second. The move is written to your repository only if no test that passed before now fails, errors or is missing; a test that fails once and passes again is reported and not counted. The evidence (the junit files, the output, what each environment held and the verdict) is in .obelize/trials/<id>/.

It asks first because installing runs the build scripts of the packages it installs and the tests run your code. --yes is the answer for a pipeline, and a terminal is asked. A tree with uncommitted changes to tracked files is refused before anything starts.

Exit 3 means tests broke, with their names; exit 6 means the trial could not judge: no tests, no result obelize can read, no test that passed before, tests that did not finish within --timeout, a move that changed no file, a place left for a person, or environments that could not be made. Without tests, write the migration with obelize fix --apply and check it another way.

4. Keep it on a branch

uvx obelize upgrade pydantic --apply --yes --branch

The move is committed to a new branch, obelize/upgrade-<the new release> or the name you give with --branch-name, and your working tree is left as it is. The commit is built with git's plumbing: no checkout, no hook, no signature. obelize never pushes; open the pull request yourself, or let a workflow do it.

What it will not do

  • It writes a move to the whole repository or to none of it. A library that keeps its import name across the move is one distribution for both APIs, so one place that obelize cannot migrate holds every other file. A real repository often has such a place, and the trial then ends 6 until a person or an agent has moved it. With a coding agent hands those places over and keeps the check.
  • It finds a name through the module (pydantic.BaseModel.parse_obj), not through an object whose class it cannot tell, such as a subclass of BaseModel you wrote.
  • It cannot see a name a library builds when it is imported. If a module makes names on demand, the command counts the names it could not judge instead of calling them removed.
  • It installs only from the index uv is configured for, and only in a trial.

A library can change more names than a pack file can hold. Then upgrade makes the pack for the names your code reaches through its imports and says so (... changed <n> names, more than a pack holds. It flags the <m> name(s) this repository uses.). The names come from the import statements and the attribute chains that start at what they bind; a name reached any other way, through an object's attribute for one, is not among them.

When places are left for a person

A real move often leaves places that a pack made from two releases can only flag, and a library that keeps its import name has one release for both APIs, so the rules write nothing until every one has moved. With your own coding agent installed, obelize can put those places to it:

uvx obelize upgrade pydantic --apply --yes --branch --agent claude_code

In the trial's copy, each flagged place is sent to your claude program as a text question: the pack's words about it (the library's own message where it gave one), the closest new names that hints.json offers if there are any, and the lines around it, nothing else, with every tool of the program turned off. The answer must pass the same guard as any model proposal. The accepted edits go into the copy, the rules then write the rest, and your tests judge the whole. Only a move the tests agree to is committed, and only to a branch; the commit message names the files the agent edited, which are the lines to read first. --max-asks limits how many places are asked about.

An answer may also list the imports it needs, which obelize adds at the top of the file, or say that a place needs no edit at all, such as a call that passes none of the arguments that changed. That claim writes nothing and only your tests check it, so the commit message names each such place.

If tests that passed before the move fail after it, the agent is put those too, with the error and the line of your code the traceback points at, for up to --max-rounds rounds. It never edits a test.

Runs on real repositories

The first two runs were on a public repository, academic-mcp at the commit the benchmark pins, and none changed a clone's working tree.

  • upgrade PyPDF2 --apply --yes --branch: the bundled pack changed the import in 19 files and the dependency line to pypdf. The 60 tests that passed before the move all passed after it, and a commit was made.
  • upgrade mcp --from 1.28.1 --to 2.3.0 --apply --yes --branch --agent claude_code: no pack existed, so one was made from the two releases. It flagged the three places that used FastMCP, and the agent moved them, given the new names that hints.json offered. The 99 tests that passed before passed after, and the commit held the one file the agent edited. The dependency line, mcp[cli]>=1.28.1, carries an extra, so it was left, and the summary and the commit message said so.

The third run needed two fixes before it passed. upgrade pydantic --from 1.10.26 --to 2.14.1 --apply --yes --branch --agent claude_code on a small scraper (THSR-Ticket, whose tests that need the network were left out with --test-command) first flagged 55 places, most of them every Field(...) call, because the new release deprecates one of its parameters and gave repr a private default. Neither is a change a call shows, so the pack now flags only the @validator places. The agent moved none of them: the replacement needed field_validator, imported at the top of the file, outside the lines it was sent. A proposal may now list the imports it needs, which obelize adds to the file's import block, and the import of a name is covered once every place that used it moved. The agent then moved the ten decorators, the 23 tests that passed before passed after, and the commit held the one file it edited and the dependency line.

The fourth, on ga4gh/openapi-test-runner with pydantic 1.10.13 and the same flags, flagged nine places (validator, EmailStr.validate, pydantic.tools). The agent moved the validators, rewrote EmailStr.validate(x) as TypeAdapter(EmailStr).validate_python(x), joined both new names to the import line the files already had, and the 55 tests that passed before passed after. It also added a check for None that a version 2 validator needs, which is why the commit message names the files it edited.

What they show is that the steps join up, in four runs on three repositories. They do not show how often a move passes: for that, the benchmark is the evidence.