Repository navigation
Releases
v0.10.0
obelize upgrade now writes part of a repository where the library keeps its import name, which it
wrote whole or not at all, and takes --from where a manifest declares the library, which it took
only where none did. With 0.9.0 its dry run wrote a file for none of 80 public repositories; with
this release it writes one for 5 of them, and a trial of those 5 passed for 1 (the benchmark below
says how small that is).
A move can be partial now. A file that holds a place the pack can only flag is left as it was, the
others are written, and the summary, REPORT.md, the commit message of --branch, the pull request
of the action and trial.json name the files left and say that a passing trial does not show the
rest has moved. Without --branch such a move ends 4, as fix --apply does where places are left
for you. A pin that rules out the release --from names (pydantic==2.5.0 with --from 1.10.26)
stops the plan, where the pack's floor let it through in 0.9.0. Everything else is reached as it
was: fix, scan, --pack and an upgrade with an agent set up (--agent, or agent.backend in
your own file) keep the rule of one repository whole or not at all, and scan and fix with the
bundled packs gave the same output as 0.9.0 on the repositories of the test suite and the examples.
Benchmark
I took the dry run of obelize upgrade again with this release, over the same 80 public
repositories at the same commits (twenty each that still use the old API of pydantic 1, SQLAlchemy
1.4, urllib3 1 and pandas 2), so its records read obelize 0.10.0. With no pack it writes a file for
5 and none for the other 75: 32 stop before planning, 32 are held and 11 have no usage of a name the
pack lists. The 0.9.0 dry run wrote no file for any (36 stopped, 33 were held and 11 had no usage).
The 5 are all pydantic. It finds 944 usages: 78 are places a rule would rewrite (all in pydantic,
rename_member), the pack only flags 810 and 56 are held for a cause of its own, and 14 are in a
file the dry run would write; 0.9.0 found the same 944, none of them in a file it would write. Of the
32 that stop, 14 stop on a manifest that declares the library in a way the pack does not take
(legacy_version_unsupported; 18 with 0.9.0, of which 12 do not stop now, and 8 that were held do)
and 18 on a declared Python that the new release does not run on. 4 of the 5 that write had stopped
on legacy_version_unsupported with 0.9.0, and 1 was held by a place left.
The trial, which installs the dependencies of a repository and runs its tests, is measured for the
first time, in a box that reaches only the package index, over the 5 whose plan writes a file (the
results). It passed for 1 (with 20 places left for a person
and 3 tests compared), could not install the dependencies of 3 and got no test result from 1. The 3
have three causes: no recipe uv knows, a package that builds against a system library the image
lacks, and a requirement on a GitHub URL, which the box does not reach. That is one repository and
not a rate: n is 5 of 80, drawn by code search of repositories that import the old API, and a pass
means that the tests that passed before still pass, 3 of them here. It says nothing about the 20
places left for a person or about code no test reaches. The box is arm64 Linux, has no GitHub and
lacks system libraries.
What limits the number now is the pack. A pack made from two releases mostly flags: 810 of the 944
usages (85.8%) are places it only flags and never rewrites, and in pandas, SQLAlchemy and urllib3
none of the 663 is a place a rule would rewrite, so those three frames write nothing.
scan and fix with the bundled packs gave the same output as 0.9.0 on the repositories of the test
suite and the examples, and the fixtures of the packs are unchanged and pass, so Gate 1's scan and
the three fix dry runs are the records 0.9.0 made and read obelize 0.9.0; they were not taken
again.
Unsupported patterns
What the 0.9.0 list says still holds, except what it says of upgrade with no pack: that --from
counts only where no manifest declares the library, and that a repository is written only when
nothing in it is left. This release changes both, and what is unsupported now is:
- A pack made from two releases rewrites a name that the library's own message says to replace with
a name that exists in the newer release, and a name that left a submodule for its package, and
reports the rest. In these frames a rule rewrites something for pydantic only (rename_member). - A file with a place the pack only flags is left as it was, so a written move is partial where one
exists: the files that hold such places run on the new release until someone moves them, and a
passing trial does not say they are done. 30 of the 32 held repositories have such a place, and 2
have a file the scan cannot parse. A dependency line the rule cannot write (a requirement with
extras the new release does not list), a notebook, a stub, a.pywscript and a file obelize
could not open still hold the whole repository, and so dofix,scan,--packand anupgrade
with an agent set up. - A declaration of the library that rules out the release
--fromnames, or that obelize cannot
read, stops the plan: 14 of the 80 stop onlegacy_version_unsupported. - The trial is measured over 5 repositories, of which 1 passed. A repository whose dependencies need
a system library the box lacks, or a requirement on GitHub, ends as an error there.
The formats that changed: none of the JSON Schemas, and not the pack format. trial.json, which has
no schema, gains held, the files the move left as they were. In run.json, packs[].stated is
also set where every declaration of the library admits the release --from names and is never set
together with assumed, and blocked is legacy_version_unsupported also for a declaration that
rules that release out, which the pack's floor passed on its own in 0.9.0. The exit code of
upgrade --apply is 4 where a passing trial left places for you; a move to another distribution
that left places (PyPDF2 to pypdf) ended 0 in 0.9.0 when no verification command of your own
ran.
Added
- The harness that measures the trial (
bench/trial_box.py,bench/trial_collect.py,
bench/trial_score.pyandbench/trial/) is a tool of the repository, in neither the wheel nor
the source distribution: installing obelize adds no Docker code and no way to run another
repository's tests. The trial ofupgrade --applythat you run is unchanged by it.
Changed
obelize upgrade --from <release>takes that release for the one in use where every dependency
file that declares the library admits it, and not only where none declares it:pydantic,
pydantic<2,pydantic>=1.8andpydantic = "^1.9.1"all admit--from 1.10.26. The plan goes
with your word against the file's, says so in its summary and inREPORT.md, and a dry run does
not check it;--applydoes, with your tests. A declaration that rules the release out
(pydantic==2.5.0,pydantic>=2,pydantic<1.5), or that obelize cannot read, stops the plan
and says so, including one that reached the pack's floor on its own, which--fromwas not asked
about before. Where the pack would take a release of its own for such a declaration (its newest,
forpydantic>=1.8), your--fromis the release the plan takes and says, andrun.json
records it asstatedand not asassumed.fix,scan,--packand anupgradewithout
--fromare gated as they were
(the decision).obelize upgradewrites a file at a time where the library keeps its import name (one
distribution for both APIs:pydantic,SQLAlchemy,openai,numpy), in its dry run, in the
trial's migration and in the write after a passing trial. A file that holds a place the pack can
only flag is left as it was, every other file is written, and the dependency line follows the
files written, where a single such place used to hold the whole repository back. The trial runs
your tests on the move as it would be written and writes nothing if one that passed before fails.
The summary,REPORT.md(a Held back section), the commit message of--branch, the pull request
of the action andtrial.json(held) name the files left and say that a pass does not show that
the rest has moved. Without--branchthe command ends4for such a move, asfix --applydoes
where places are left for you, and0where none is, whether a verification command of your own
ran or not; with--branchit ends0after the commit either way, so read the text. A dry run
that plans part of a repository ends with theupgradecommand that writes it, notfix --apply,
which would write none of it. The whole repository is still held where no file can be written,
where the dependency line cannot be written, and forfix,scanand--pack, and for an
upgradewith an agent set up (--agent, oragent.backendin your own file), whose dry run,
trial and write all keep that rule, since the agent is asked about the places only when a first
run of the rules writes nothing, and whose dry run says the agent is why.upgrade numpyand
upgrade openaiwrite the files that hold no place where 0.9.0 wrote none
(the decision).- The upgrade mode of the action names the files a partial move left in the commit message and in
the pull request ("Left as they were, for you to change"), and a name of such a file that the
action would not write itself (tools/run:all.py, a decomposed Unicode name) is only shown as
text, so it does not refuse a branch that was written whole. - A trial of
obelize upgradethat changed no file says what held the repository: a place left in a
file,...
v0.9.0
obelize can do more for a library it has no pack for, and ships more packs. A pack can be about the
Python itself (datetime.utcnow(), pkg_resources, the modules Python 3.12 and 3.13 removed), hand
its files to a maintained tool (numpy/numpy-1-to-2 runs your own ruff), and flag a changed
signature only at the calls it can break. pack synth and upgrade use the last, and can take
claims from a migration guide that a model you set up has read and code has checked. A requirement
that names extras keeps them when its pin moves, and the GitHub Action can upgrade a dependency in
one job and open the pull request from another. Everything new is reached by a pack or a flag that
uses it: an explicit --pack run of the three bundled packs of 0.8.0 writes what it wrote (their
fixtures are unchanged and pass, the benchmark records below agree) and their pack hashes are
unchanged.
What does change for every repository is what a default run finds, since a default run uses every
pack and there are now seven. The three packs for the Python report what they cannot rewrite, so a
repository that only has from distutils.core import setup in its setup.py exits 0 from
scan --fail-on eligible --fail-on needs_review --fail-on unsupported in 0.8.0 and 4 now,
fix --apply exits 4 where it said "No pack applies", and the GitHub Action, whose default
fail-on is those three statuses, fails the job. The same holds for pkg_resources, cgi,
telnetlib, utcnow() and the other surfaces the packs name, and for np.float_, np.NaN and the
other names numpy/numpy-1-to-2 names. Pass --pack to run the packs you mean, or fail-on to the
statuses you accept (docs/CLI.md, UPGRADING). A default scan
also parses a file that holds a token of one more pack, once for each pack that names it: numpy,
pkg_resources, utcnow, utcfromtimestamp or the name of a module Python removed.
What is deliberately not done: obelize installs no tool (the ruff that numpy/numpy-1-to-2 runs is
yours, on your PATH), and ruff is the one fixer in the registry. --agent takes claude_code
only: a backend for codex exec was checked against its documentation on 2026-10-27 (CLI 0.164.0)
and not built, since it documents features.shell_tool and permission profiles but no setting that
leaves a model without tools, so a text-only run cannot be guaranteed
(ADR-076, which says what would change that).
upgrade --from still takes a release as the one in use only where no manifest declares the library.
Benchmark
I took Gate 1's scan and the three fix dry runs again with this release, over the same 80
repositories each at the same commits, so they read obelize 0.9.0. 317 of the 320 records are the
ones 0.8.0 gave, finding for finding and withheld row for withheld row. miguel-webplace/questiFy
of the Gemini frame is not served by GitHub now, so its record in the scan and in the dry run is an
error and that frame's shares are over 79 repositories. ZeroTang05/cyber-doctor of the PyPDF2
frame, an error in the 0.8.0 records, is served again and its record is the one 0.7.0 gave, so that
frame's shares are over 80 repositories again.
The dry run of obelize upgrade with no pack is new: four frames of twenty public repositories that
still use the old API of pydantic 1, SQLAlchemy 1.4, urllib3 1 and pandas 2, each planned with a pack
made from the two releases. Over those 80 repositories it writes no file: 36 stop before planning,
33 are held and 11 have no usage of a name the pack lists. It finds 944 usages: 78 are places a rule
would rewrite if nothing else were left in the repository (all in pydantic, rename_member), the
pack only flags 810 and 56 are held for a cause of its own, and none is in a file the dry run would
write. That is what a plan holds: no --apply was run on these repositories, so how often a move
passes their tests is not measured (the results).
Unsupported patterns
docs/KNOWN_ISSUES.md has a new section on the packs for the Python itself
and one on the tools a pack can run. What it names:
- None of the three packs for the Python has been run over public repositories, so how often they
write is not known. python/datetime-utcnowrewrites calls only:field(default_factory=datetime.utcnow)and a
SQLAlchemydefault=datetime.utcnoware reported and keep their file as it was. The value stays
naive, asutcnow()made it; a test that mocksdatetimein a module of its own is not seen and
fails after the rewrite.setuptools/pkg-resources-to-importlibwrites nothing and is gated at Python 3.10.python/removed-stdlib-modulesrenamespipes.quoteand reports the rest. It leaves outimp,
uu,nis,cryptandchunk, whose names sit insideimport,uuid,finish,encryptand
chunk.- A default scan, which runs every known pack, parses the files that hold
pkg_resourcesor the
name of a removed module once more for each of these packs, and those that spellutcnowor
utcfromtimestampfor the datetime pack. Over a synthetic repository of 4,000 files a default
scan took 2.3 s, against 2.5 s before the packs. - A comment inside a rewritten call moves above the statement that holds it.
numpy/numpy-1-to-2rewrites 23 names, reports the others the migration guide lists, and holds
the whole repository back for any one of those, the pin included. It does not see what NumPy 2
changes besides names (promotion, the default integer on Windows, the C API), nor strings. It moves
a requirement that excludes NumPy 2 (numpy==1.26.4,numpy<2) tonumpy>=2and leaves one that
admits it (numpy>=1.22) as written, but it does not judge the other pins beside it (a
pandas==1.5.3), so install the result. It was run over five public repositories and has no
Gate 1 measurement. A fixer that moves an import, renames a keyword or needs the target release a
repository states (ruff'sUP035andAIR301,django-upgrade,pyupgrade) is not registered
(ADR-074).--guidehas been run once on a public guide (pydantic 1 to 2) and measured nowhere else. What the
model drafts is its recall, not obelize's: a first prompt gave 13 claims from the guide and the
second 41 and 47 in two runs. A change in another distribution, a class body (class Config) and
what a function does have no name to hold a claim to, a table or a page built in the browser may
not be read, and a rewrite rests on the API diff and not on the guide, so your tests decide whether
it means what the old call did (the section on migration guides in KNOWN_ISSUES.md).- A name that a pack flags only at some calls is not listed where it is handed to another call
(map(pd.to_datetime, rows), a callback), whatever positional arguments the code that receives it
passes, and a deprecation of the whole name that happens to use the word of a changed parameter
lets a call by. A name that also left or moved, a function that changed kind and every method are
still listed wherever they are used (the section on upgrading a dependency in KNOWN_ISSUES.md). upgradewith no pack stopped 18 of those 80 repositories on a manifest that declares the library
loosely (pydantic,pydantic<2,^1.9.1) or below the release the pack was made from, with
legacy_version_unsupported, though--fromnames a release inside the pack's range: a declaration
decides, and--fromcounts only where none exists. Whether it should overrule a loose one is not
decided (ADR-072).- A repository's plan is written only when nothing in it is left, for a library that keeps its import
name, so one place the pack can only flag holds the rest of the repository: 32 of the 33 held
repositories had such a place. The section on upgrading a dependency in KNOWN_ISSUES.md has both.
The formats that changed are pack.yaml and pack.schema.json, which describes it: a side of a
migration is a distribution (package and version) or the Python (python), and not both, and
to.requires_python is required only with a distribution, a rewrite_pattern takes imports,
each of a closed list of modules, flag_only takes calls, external_fixer is a new kind (fixer,
symbols), manifest_dependency takes provides_extras, a change may carry a source of its own
whose type may be guide, which is never bundled, and a module of a shared pack may have no
prefilter token in its name when each symbol under it ends in one. The id of a pack made with a
guide ends with -with- and a digest of the guide's text, and guide.json is the new record beside
such a pack. run.json gains packs[].fixers and packs[].stated, and eight codes join the bail
vocabulary and the bails an import row may carry, which findings.json, plan.json,
proposal.json and run.json list (external_tool_missing, external_tool_version_unsupported,
external_tool_failed, external_tool_timeout, external_tool_size_limit,
external_fix_out_of_scope, external_fix_incomplete, external_fix_not_idempotent). The reasons
alias_collision and local_import are also given for a rewrite_pattern that cannot name a module
it imports, the text of runtime_unsupported for a pack whose new side is the Python names the pack,
and a finding's evidence, which was always null, may carry the words of
SCAN_VOCABULARY.md section 14 (findings.json follows).
Added
- A pack made from two releases can take what the library's authors say in prose.
pack synthand
upgradetake--guide <url|file>(up to three), and a model you set up in your own
configuration (the endpoint inmodel, else the agent inagent) reads the guide and drafts
claims: a name renamed, a meth...
v0.8.0
A pack can say more than it could. It can rename a method of a library's class where your code
reads it off an instance of a class you wrote, rewrite a call from the shape of its arguments, and
hand a change to a plugin you install and allow, and pack synth uses the first for the methods a
library's own message says plainly to replace. Everything new is reached by a pack that uses it,
and what the three bundled packs write is what it was.
Benchmark
I took Gate 1's scan and the three dry runs again with this release, over the same 80 repositories
each at the same commits. 319 of the 320 records are the ones 0.7.0 gave, finding for finding and
withheld row for withheld row, and now read obelize 0.8.0. The other, ZeroTang05/cyber-doctor of
the PyPDF2 frame, has been deleted from GitHub and cannot be fetched, so it is recorded as errored
and that frame's shares are over 79 repositories.
Unsupported patterns
docs/KNOWN_ISSUES.md has two new sections, on members of a class and on
plugins. What they name:
- A member is renamed only where the file shows what its receiver is: a call of a class that
derives from the library's,selfandclsin a method of one, an annotated parameter or
variable, a name every assignment of which is one. A value a function returned, a field of
another object, an argument with no annotation and a loop variable are left as they are, and
not reported. - A class is found by the name an import gives it, at the module level of the files the walk
selects. One made in a function, bytype(...), in an excluded directory or throughfrom module import *is not found, nor is a mixin that comes from another library. - If any class of the repository defines a member a pack renames, no read of it off a receiver of the
library's class is renamed (member_overridden), since the receiver may be an instance of the
subclass that defines it. - A pattern spells only names of the module it calls (spelled as the file spelled the module), a pure
builtin or a metavariable; a call written without its module has none to spell by. An expression a
pattern would run twice or never is left for you, and the order a pattern writes its arguments in may
change the order their side effects run in. - A plugin is code of yours: obelize does not sandbox it, limit it, or read what it sends. It is found
with the working directory off the import path, so a plugin you develop must be installed. pack synthrenames only a method whose deprecation message says no more than which method to
use;copyandfrom_ormof pydantic stay flags, with the library's words.
The formats that changed are pack.yaml (the kinds rename_member, rewrite_pattern and plugin,
with members, takes, before, after, where, plugin and requires_plugins), the reasons
and codes of findings.json, plan.json and run.json (receiver_typed_by_class,
member_overridden and pattern_unmatched), run.json (packs[].plugins) and the user config
(plugins), with the JSON Schemas that describe them. pack synth writes rename_member where it
wrote a flag, and a scan of a pack that renames members of a class reads the class headers of every
Python file first and parses a file that names a class deriving from one.
Added
- A pack can rename a member of a class (
rename_member):user.dict()becomesuser.model_dump()
whereUserderives from the library'sBaseModel. The scan types a receiver from what the file
shows (a call of such a class,selfandclsin a method of one, an annotated parameter or
variable, a name every assignment of which is one) and leaves any other alone. A class that another
file of the repository defines is found, through the names its imports give it, though the file that
uses it never names the library. A member that a class defines itself is withheld as
member_overridden. No bundled pack uses the kind. - A pack can rewrite a call from the shape of its arguments (
rewrite_pattern): two one-line
templates with$namefor any expression, such aspydantic.parse_obj_as($t, $v)to
pydantic.TypeAdapter($t).validate_python($v), or$m.dict(exclude=$e)to
$m.model_dump(exclude=$e)on an instance of a named class. The call is found by its name and
compared with the first template by form; a call of another shape is withheld as
pattern_unmatched. No bundled pack uses the kind. - A rule kind can come from a plugin: a distribution you install that registers an entry point in
the groupobelize.kinds, which a pack names withkind: pluginbesiderequires_plugins.
A plugin is code that runs in obelize's process with your rights, so nothing is imported until you
allow its distribution and entry point in~/.config/obelize/config.yml(a repository's
.obelize.ymlmay not, and a bundled pack never asks); a pack that needs one you have not allowed
stops with exit code7before any file is read. The run folder records each plugin's release and
the hash of itsRECORD. The interface isobelize.plugin, described in
Writing a plugin; obelize does not contain what a plugin does. pack synthwritesrename_memberfor a method the library deprecated, or removed after saying so,
with a message that says only which method to use, when a call of the old one can move to the new
(pydantic 1 to 2:parse_obj,construct,schema, anddict,jsonandupdate_forward_refs
withtakes). A call that passes a keyword the new method lacks is left for you. What a pack flags
on such a class is flagged where it is read off an instance as well.
v0.7.0
obelize can now move a dependency that no one has written a pack for. obelize upgrade finds what is
behind, makes a pack from two releases of the library, tries the move in a copy of your repository
with your own tests and writes it only if no test that passed now fails. With your own coding agent
it also moves the places a pack can only flag, and it can commit the result to a branch. Around it
are the pieces that let a pipeline and a coding agent use obelize: SARIF, --fail-on, an MCP
server, a GitHub Action and a pre-commit hook. Everything new is reached by a command or a flag,
apart from the few changes listed under Changed, and what the three bundled packs write is what it
was.
Benchmark
I did not run round 1 again. Its numbers are still 0.1.0's and are for the Gemini pack alone: 25 of
277 usages (9.0%) migrated in 20 repositories with no wrong edit. I did take Gate 1's scan and the
three dry runs again with this release, over the same 80 repositories each at the same commits.
All 320 records are the ones 0.6.0 gave, finding for finding and withheld row for withheld row, so
they now read obelize 0.7.0 and nothing else in them moved.
The commands added in this release have no benchmark of their own. Four runs of upgrade on three
public repositories (docs/UPGRADING.md) passed their
own tests before and after the move. They show that the steps join up, not how often a move
passes.
Unsupported patterns
docs/KNOWN_ISSUES.md has a new section on upgrading a dependency. What it
names:
- A trial installs packages and runs their build scripts, and your tests, as you on your machine. The
question it asks first is the only gate: nothing sandboxes them. - A library that keeps its import name across the move is one distribution for both APIs, so
upgradewrites to the whole repository or to none of it. A trial of a real repository ends in
exit6until the places a pack only flags have been moved, by you or by--agent. - A name is found through its module (
pydantic.BaseModel.parse_obj), not through an object whose
class obelize cannot tell, such as a subclass you wrote.User.parse_obj(...)and.dict()are
not seen, and a method that is deprecated but still works shows only if a test breaks. - A parameter that was renamed, removed or made required is flagged and never rewritten.
- A requirement with extras (
mcp[cli]>=1.28.1) is left as it was, and the summary and the commit
message say so. - The environment for the release in use holds today's resolution of the dependencies you did not
pin, so a test that fails there for that reason is not blamed on the move. - A module that makes its names when it is imported (pydantic 2's top level, for one) is counted as
names obelize could not judge, not as removed. - A failing test whose error names no line of your code cannot be put to the agent.
- The agent is asked about the places a pack flags only when the first run of the rules wrote
nothing, as it does for a library that keeps its import name. Where the rules wrote some files, it
sees only the places where tests then broke. - A flagged place in a test file is put to the agent like any other. Only the repair rounds leave
test files alone, and the commit message names every file the agent edited. - A library that changed more names than a pack file holds (pandas) gets a pack for the names your
code reaches through its imports, and a name reached any other way is not in it. - It reads Python only, the trial needs uv, and
claudeis the one agent program it can use. The
agent's tests run on Linux and macOS, not on Windows.
The formats that changed are pack.yaml (source.type: api_diff, and rename_symbol's renamed and
calls, with symbols no longer required), the model proposal (imports, and the reason
no_pack_for_usage), findings.json, plan.json and run.json (the reason no_pack_for_usage,
packs[].source: inventory and verify.edited), the user config (network and agent) and the
JSON Schemas, which gain outdated.schema.json. pack synth writes a new file, hints.json,
beside the pack it makes, and a trial writes a new folder, .obelize/trials/<id>/, with a
trial.json that no schema describes yet.
Added
obelize scan --sarifprints the findings as SARIF 2.1.0, which GitHub code scanning reads.obelize scan --fail-on <status>exits4when a finding has the status you name (eligible,
needs_revieworunsupported), and the run'sexit_coderecords it.obelize verify --jsonandobelize undo --jsonprint therun.jsonandundo.jsonthey wrote.obelize scan --library <distribution>lists every place a repository uses a library that has
no pack, with--moduleto name the modules when the library is imported under another name.
Each place isneeds_reviewwith the new reasonno_pack_for_usageand nothing is rewritten. The
run recordssource: inventoryinpacks[]and holds no copy of a pack.obelize pack new <id> --from "<requirement>" --to "<requirement>" --guide <url>makes a pack
that loads, with fixtures and sources to edit.obelize pack test <id|path>runs a pack over its own fixtures and sources: the checks the three
bundled packs pass in the test suite, now available for a pack of your own.obelize verify --currentruns a run's verification on the tree as it stands instead of
answeringtree_changed, and records the files that differ from what the run wrote in
verify.edited(a new field ofrun.json, empty for any other verification). An--applyrun
that wrote no file can be checked this way, so a migration that an agent made by hand can be
verified.obelize mcpserves obelize to a coding agent over the Model Context Protocol, on standard input
and output and with no new dependency. Seven tools:obelize_scan,obelize_plan,
obelize_apply,obelize_verifyandobelize_undorun the commands and return what they print,
obelize_reportreturns a run'sREPORT.md, andobelize_residuelists the places obelize could
not migrate with the code around each. No tool takes a command, a path, a model or a consent, and
--read-onlyleaves out the two that write files.action.ymlis a GitHub Action that runs a scan, annotates each place on its file and line,
uploads the findings to code scanning if asked and fails the job on the statuses you name.
.pre-commit-hooks.yamloffers the same scan as the hookobelize-scan.obelize pack synth <distribution> --from <version>makes a pack for a library that has none. It
asks the package index for two releases and, for the names that left, the newest release of each
minor line between them (at most six, newest first, until each name is found), opens each for its
Python source and the metadata that names its modules, reads what each exports without importing
or building anything, and writes a pack: a name the library's deprecation message says to replace
is renamed, a name moved up out of a submodule is moved, everything else that changed is flagged
with the library's own message where it gave one, and the dependency line moves. The pack must
passpack testor it is not kept. Every host is printed before it is contacted, answers are
cached for you alone, and--offlineuses only the cache.- A function is not flagged only because one of its parameters is deprecated or because a
parameter's default became a sentinel such as_Unset: the library decides the value
elsewhere, which a name cannot show. Two old names that become one new name are flagged and not
renamed. - A wheel's compiled library does not stop the archive from being read (numpy), and a fixture
names at most 60 of a large library's names. --used-by <path>makes the pack for the names the code in a folder reaches through its
imports, for a library that changed more names than a pack file holds (pandas, for one).
upgradedoes so by itself and keeps the pack for that repository. A star import of the library
is refused, since it leaves no telling which names are used.
- A function is not flagged only because one of its parameters is deprecated or because a
obelize outdatedlists the dependencies a repository declares that the package index says have
moved on: the release in use, the newest, the size of the gap, whether the library marks itself
inactive, the release was withdrawn or has an advisory, and what can migrate it (a bundled pack,
or two releases to compare). It lists a dependency only if it has a flag or a major gap, unless
you pass--all.--jsonprints the rows, described by the newoutdated.schema.json.obelize upgrade <distribution>shows what moving one dependency to a newer release would change:
it uses the pack that migrates it, or makes one from the two releases and keeps it, then plans the
migration asfixdoes without--apply. It writes no file of your code, only its run folder
under.obelize/. With no dependency it lists those thatoutdatedshows and obelize can
migrate, and asks which to move; where no one can be asked it prints the list and exits2.obelize upgrade <distribution> --apply --yestries the move before it writes it: in a copy of the
repository and two environments made with uv (the release in use, and the release moved to), it
runs the repository's own tests before and after the migration, compares them test by test and
runs the ones that broke once more. The move is written only if no test that passed now fails; the
evidence is.obelize/trials/<id>/. It asks first, refuses a tree with uncommitted changes to
tracked files, and exits3for tests that broke and6for a trial that could not judge.
--timeout <seconds>sets how long the tests may run, and tests that did not finish are reported
as that.--offlinekeeps uv to its cache.--branchcommits a passed move to a new branch
instead o...
v0.6.0
Benchmark
I did not run the benchmark again for this version. Round 1's numbers are still 0.1.0's and are for
the Gemini pack alone: 25 of 277 usages (9.0%) migrated in 20 repositories with no wrong edit. The
scan record of gate 1 and the three fix dry runs were measured again against the packs and the
engine of this version, and they read obelize 0.6.0. Each pack's record is bound to the sha of
the pack it was taken with, so a changed pack fails a test until it is measured again.
- Gate 1: scan precision is 100.0% and recall 98.4% over the same 64 usages in five repositories, and
the zero-bail share is the 0.1.0 record's 57 of 80 repositories (71.2%). The record was taken
again withobelize scan --pack(the harness now names the pack, since a scan without it runs
every pack a repository uses). openai/openai-0-to-1: complete 12 to 27, blocked 41 to 27 and held 27 to 26. Nine repositories
moved from blocked to complete, six from held to complete and six from blocked to held, and one
moved from held to blocked on a Python floor that is now read.py-pdf/pypdf2-to-pypdf: complete 31 to 32, blocked 35 to 36, partial 6 to 5 and held 6 to 5.
Two repositories moved from blocked to complete because arequirement.txtis now read. Three
moved to blocked: apypdfpin that admits nothing the pack writes (twice) and a Python floor read
fromsetup.py(once).gemini/google-generativeai-to-google-genai: complete 35 to 27, held 32 to 40, partial 13 to 12
and blocked 0 to 1. Eight repositories moved from complete to held and one from partial to held,
because a read ofresponse.textinside a bare,Exception,BaseExceptionorValueError
handler is now refused, which is the price of failing closed. One moved from held to blocked on a
Python floor read from aPipfileorsetup.py.
These are plans that wrote something, not accuracy: there is no answer key for these frames, and
"complete" means a file written and nothing withheld, not a correct migration. The dry-run records
also keep the declaration a blocked pack stopped at. The benchmark scripts are a package now and run
as python -m bench.<name>.
Unsupported patterns
docs/KNOWN_ISSUES.md lists what I know about and have chosen not to fix yet,
and most of what 0.5.0 listed is gone from it. What it names now:
- A file nested more than 300 levels deep is refused as one that does not parse, and a few hundred
parts of an implicit string concatenation, which that count does not see, fail through libcst's
own limit. The codemod's stack use per level is not measured. openai: a result bound to a module-level name is checked against the modules that nameopenai,
so a module that imports it and never says so goes unseen. A requirement with extras, a hash, a
continued line or a URL holds the repository until you edit it, and the old pin in a form the
reader does not open (an-rof a file with another name, a Dockerfile,tox.ini) stays with no
row. A notebook that calls the old API holds the pin until you migrate it by hand.PyPDF2: a requirement obelize cannot rewrite in place (extras, a hash, a continued line) leaves
the manifest onPyPDF2while the code moves topypdf. Releases 1.x and 2.x are flagged and
never migrated. Installer settings outside the repository (apip.conf,PIP_*variables) are
not read, on purpose, so that a finding is the same on every machine.- A model endpoint that drips the headers or the chunk sizes of a reply is held back only by the
socket timeout of each read.
The formats that changed are pack.yaml (from.since, to.introduces, manifest_dependency's
declare_transitive, rewrite_call's value_maps, literal_prefixes and warnings_unless,
flag_only's variables and the rename_symbol kind), the model proposal (replaces), the user
config (model.allow_insecure_http), findings.json and run.json (the bails internal_error and
new_version_unsupported, the limitation code internal_error, the warnings
response_text_may_be_none and upload_display_name_unset, the guard refusal
replaced_text_differs), the dry-run and round records (new provenance fields), and the JSON
Schemas, which now carry the patterns of the values the models check.
Added
- A model proposal must quote the lines it replaces (
replaces), and the guard refuses one whose
quote is not the text of those lines as sent (replaced_text_differs); a reply without it is
unanswered asproposal_does_not_hold. The code sent to a model carries each line's number in the
file (12: code), added after redaction, and the prompt says the numbers are not code. model.allow_insecure_http: a plainhttp://modelbase_urlto a host other thanlocalhost,
127.0.0.0/8or::1is refused unless it istrue.- An unexpected error on one file no longer ends a run. A scan reports the file as one
parse_error,
andfixleaves it as it was and withholds its rows as the new bailinternal_error;
run.json'slimitationsnames the exception's class and never its message. - A manifest that pins the new distribution so that it admits no release the pack writes
(pypdf==3.0.0,pypdf<6) blocks the run asnew_version_unsupportedand names the path. pack validaterefuses a pack whose twoflag_onlychanges name one surface, naming both.- After a manifest pin is written, the summary and
REPORT.mdname the lock files to regenerate,
and the fix summary says what to change for each reason a finding was left (also in
REPORT.mdandSCAN_VOCABULARY.md). - A file that uses the legacy API and that no rule writes (a notebook, with the cells that call it,
or a stub) is named in the terminal summary and under Limitations inREPORT.md, also where the
pin has already moved and the run used to say "No pack applies". - Warnings on a rewritten Gemini call:
response_text_may_be_nonewhere the result's.textis
read (it isNonenow where it raisedValueError), andupload_display_name_unseton a
rewrittenupload_filethat leaves outdisplay_name, whose old call named the file after its
path. - Benchmark rounds resolve every install as of the round's start (
UV_EXCLUDE_NEWER, repeatable
with--exclude-newer) and record uv's version, that cutoff and a hash of the installed set in
each case's provenance. Results also recordforbidden_reported, the findings at a position the
answer key forbids, andbench/CASES.mdprints how many of the 38 forbidden positions the scan
reported.
Changed
-
A file that an
-ror-cline names is read whatever its name (inside the repository, eight
includes deep, never written): a legacy pin in it holds the pack with one distribution for both
APIs, and the newest-release check no longer stops at the include alone. -
A
.pyfile that the scan cannot read (overmax_file_bytes, or unopenable) holds the pin of a
pack with one distribution for both APIs (openai), and its limitation says so; raise
max_file_bytesor list the file underexclude. A file over 32 MiB that the newest-release
search skipped is named as a limitation. -
The
model:block of the user config and a pack'sretrieved_atrefuse a value of another type
instead of converting it (log_prompts: 'yes', a quoted date). -
The code of a model reply that
compile()refuses isoutput_does_not_compile, and
output_does_not_parseis for a resultcompile()accepted and libcst cannot read. A reply
nested deeply enough to crash libcst's parser is refused before libcst sees it, and a statement
over 500 tokens in a replacement is refused asreplacement_too_large. -
A model
base_urlwith a query string or fragment is refused, with a message that sends the key to
api_key_env; the failure detail of an unreachable endpoint names the host and port, not the URL. -
A file nested more than 300 levels deep (a long chain of operands, attributes or calls, a dotted
name, anelifladder, about a hundred brackets inside brackets) is refused as one that does not
parse, the same on every--jobsand every Python. On Python 3.14 a chain of about thirteen
thousand terms killed the scan with a segmentation fault. -
A configuration or pack file over 1 MiB is refused unread, with the limit in the message. A YAML
set (!!set) in.obelize.ymlor a pack is refused with a line and column, where it was read in
hash order. -
.obelize.yml, the user'spack_dirsandverify.allow, and a pack'slayout.line_length,
match.sharedandcoroutinerefuse a number, flag or string of the wrong type
(allow_dirty: 'yes',max_file_bytes: '1000') instead of coercing it, so a config that loaded
by luck is refused. -
The published JSON Schemas carry the regular expression of every digest, run id, instant, pack id,
version and symbol the models check, and the length limits of display text; a pack's
pack_versionno longer accepts a digit of another script. -
The
fixtures/directory beside a pack of your own that sits inside the repository is left out of
a scan, not the whole directory the pack file is in: a helper besidepack.yamlthat imports the
legacy API is scanned and holds the pin.REPORT.mdsaysNot scannedand names the directory,
andscan --list-filesleaves it out. -
The
Next: regenerate each lock fileline names only the lock files in the written manifest's
directory or above it. The--jobshelp says that Windows takes at most 61 workers. -
A dependency file is read the way pip reads it: a line ending in a backslash joins the next,
options after a requirement (--hash,--config-settings) are cut, and a hash-pinned, continued
or editable requirement or a local wheel keeps its name and gets no pin edit
(manifest_pin_shape_unsupported) instead of being missed. -
A
dev-requirements.txt,requirement.txtor any other `*requ...
v0.5.0
Benchmark
I did not run the benchmark again for this release. Its numbers are still 0.1.0's and are for the
Gemini pack alone: scan precision 100.0% and recall 98.4% over 64 usages in five repositories, and
25 of 277 usages (9.0%) migrated in 20 repositories with no wrong edit, on the
results page. The
scanner and the rewriter have changed since, so read those numbers as dated. The new gate rule is
measured without the tests: a fix dry run with no model over the 80-repository frame of each pack
plans for openai and Gemini what 0.4.0 planned, and for py-pdf/pypdf2-to-pypdf plans for 19 of the
53 repositories the version gate stopped: 15 complete, 3 partial and 1 held, so 31 of 80 repositories
plan completely against 16 (the other 34 that the gate stops still stop, and one stops on a Python).
That says what is written, not whether the result passes a repository's tests, and "complete" means
a file written and nothing withheld, not a correct migration. openai/openai-0-to-1 and
py-pdf/pypdf2-to-pypdf still have no accuracy figure and no benchmark; they rest on fixtures, on
each change measured against both library versions installed side by side, and on a weekly job that
runs their facts on the oldest and newest releases and now also checks that PyPI has no PyPDF2
release newer than the one the pack takes an unversioned requirement as.
Unsupported patterns
docs/KNOWN_ISSUES.md lists
what I know about and have chosen not to fix yet. The gaps listed under 0.4.0 stand. A PyPDF2
requirement with no version is now taken as 3.0.1 where git lists the files and nothing in them
shows another release, which is a claim about an install nobody ran: the environment the code runs
in, a pip.conf in your home directory and a release another package requires are not seen, and the
section on the PyPDF2 pack lists the rest. The formats that changed are pack.yaml (the optional
from.newest and from.lacks), run.json (packs[].assumed) and their JSON Schemas.
Added
- A pack may name the newest release of the distribution it migrates from (
from.newest) and the
names that release no longer defines (from.lacks), and a declaration that names no version, or
a range that reaches below the floor but admits that release, is then taken as it
(ADR-056). ThePyPDF2pack names 3.0.1 and its
three removed classes. Nothing else in a pack changes: a pack that names nonewestis gated
as before, andtotakes neither field. - A run that took such a release says so:
packs[].assumedinrun.jsonholds it, and the
terminal andREPORT.mdsay that the distribution is declared with no version that rules the
release out and is taken as it. - The weekly end-to-end run fails when PyPI lists a final PyPDF2 release newer than the pack's
from.newest, so the claim cannot lapse unseen.
Changed
- The legacy version gate no longer stops a
PyPDF2repository for a requirement with no version,
or a range that reaches below 3 but admits 3.0.1 (PyPDF2,PyPDF2>=2.10), where the
repository shows no other release. On the 80-repository PyPDF2 frame that unblocks 19 of the 53
repositories the gate stopped: 15 plan completely, 3 partly and 1 only holds. It still stops, and names the file, where one does: code that usesPdfFileReader,
PdfFileWriterorPdfFileMerger; a lock, a constraints file, apip.confor another
installer setting; an index setting inpyproject.tomlorPipfile; a pip option that moves
the index; a manifest that namesPyPDF2where no requirement was read or cannot be read; a
version ofPyPDF2written anywhere git lists that rules 3.0.1 out (a Dockerfile, a CI file, a
tox.ini, a notebook); a virtual environment kept in the repository with another release. A
requirement with an environment marker or a pre-release is not taken, and nothing is taken in a
directory that is not the top of a git repository. Theopenaiand Gemini packs are gated as
before. A repository the gate no longer stops can now have its rows asked of a model you
configured, so its source can reach that endpoint, which the stop had kept it from. - The message for a pack that plans nothing because of what a repository declares (the legacy
version gate, or a Python the new distribution does not install on) now says what the edit
leads to: in a git repository, commit it beforefix --apply, which refuses uncommitted changes
unless you pass--allow-dirty. For a repository that declares the library nowhere, it names the
requirement to declare (PyPDF2>=3) and a manifest that will do (requirements.txt).
Fixed
- A Poetry caret on a
0.xversion is read as Poetry reads it:^0.27stops below0.28and
^0.0.3below0.0.4, not below1and0.1. Nothing in a run changes yet, since only the
floor of a declaration is read today. - A
setup.pyrequirement that code builds around its string ("PyPDF2" + VERSION,
"PyPDF2" ">=3","PyPDF2=={}".format(v), a subscript) is named without a pin, sofixno
longer rewrites the literal into"pypdf>=6.19" + VERSION; the row needs review.
Packs
py-pdf/pypdf2-to-pypdf0.3.0 namesfrom.newest3.0.1andfrom.lacksPdfFileMerger,
PdfFileReaderandPdfFileWriter, and its limitations say what is taken as 3.0.1, what stops
that, and what lives outside the repository and is not seen. The rules are unchanged.
v0.4.0
Benchmark
I did not run the benchmark again for this release. Its numbers are still 0.1.0's and are for the
Gemini pack alone: scan precision 100.0% and recall 98.4% over 64 usages in five repositories, and
25 of 277 usages (9.0%) migrated in 20 repositories with no wrong edit, on the
results page. The
scanner and the rewriter have changed since, so read those numbers as dated. The rule for a held
generation config is measured without the tests: a fix dry run over the 23 round-1 repositories at
their pinned commits gives the rows 0.3.0 gave, except 26 that moved from needs_review to auto
(20 in kvcops__Virtual-Herbal-Garden, 6 in
Momen2410__Question-Answering-ChatBot-Using-Gemini1.5). That says what is written, not whether it
passes a repository's tests, which a new round measures, with those two repositories in dev.
openai/openai-0-to-1 and py-pdf/pypdf2-to-pypdf have no benchmark. They rest on fixtures, on
each change measured against both library versions installed side by side, and on a weekly job that
runs their facts on the oldest and newest releases.
Unsupported patterns
docs/KNOWN_ISSUES.md lists
what I know about and have chosen not to fix yet. The gaps listed under 0.3.0 stand, except that
Python 3.15 is supported. The Gemini pack still withholds a model and the chat it starts in one
chain (GenerativeModel(...).start_chat(...)), and a generation config or safety settings held by a
name that is assigned twice, changed after its assignment, passed on, read by anything but the call
or taken as a parameter.
Added
fix --applyandverifyprint one line on stderr before each phase of tests: how many
commands are about to run and after how many seconds each is stopped, so a long suite no longer
looks like a hang. It names no command, since one can carry a credential, and stdout is unchanged.- The Gemini pack rewrites a
generation_configorsafety_settingsheld by a name, where it
refused asgeneration_config_not_staticorsafety_settings_not_static(COVERAGE gap 29).
The name needs one plain assignment, a mapping, a list of mappings or aGenerationConfig(...),
and no reader but that argument of a call the rule rewrites, so nothing else sees the value or
changes it between the assignment and the call. The keys are written into each call. A module's name stays where it
is, and aGenerationConfig(...)it holds becomes the new SDK'sGenerateContentConfig(...);
a function's own goes with the model, and so does theGenerationConfig(...)it holds. A name
assigned twice, changed by a stored key orupdate, passed on, read elsewhere or a parameter is
still refused. A module's name that another module changes after importing it is not seen.
Changed
scanandfixdo less work on a large repository and write the same findings. The pass that
finds a module another one reads (model_object_read_elsewhere) parses only the modules that
spell both the module's name and a name read, and walks each once, where it walked every parsed
module again for each model (9.5 s to 3.5 s on 800 synthetic modules).fixno longer parses
a file that only mentions the library in prose or a comment: 28 s to 4 s on 200 files, 180 of
them such. A pack the repository does not use, with no code to migrate and no pin to judge, no
longer parses the files that name a module only its legacy distribution installed, and one that
does reads imports only where a statement can hold one (11 s to 6 s over 5,000 files).
End to end,fixtakes 4.6 s on a repository of 1,000 files where 200 use the legacy SDK, which
took 40 s, and 4.4 s on one file of 800 calls, which took 15 s.patch.difffor a file of more than four million line pairs (about 2,000 lines each side) is
made without the matcher's repeated-line pass, which cost the square of a repeated line's
count. Below that size the hunks are the ones 0.3.0 wrote; above it a hunk can be larger or
split differently, andgit applyreads the patch the same.
Fixed
- A second
obelize undoof a run exits0when every file already holds its original, where it
exited4as if something had gone wrong, and it leaves the firstundo.json, which says what
was put back, instead of replacing it with a row ofhash_mismatchskips.undo.json's
exit_codeis now derived from files that were reverted or already original. - The note
obelize verifyappends toREPORT.mdnames the commands it ran, on one line, so a
reader of the report is not left with the first run's. A command with a backtick or a newline in
it stays inside its code span: the fence is longer than any run of backticks, and the newline
is spelled out. - A run folder that an older obelize wrote and this one cannot read now names the release that
wrote it and the command that undoes the run (uvx obelize@0.2.0 undo --run <id>), whereundo
andverifyonly said the record was unreadable. - The terminal output of
scanandfixhas less in it that nobody acts on. A planned file shows
path (N hunk(s))and not the list of rules that produced the hunks, whichREPORT.mdand
plan.jsonkeep. An eligible row has a blank reason, where it printed the code that resolved
its name. A dry run prints noVerification:line and itsREPORT.mdno empty phase, since no
test was attempted, and two packs that suggest the same tests say them once. fix --applyno longer refuses a dirty tree when it has nothing to write. A repository where
every finding is held back used to exit5with "uncommitted changes", which hid the real
reason; it now exits4with the findings. A configured model keeps the gate, since a
proposal could write.fix --apply --verify "<cmd>"looks for the command's program before it reads a file. A typo, or
a.venv/bin/pythonthat is not there, is a usage error (exit2) and nothing is written; it
used to write the patch and endinconclusive(exit6) withcommand_not_executable. A
command from.obelize.ymlor the allowlist is still started, and still ends that way.- When the commands you pass to
--verifychange a tracked file, the refusal offix --apply
says they did, where it asked you to commit or stash changes you had not made. The tree was
clean when the run began, whichgit_dirty: falsealready recorded. - A verification command that timed out gets a hint naming
--timeout <s>and the default of 600
seconds, which the flag's help anddocs/CLI.mdnow state.
Security
fix --applytries to make a file in each directory it will write into before it writes any,
and refuses the whole apply asunreadablefor a directory that takes none. A read-only
directory used to stop the write partway and leave a half-migrated tree; a partial write that a
race still causes now ends withobelize undoin itsNext:line.- A file name that holds a control or format character, a backtick or a pipe is skipped as
unusable_name. Printed as it was, an escape sequence in a name moved a terminal's cursor or
colours, and a backtick or a pipe ended a code span or a table cell inREPORT.md. Its name is
spelled out (\x1b) in the skip, and the detail of a limitations row is escaped as a pack's
text is. .obelize/latestis replaced by a rename below a.obelizeopened without following a link,
where a check for a link and then a write could race a swap; a link there is now replaced and
what it named is not written..obelize.ymlis read without following a link, so a repository
cannot make a parse error quote another file. A relativeXDG_CONFIG_HOMEis ignored, as the
specification says, where it could have put the user's model block, allowlist and pack
directories under the repository being scanned. Approving a command appends a row and keeps the
rows already there, with their command text, where it rebuilt the file from the keys.
Python 3.15
- obelize supports Python 3.15, which CI runs on Linux, macOS and Windows and the classifier names.
uv.lockmoves to pydantic 2.14.1 and pydantic-core 2.50.1, which have 3.15 wheels, where the
older pair made pip compile pydantic-core with Rust. PyYAML has no 3.15 wheel yet, so pip builds
it in pure Python, with no compiler. An Intel Mac installs libcst 1.8, which has no 3.15 wheel,
so it compiles that with Rust there; the other systems install libcst 1.9's wheel.
tomllibreads TOML 1.1 from 3.15 on, so apyproject.tomlthat uses it has its Python floor
read there, and is still reported as unreadable on 3.12 to 3.14.
Packs
gemini/google-generativeai-to-google-genai0.3.0 writesgoogle-genai>=1.55where it wrote
>=1. Releases 1.0 to 1.54 do not hold what the pack claims: 1.54 has no
enable_enhanced_civic_answers, one of the fifteen generation-config keys the pack moves into a
config, so code it wrote could fail on an install>=1admitted. Agoogle-genailine you
already declare is left as you wrote it. The weekly job now runs the pack's facts on 1.55.0 and
on the newestgoogle-genai, and thepy-pdf/pypdf2-to-pypdffacts on the newestpypdf; the
Gemini facts also check that every keyword a rewritten call carries is one its method takes.
v0.3.0
Benchmark
I did not run the benchmark again for this release. Its numbers are 0.1.0's and are for the Gemini
pack alone: scan precision 100.0% and recall 98.4% over 64 usages in five repositories, and 25 of
277 usages (9.0%) migrated in 20 repositories with no wrong edit, on the
results page. The scan
specification the Gemini pack hands the scanner is the one measured, which tests/unit/test_gate1.py
checks, and the verdict of every hand-written answer key under tests/fixtures/scan/ is unchanged;
one row of the encoding key moved from line 8 to line 6, the line compile() names. The scanner and
the rewriter are not unchanged, so read those numbers as dated. openai/openai-0-to-1 and
py-pdf/pypdf2-to-pypdf have no benchmark. They rest on fixtures, on each change measured against
both library versions installed side by side, and, for openai, a weekly job that runs the same
fixtures on 0.27.0, on 0.28.1 and on the new releases.
Unsupported patterns
docs/KNOWN_ISSUES.md lists
what I know about and have chosen not to fix yet. The 0.1.0 gaps for the Gemini pack stand. The main
gaps of the two other packs:
openai: async and streamed calls, Azure, every resource but the seven calls under 0.2.0's Added,
and theopenai.errorclasses are reported and never rewritten. A write to a module setting
throughexec,globals(),sys.modulesorsetattr,OPENAI_API_BASEset in code, and a base
URL formed once at assignment are not seen. A handler for top-levelopenai.APIErrorthat reads
http_statusorjson_bodyfails after the pin moves, with no warning. The pack writes nothing
while any row of it is withheld.PyPDF2: a method or a parameter is never rewritten or reported, so only your own tests find that
PdfWriter(path)starts empty on PyPDF2 and clones the file on pypdf, that page boxes turn from
Decimalintofloat, or thatextract_textputs different whitespace between the pieces of a
line. A repository that does not declarePyPDF2at 3 or above, or a Python below 3.9, is
blocked.- Model names are kept as they are, in every pack.
- A source file with a chain of about four hundred terms is a
parse_errorrow, not a rewrite. On
every Pythoncompile()accepts a chain of about thirteen thousandandororoperands, and
on 3.14 and later a chain of tens of thousands of terms, and libcst can still segfault on them. - Python 3.15 is not in the supported range yet.
- The four safety gaps in
docs/THREAT_MODEL.md are
narrower and still open: a filter driver named in a repository's own.git/configand, on
Windows, a DLL placed in the repository;obelize verifyorobelize undorun twice overwriting
the record of the first; a credential shape the redactor does not list, a secret a Windows program
writes as UTF-16 and a key in the model adapter's traceback; on Linux and macOS,SIGTERMor
SIGHUPsent to obelize itself, which leaves a verification command running where Ctrl-C does
not; and, on Windows, a process that WMI, Task Scheduler ordockerstarts for a verification
command.
Fixed
-
A manifest that already declares the new distribution is no longer given a second line for it:
the legacy row is withheld asmanifest_pin_shape_unsupportedand the file stays as written. -
One deeply nested expression no longer ends the whole scan.
compile()now runs before libcst's
parser, which overruns the C stack on thousands of nested parentheses, a chain of tens of
thousands of operands or a stack ofnots and killed the process; the file is one
input_does_not_parserow. A tree that parses but overruns the analysis or the rewrite is that
row, or is kept as it was withoutput_does_not_parse. A filecompile()and libcst both refuse
is now blamed on the linecompile()names. Asetup.pythat does not compile declares nothing. -
A PEP 810
lazy importof the legacy module (Python 3.15) is no longer invisible. libcst binds
no name for it, so the uses after it were never seen and the pin could be rewritten under code
that still used it. The statement is now oneneeds_reviewrow,lazy_import, a new value of
the closedbailvocabulary infindings,plan,runandproposalschemas; the file and
the pin stay. -
A legacy pin that nothing imports is no longer
not_a_usage. Code already ongoogle-genaior
pypdfwithgoogle-generativeaiorPyPDF2still pinned is now aneeds_reviewrow,
manifest_code_mismatch, so the CI check indocs/CLI.mdexits1on it and a pack that only a leftover pin
names is chosen.fixstill writes nothing for it. An excluded file that names the library, a
module only it installs, andopenai(one distribution for both APIs) keepnot_a_usage.
legacy_version_unsupportedis no longer raised for a pin below the floor when no code uses the
library. -
A notebook, stub or windowed script that the default
includeleaves out and that names the
library kept the pin whateverexcludesaid.excludenow releases it: the file is named as
excluded and blocks nothing, so the rest migrates. A module a narrowerincludeleaves out is
named the same way instead of dropped without a word. -
A pin copied below a legacy one that something still imports is no longer joined to its
neighbour. A quoted array element that no comma follows (the last one of apyproject.tomllist
or asetup.pyinstall_requires) was copied as it stood, which left apyproject.tomlthat
tomllibrefuses and asetup.pyrequirement ofPyPDF2==3.0.1pypdf>=6.19. The row is now
withheld asmanifest_pin_shape_unsupported. -
A manifest edit that its rule would not write (extras, a URL, the case above, the new
distribution already declared) is withheld like a source rule's refusal. It was reportedauto
with the manifest unchanged, so the run saidVerification: passand exited0or6. It is now
inwithheld[], is not counted incounts.auto, and the exit is4. -
A
setup.pywith a lone carriage return before the requirement, or a byte order mark on its
line, no longer has its pin written at the wrong place (aSyntaxErroron a line ending
)>=6.19pypdf): libcst counts those lines differently from the edit, so the row is named
without a pin. One that parses but overruns the stack in libcst's metadata pass (about four
hundred terms) declares nothing, where it endedscanandfixwith aRecursionError. -
A
SyntaxWarningin the code obelize reads (an invalid escape,iswith a literal) is no longer
printed to stderr, wherecompile()and libcst printed one for each use, with the line that
caused it.
Changed
- The
fixsummary says why findings were left for you, with a file written or not: how many, the
most common reasons, up to five of the rows that cause them (path:line symbol code), the files
that hold the pin without a row (a notebook, a file importing a module only the old distribution
installed), and a link to what each code means at this release's tag. A pack whose one
distribution holds both APIs says that the repository moves whole or not at all. - A warning on an edit that is written, or would be, is printed as
Check <file>:<line>: <code>: <what to look at>. - A command that follows:
Next: obelize fix --apply ... writes it.after a dry run that planned a
file, andNext: obelize fix ... shows what it would change.after ascanwith aneligible
row and no blocked pack. Both repeat--packand--repo. After exit6the step is now one path
for every pack: undo,fix --apply, install what it wrote, thenverify --run <new id>, since
a baseline that failed leaves nothing to compare with; when the tests merely gave no verdict, the
line says to install what the run wrote into the manifest first. scannames the first three files that block a pin and, inREPORT.md, the files that import a
module only the old distribution installed. A blocked pack's rows are introduced as whatfix
would try once the block is lifted. The limitations of a pack name its guide.--verifysays in its help that it runs only with--apply.- A report that was misleading now is not. An apply that wrote nothing prints no
Next:line,
since there is nothing to undo; aCheckline after--applysays its line number is the one
before the change; a blocked migration says that migration plans no change, not the run; the
importers of a module only the old distribution installed are not listed as unanalysed; the
count of dependency declarations counts declarations, not the rows one is graded into; the
legend says a file is left as it was when any row in it isneeds_revieworunsupported; a
verification command with a|and the words of a model endpoint no longer break the tables of
REPORT.md. - A pack's
source.urlmay hold no whitespace, control character or Markdown or HTML character,
since the report prints it. REPORT.mdno longer lists a manifest as "still importing the legacy distribution" when the
pin waits on a module only that distribution installed; it lists the files that import it.
Documentation
- The README leads with the example, says what to do after each exit code (three guides for
4,
two causes for3), givesopenaia four-step way to check a migration whose two versions are one
install, and has a "With a coding agent" section.skills/obelize/SKILL.mdis the same workflow as
a skill file, and a test parses every command in it. docs/CLI.md's CI check prints one::errorannotation per finding and pinsX.Y.Z, and has a
paragraph on monorepos. The phase order says the compile check belongs to the ver...
v0.2.0
Benchmark
I did not run the benchmark again for this release. Its numbers are 0.1.0's and are for the Gemini
pack alone: scan precision 100.0% and recall 98.4% over 64 usages in five repositories, and 25 of
277 usages (9.0%) migrated in 20 repositories with no wrong edit, on the
results page. The scan
specification the Gemini pack hands the scanner is the one measured, which tests/unit/test_gate1.py
checks, and the verdict of every hand-written answer key under tests/fixtures/scan/ is unchanged.
The scanner and the rewriter are not, so read those numbers as dated. openai/openai-0-to-1 and
py-pdf/pypdf2-to-pypdf have no benchmark. They rest on fixtures, on each change measured against
both library versions installed side by side, and, for openai, a weekly job that runs the same
fixtures on 0.28.1 and on the new releases.
Unsupported patterns
docs/KNOWN_ISSUES.md lists
what I know about and have chosen not to fix yet. The 0.1.0 gaps for the Gemini pack stand. The main
gaps of the two new packs:
openai: async and streamed calls, Azure, every resource but the seven calls under Added, and the
openai.errorclasses are reported and never rewritten. A write to a module setting through
exec,globals(),sys.modulesorsetattr,OPENAI_API_BASEset in code, and a base URL
formed once at assignment are not seen. The pack writes nothing while any row of it is withheld.openai: a handler for top-levelopenai.APIErrorthat readshttp_statusorjson_bodyfails
after the pin moves, with no warning.PyPDF2: a method or a parameter is never rewritten or reported, so only your own tests find that
PdfWriter(path)starts empty on PyPDF2 and clones the file on pypdf, or that page boxes turn from
Decimalintofloat. A repository that does not declarePyPDF2at 3 or above, or a Python
below 3.9, is blocked.- A source file with a chain of about four hundred terms is a
parse_errorrow, not a rewrite. - The four safety gaps in docs/THREAT_MODEL.md
are still open.
Added
- One run can use several packs.
obelize scanandobelize fixtry every known pack over one read
of the repository, and run each one with a finding that is notnot_a_usage;--packis
repeatable and names exactly the packs that run. A run no pack applies to says so and exits0.
Packs run in id order over what the ones before them wrote, each as a single-pack run; two that
could feed each other are refused with exit2. pack_dirsin~/.config/obelize/config.ymladds directories of your own packs, chosen by id like
the bundled ones. A repository's.obelize.ymlmay not set it.- A second bundled pack,
py-pdf/pypdf2-to-pypdf: the import, the names that exist unchanged on
both sides, and the dependency line, fromPyPDF23 topypdf6.19 or later. It reports the
camelCase classes PyPDF2 3.0.0 removed and the names pypdf dropped, and blocks a repository
below Python 3.9 or with PyPDF2 pinned below 3. - A third bundled pack,
openai/openai-0-to-1, so obelize ships three. It migratesopenai0.28.1
to 1.109.1 or later, one library whose module keeps its name. It rewrites
openai.ChatCompletion.create,Completion.create,Embedding.create,Image.create,
Moderation.create,Audio.transcribeandAudio.translateto the same calls on the module client
(openai.chat.completions.create,openai.images.generate) when every argument and every read of the result
is one it has checked, and moves the pin toopenai>=1.109.1. A read of the result by string keys
along a path it lists (response["choices"][0]["message"]["content"]), also through a name that
holds a part of the result or a loop over its items, is rewritten to the
attribute path with the call, where the name holds the result alone and no handler for a missing key
orcontextlib.suppresssurrounds the read. It rewrites the assignmentopenai.api_base = value
toopenai.base_url = valuewith the value ending in/, which the new module client needs, and
refuses any other use ofapi_base. It reports async calls, twelve
module settings the new releases ignore or read differently, theopenai.errorclasses, the other
resources, the modules of the 0.28.1 package and indirect use. Both versions are one distribution,
so it writes no file while any row of the pack is withheld: each row of another file it would have written readsrepo_not_fully_migrated, and the pin stays. A declaration that
already admits only 1.109.1 or later (openai==3.26.0) is left as written, and one it cannot rewrite
in place (extras, a URL) holds every file (manifest_pin_shape_unsupported, shown byscan). It does
not report top-levelopenai.APIError, which both sides have and code on the new release writes, so a
handler that readshttp_statusorjson_bodyfails after the pin moves with no warning. - Pack format: a seventh kind,
rename_setting(ADR-055), for a shared module whose setting is
assigned under another name in the new release.settingsmaps the legacy dotted attribute to
its new name, andvalue_ends_withis one character the value must end with: a string literal gets
it, anything else is written("%s" % (value,)).rstrip(c) + c. Any use of the old name but a plain
assignment isattribute_removed, and a file that already uses the new name isalias_collision.pack.schema.jsonlists the kind. A pack field that names an identifier (a new symbol or setting name, a keyword argument) is refused when it is a Python keyword. - Pack format, for a library that keeps its module name.
match.sharedmakes only the names in
match.symbolslegacy, and a shared pack has norename_importchange.rewrite_calltakes
root: module(the call stays on the root the author wrote, and noconfigure_to_clientis
needed),keywords(parameters carried when written as keywords) andresult_paths(the only
reads of a result that carry; a string key that is the next segment of a path is rewritten to the
attribute). Amanifest_dependencymay name one distribution on both sides,
the two told apart byfrom.versionandto.version, and a declarationto.versionalready admits is
left as written.arg_mapmay not rename a parameter ontoconfig_kwarg, which named the keyword twice. scanandfixprint what a pack says about the findings it flagged: for eachflag_onlychange
that claimed one of its own pack's, its id, how many, its message and its suggestion.REPORT.md
has the same under Guidance, lists the limitations of every pack that ran, and lists a fix's
verification.suggestionsas suggested and not run. The pack format has promised all three since
0.1.0 and nothing printed them.REPORT.mdescapes the Markdown and HTML characters in pack text,
and display text refusesU+2028andU+2029.
Changed
findings.json,plan.jsonandrun.jsoncarrypacks[]in place ofpack, andrun.json's
blockedmoved into each pack's row. A run folder holds each pack's copy under
packs/<provider>/<slug>/in place ofpack.yamlandpack.sha256. An edit'srule_idis
<pack id>:<rule id>.- A pack whose repository rules it out has every row withheld for that reason, so an apply that
writes for one pack and is blocked for another exits4. The reason isruntime_unsupported, or
the newlegacy_version_unsupported: the pack'sfrom.versionhas a floor and the legacy
distribution is declared below it, unpinned, or not at all while the code uses it. The text for a
blocked pack names the declaration and what would unblock it, in place of "the new SDK". --packhas no default, andcli.DEFAULT_PACKis gone.- In a shared module the scanner reports a read off a call's result (
f(...).choices[0].x) as part of
the call's own row, with no attribute finding of its own. A pack that is not shared keeps that finding,
so the Gemini results are unchanged. It also reports the bare module fetched by its string
(importlib.import_module("openai"),__import__,sys.modules) asdynamic_access, a read of its
__dict__or__getattribute__asmodule_alias_rebound, and a star import only from the module
itself or a legacy name. repo_not_fully_migratedalso lands on the source rows of a pack whose new distribution is its
legacy one, import rows included,manifest_pin_shape_unsupportedis also raised byscanfor such
a pack, andfrom_import_unmigrated_symbolis also raised for a call a module-rootedrewrite_call
reaches throughfrom M import Name. No code was added to the vocabularies.- A pack describes its target more generally.
to.requires_pythonis required and replaces the
fixed Python 3.10 floor.rename_importtakessymbol_map(Nameor<submodule>.Name) and
alias_fallbacksin place oftypes_symbol_mapandtypes_alias_fallback. A pack with no
configure_to_clientis valid when no change rewrites calls onto a client. obelize fixnever asks the configured model about a pack whose new library is its old one, since a
proposal is one row and the pack writes a whole repository or none.--model,--accept-modeland
--show-contextwith such a pack exit2, and a configured model is skipped with a line on stderr.rewrite_callcounts a read of a result's name that the scope analysis links to no assignment (in a
loop's second pass) and refuses a result bound in a class body. A call inside an f-string field is
laid out on one line, since Python before 3.12 takes no newline there.
Removed
- The pack fields
changes[].preconditions,changes[].replacementand
generative_model_calls.default_model_name, whi...
v0.1.0
Benchmark
I measured this release on public repositories, against answer keys I wrote by hand. The sample
leans towards small single-purpose applications. The
results page has
the method and every case.
- Scan precision is 100.0% and recall is 98.4%, over 64 usages I labelled in five repositories
before the scanner ran on them. The scan reported 63 of the 64, and all 63 were right. - obelize migrated 25 of 277 usages (9.0%) in 20 repositories and left the other 252 for a
person. The most common reason, for 98 of them, is that something else in the same file or
repository was not migrated, since obelize writes a file only when it can migrate all of it.
Another 129 were withheld for a stated reason, most often a module with more than one
configure()call (19 usages), the old SDK imported inside a function (18) or the module alias
reassigned (17). The last 25 the scan never reported, mostly code reached through a function's
return value, a star import in another file or a container. - obelize wrote nothing in 14 of the 20 repositories, migrated part of five and finished one.
None got a wrong edit. The scan did report three usages that my answer keys do not list, and
none of the three was edited. - None of the 20 was migrated and verified by its own tests. Of the 20, 16 have no test command,
including the one obelize finished, and each of the four that do holds a usage no rule writes. - On 13 of those repositories (174 usages) I also ran a general-purpose coding agent, given
Google's migration guide and one fixed prompt. It migrated 154 usages (88.5%) against
obelize's 17 (9.8%), and one of its migrations was verified by the repository's tests where
none of obelize's was. It made a wrong edit in two of the 13 repositories, both real defects,
and obelize in none. The agent does far more of the work, so I do not claim obelize is more
accurate. The comparison shows that obelize made no wrong edit and names a reason for each
usage it withholds. It is one agent, one day and one snapshot of the guide, on the split
obelize's rules were tuned against. The agent's side dates from 2026-09-22 and was not rerun
for 0.1.0, so its guide and model may have moved since.
Unsupported patterns
The bundled pack declares 20 limitations, among them rewrites never checked against a live API
call. docs/KNOWN_ISSUES.md
lists what else I know about and have chosen not to fix yet. The main gaps:
- Every tool declaration is reported and never rewritten, because automatic function calling is
on by default ingoogle-genai. - Model names are kept as written. Google has retired the
gemini-1.5models, so change such a
name yourself. - Notebooks (
.ipynb) that use the old SDK are found and not migrated, and the old dependency
line stays while one does. - Code that reaches the SDK through a function's return value, a star import in another file or
a container is not found. - obelize leaves a whole file as it was when the file has a
configure()call in another module
or more than one in the module, the old SDK imported inside a function, the module alias
reassigned, or a model object passed around or read from another module. mock.patchtargets, dynamic imports and surfaces with no rule, such asprotos,caching
and tuning, are reported and never rewritten. Asys.modulesstub of the old module can be
left in place without a report.- Four safety gaps stay open, described in
docs/THREAT_MODEL.md:
a git command obelize runs can start a program that the repository's git configuration names
(on Windows it can also load a DLL from the repository that git finds nowhere else),
runningobelize verifyagain orobelize undotwice can overwrite the earlier record,
redaction misses common credential shapes, and Ctrl-C can leave a verification command's
processes running. On Windows that last gap is a process a broker such as WMI or Task
Scheduler starts, and redaction also misses a secret written as UTF-16 or, with non-ASCII
characters, in an older code page. Run obelize in a repository that is committed or backed up.
Added
- obelize runs on Windows. It writes files, and opens run-folder files, relative to their parent
directory and refuses a symbolic link or a junction as it does elsewhere, and a verification
command and everything it starts run in a job object that the deadline ends. CI runs the suite on Windows for Python 3.12
and 3.14. A real console, a OneDrive folder and a path longer than 260 characters are untried. .obelize/carries its own.gitignoreholding*, sogit add -Anever picks up a run
folder.- A dry run of
obelize fixprints up to 200 lines of the diff after its file list, with
secrets redacted and control characters escaped, then the path of the wholepatch.diff.
--jsonoutput is unchanged. - An apply ends with a
Next:line naming the command to run after its exit code, and a
run with nothing to write names the reason that held back the most findings. A
--verifyprogram that never started is named, with the interpreter form to use instead.
--jsonoutput is unchanged. python -m obelizeruns the CLI where theobelizescript is not onPATH.obelize scanexplains its table's columns in one line under it.
Changed
obelize pack validate --helplinks the pack format on GitHub instead of adocs/path,
which an installed copy does not have. A defect in a bundled pack links the issue tracker,
a refusal over a dirty tree names--allow-dirty, andobelize --helpnames the migration
it supports.obelize fixuses the bundled Gemini pack when--packis not given, asobelize scan
does.obelize fix --helpnames the two--modelproviders,noneandopenai_compat.- Help, terminal output,
REPORT.mdand error messages use plainer words and cite no design
records. A refused--verifynames the flag without pydantic's "Value error," prefix, and an
apply refused over a file git does not track says to commit it, sincegit stashwould
leave it in place.~/.config/obelize/config.ymllists every problem under its key, and a
misspelt key is matched against the keys that file accepts. - The run folder's path is printed under
--repoas typed, so it opens from the directory
the command ran in. A secondobelize undoof a run marks each file that holds its
original again "(already the original)"; it still exits4. Terminal output and
--helpcarry no Markdown backticks.
Fixed
- An Intel Mac installs libcst 1.8, which has a wheel for that platform, so installing
obelize there needs no Rust compiler. libcst 1.9 has no Intel macOS wheel. --jsonwrites its document as UTF-8 bytes whatever the terminal's encoding, so stdout
holds the same bytes asfindings.jsonorplan.json.- A file or directory whose name holds a backslash or is not valid UTF-8 no longer stops
obelize scanbefore it writes any evidence.run.jsonlists it asunusable_namewith no path, and the
detail quotes the name. - A run blocked by a declared Python floor below 3.10 says which file to change and to what,
andobelize fixprints that reason too, where it only counted rows it did not write. An
empty plan prints "No change planned.", and the verification line counts the commands a
failing baseline ran. obelize fix --applyrefuses to rewrite a file git does not track unless--allow-dirty
is given, sincegit diffcould not show that change. Other untracked files, the run
folder among them, still do not count.obelize verify --runrecords its result after an apply that ran no verification command.- A
Next:line quotes the repository and the suggested tests the way the shells of the
system it runs on read them, and a path the summary names inside the run folder uses one
separator throughout. - When an apply's write is refused after the tests before it ran, the verification line
counts the commands that ran, andrun.jsonkeeps their results as the baseline and
their time intimings.verify_ms. - The note
obelize verifyadds toREPORT.mdsays whether tests ran before the change,
and where they did, points at the report's Baseline section. - The hint about a test command from obelize's own environment says whether it failed or
timed out. - A
--modelrefused over the model block names the configuration file obelize read,
whereverXDG_CONFIG_HOMEputs it. - A
--verifycommand that needs a shell or has an unclosed quote exits2with the reason
instead of a traceback, and so does an unusable allowlist entry inobelize verify.