Changelog¶
Notable changes per release, in Keep a Changelog 1.1.0
format. Versions are 0.MINOR.PATCH.
Stability¶
Every format obelize reads or writes is unstable through 0.x: the CLI surface, the exit codes,
.obelize.yml, migration packs, the run folder and its JSON Schemas. None of them carries a
format version, so a 0.x release can change any of them, and this file lists each format change
in the release that makes it.
[Unreleased]¶
[0.7.0] - 2026-10-19¶
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 inverify.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.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 of the working tree, with git's plumbing: no checkout, no hook, no signature, no push.obelize upgrade <distribution> --apply --branch --agent claude_codeputs the places a pack only flags to your ownclaudeprogram as a text-only model (every tool, session, MCP server, skill and setting of yours off, and an environment cut down to the variables a login and a network need, listed in the privacy notes), checks each answer with the guard, writes it to the trial's copy, lets the rules write the rest and your tests judge. It needs--branch, so nothing a model helped write reaches your working tree; the commit message names the files it edited.agent.backendandagent.modelin your own configuration do the same where--apply --branchis given, and a repository's.obelize.ymlmay not setagent.- A place the pack flags is sent with the new names closest to the removed one, which
pack synthkeeps inhints.jsonbeside the pack, and the dependency line follows the places the agent moved. A dependency line the rules cannot write is named in the summary and the commit message. - An answer may list
imports, up to four plainimportorfrom ... importstatements of the pack's target modules, which the guard adds at module level, to the file's ownfrom m import ...line when it has one. A file's places are asked about from the bottom, each where the earlier answers left it, and the import of a name is covered once every place that uses it moved. - An answer may be that a place needs no edit (
"unchanged", with a sentence). The place is settled and nothing is written for it, and the evidence and the commit message name such places: only the tests check them. - In a library that has one release for both APIs, a place left in a file the agent moved does not hold the dependency line.
- Tests that passed before the move and fail after it are put to the agent too, with the error and
the line of non-test code the traceback points at, for up to
--max-roundsrounds (3). A test that fails because its module would not import is put with the line pytest points at. The rounds never edit a test file. ~/.config/obelize/config.ymltakesnetwork.indexandnetwork.offline, and the environmentOBELIZE_INDEX_URLandOBELIZE_OFFLINEdo the same for one run.OBELIZE_INDEX_AUTHORIZATIONholds the value of anAuthorizationheader, sent to the index host and no other. A repository's.obelize.ymlmay not setnetwork: it is refused, asmodelis.source.typeof a pack may beapi_diff, the type of a pack made bypack synth. A bundled pack never is.- griffelib is the seventh runtime dependency. It reads a release's API in a child process and is loaded by no other command.
Changed¶
- A
rename_symbolchange may carryrenamed, a name that stays in its module and takes another spelling, andcalls: allowed, which lets a call of a renamed name be rewritten. The defaults leave every pack as it was, and the pack schema,docs/PACK_SPEC.mdand the negative corpus describe the new fields. obelize undoskips a recorded path in.git/or.obelize/asoutside_root: a run never writes either, so a run folder that names one was forged.- A model's proposal may carry
imports, as an agent's may, and the guard lets a replacement nameclassmethod,staticmethod,property,superandobject. - A scan that finds no pack to apply names the libraries the repository imports most and the command that lists one.
- The package description, keywords and README say what
upgradedoes, and the development status is Beta.
[0.6.1] - 2026-10-18¶
This release changes words and metadata and none of the behaviour. No format changed.
Benchmark¶
I did not run the benchmark again. Every number is 0.6.0's.
Changed¶
- The PyPI description now reads "Verified codemods for breaking Python dependency upgrades", and
the project's keywords add
refactoring,breaking-changes,api-migrationandstatic-analysis.obelize --helpopens with the same line. - The README is shorter and opens with what obelize does and why a run can be trusted. The details moved to pages of their own: Getting started, After a run, With a coding agent, Packs, Safety, Limits and Testing, and a Glossary names the terms.
- The product page and the roadmap say that obelize is a tool for verified dependency upgrades, and list what I intend to build next as planned. Nothing on that list ships in this release.
- Reader pages no longer name a decision or threat number in their text. The links to the decisions stay.
Added¶
- A documentation site at https://hakanbogan.github.io/obelize/, built from
docs/with MkDocs. ci builds it strictly on every push, and av*tag publishes it. The project's Documentation link on PyPI points there.
[0.6.0] - 2026-10-18¶
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 with
obelize 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,BaseExceptionorValueErrorhandler 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 inREPORT.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 in
REPORT.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; raisemax_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, andoutput_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 toapi_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, an
elifladder, 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*requirement*.txtis a manifest and is rewritten likerequirements.txt. Arequirements.inand a constraints file are read for their declarations and never written, so a legacy pin in one ismanifest_pin_shape_unsupportedwhere it stayed with no row. The arrays uv, PDM and Hatch keep requirements in are read and rewritten like PEP 621's, and a Poetry dependency given as its own table is read and refused. - A Poetry or Pipfile inline table that holds only a version (
openai = {version = "0.28.1"}) is rewritten inside its quotes. Asetup.pythat assigns its requirements to a list or tuple first (REQS = [...],setup(install_requires=REQS)) is read, and the pin is rewritten where the list is assigned. - The Python gate reads a
Pipfile's[requires]and a stringpython_requires=insetup.py, so a repository that builds on a Python below the pack's floor there is blocked asruntime_unsupported. A Pipfile that is not TOML 1.0 is reported as an unread manifest. - A module only the legacy distribution installed (
requests,aiohttp) counts as declared for a file only where a manifest in its directory or above it declares it, so one app's declaration no longer covers another app in a monorepo. - A notebook that names the library is read as code, its cells in order, and holds the pin only where a cell calls the legacy API, with the cell numbered, where it held the pin whether or not it did. A notebook is still never written.
- A read of
response.textinside atry,except*orcontextlib.suppresswhose handler is bare,Exception,BaseExceptionorValueErroris refused asresponse_shape_changed, and the read of an awaited call's result is judged like a sync one. - A
legacy_version_unsupportedblock says that a pin moves the release and not the code, so a call to a name the pinned release removed has to be ported first; it no longer reads as advice to movePyPDF2code written for a release below 3 to>=3as it stands. - A block of an unversioned
PyPDF2in a directory that is not the top of a git repository says that obelize lists every file only at the top of a git repository, and to run it there or pin. - For
PyPDF2, adev-requirements.txt,requirements.inor constraints file no longer stops the run for being there; each is read like a manifest, so only one that names another release (PyPDF2<3), a barePyPDF2or an option that moves the index does. A name in[tool.isort], a Ruff or mypy list, a project description or a URL no longer counts as an unread declaration. A listed file overmax_file_bytesis searched in 1 MiB windows up to 32 MiB, an ignored.venv,venvorenvstops the inference beside any listed file, and the pip options of a manifest the user'sexcluderemoved are judged. - The benchmark harness runs each case's installs and test suites with an allowlisted environment
(
PATH, the locale,TMPDIR, the uv and pip cache and interpreter directories) and a home directory of its own, in a session whose process group is killed when the step ends. A round records the commit and tree state it started on (obelize_tree_dirty) and refuses to start on a dirty tree, outside a git checkout, or whenobelizeis imported from elsewhere than the checkout. The comparison arm's installs and suites get the same environment and cutoff. - The bundled legacy example application defaults to
gemini-2.5-flash, the model the README andexamples/quickstartuse, instead of the retiredgemini-1.5-flashandgemini-1.0-pro. CI installs a pinned uv and numpy, runspre-commitover every file, and runs ruff from the project environment; the hook repository is held to a commit, not a tag. obelize.modelsis a package of six modules, with no import path or JSON Schema changed (ADR-058).
Fixed¶
- A model reply of one very long
or,andor (from Python 3.14)+chain no longer killsobelize fixwith a segmentation fault; a reply nested deeply enough for the guard's tree walks, deeply nested JSON and a lone surrogate no longer end the run with a traceback, and neither does a 120 KB body of brackets on Python 3.15. A model that quotes the lines it replaces as newline-joined text is no longer refused when the range ends in a blank line. - The model request's 120-second limit bounds the whole reply body, not each read of it, so a reply
dripped a byte at a time is
endpoint_unreachable. A 4xx or 5xx reply whose body stalls or resets isendpoint_refusedwith the status, not a traceback. - Asking a model about several rows of one file parsed the file once per row (about 135 ms each on a 2,800-line file); it is parsed once now.
- An impossible date, an over-long integer, a deeply nested document or an explicit tag on a value it
cannot read (
!!bool maybe) in.obelize.yml, a pack or~/.config/obelize/config.ymlends in a refusal with a line and column where there is one, not a traceback. A non-UTF-8 user config, or a deeply nested config orapproved.json, is a refusal, and the approvals store fails closed. - A YAML merge key (
<<) failed with "could not determine a constructor for the tag merge"; merge keys work in.obelize.ymland packs, and a duplicate key is found without constructing it twice. obelize undoreads no configuration, so an invalid.obelize.ymlno longer stops it.- An apply refused over a file only a model proposed runs no baseline command first.
- The hint after a baseline that failed in obelize's own environment shows
.venv/Scripts/python.exeon Windows, and the refusal of a command that needs a shell advisescmd /cthere. - The packs the wheel carries no longer add a
Not scannedline toREPORT.mdwhen obelize is installed inside the scanned repository. - A
pyproject.tomlorPipfilenested too deep fortomllibno longer aborts the scan with aRecursionError; it is reported asinput_does_not_parse. - A constraints file no longer counts as a declaration of a module's distribution, which let
fix --applydrop the legacy pin while a file still imported what only that pin installed. - The syntax check on a file no longer applies obelize's own
from __future__ import annotations, so a walrus in an annotation is not refused as unparsable on Python 3.12 and 3.13. - A requirement line PEP 508 refuses (
PyPDF2~=1,PyPDF2==1.*.*, asetup.cfgline with a trailing comment) is a row for its name with no version, so the version gate can no longer pass over it. A local build of the legacy release (PyPDF2==3.0.1+corp) no longer reads as the release. - A rewritten call inside a multi-line bracket is laid out at the indent of the line it starts on, a
Gemini call written in an f-string field stays on one line, and a call in an f-string field written
with
=is refused asresponse_shape_changedinstead of rewritten. - A comment written inside a call the codemod replaces (between its arguments, after its opening
parenthesis, in the dotted callee) is kept, beside its argument where one remains and on a line
above the statement otherwise. A comment on a deleted
GenerativeModel(...)statement, or between its arguments, moves to the next statement, and onward when that is deleted too, and a comment between the brackets of a rewritten string-key read moves to a line above its statement. - A combined import such as
import types, google.generativeai as genaino longer lets the rewrite take the nametypesforfrom google.genai import types; the submodule is bound asgenai_types. embed_content's literaltask_typeis written as the UPPER_SNAKE name the service reads, and a name, integer, enum member or unknown string is refused asunsupported_kwarg. A mappingsystem_instructionwith string parts or asystemrole, which raises aValidationErrorinGenerateContentConfig, is refused ashistory_parts_shape_incompatible.- A call on a client built with
genai.Client()in a file that also builds a legacy model is no longer reported asreceiver_unresolved, which withheld every other row of the file. - The benchmark harness no longer records a finished command as a timeout when a background process
it started still holds its output. A round is refused when obelize is imported from anywhere but
the checkout's
src/, and--exclude-newerrefuses a time that is not written digit for digit (2026-10-15T09:00:00Z), which uv would refuse after the round had started.
Packs¶
-
openai/openai-0-to-10.3.1 andpy-pdf/pypdf2-to-pypdf0.4.1 stop printing limitations the scan contradicts: a notebook holds the pin only where a cell calls the legacy API, a pin-lessopenaibesideChatCompletionorAudiocode is taken, a Python insetup.pyor aPipfileis read, and every requirement shape the pack cannot rewrite leaves its manifest line. -
openai/openai-0-to-10.3.0: - A declaration with no lower bound (
openai,openai<1,openai>=0.20) beside a call ofopenai.ChatCompletionoropenai.Audiois taken as a release in>=0.27.0,<1, the only range that can run them, and migrated where it stopped the run aslegacy_version_unsupported. The run says what it took, and a lock, a constraint or a version written elsewhere that rules the range out still stops it (pack fieldfrom.since). - A pin that admits only 0.x (
openai==0.28.1,openai<1) beside code that usesopenai.OpenAI,AsyncOpenAIorAzureOpenAIis amanifest_code_mismatchrow for review, where the run said "No pack applies" (pack fieldto.introduces). - A module that 0.28.1 installed (
requests,aiohttp,tqdm) which an app imports and no manifest of the app declares is declared by name, with no version, below theopenairequirement of a requirements file as the pin moves, where the run held the pin astransitive_dependency_in_use(pack parameterdeclare_transitive). openai.error.RateLimitError,PermissionErrorandTimeoutare rewritten asopenai.RateLimitError,PermissionDeniedErrorandAPITimeoutError, in references and inimport openai.errorandfrom openai.error import X, by a newrename_symbolrule kind (ADR-057); the otheropenai.errorclasses stay reported, each with a message that says what it became.engine=andrequest_timeout=of a chat, completion or embedding call are carried asmodel=andtimeout=, where they were refused.- A one-argument
.get("key")of a result is rewritten togetattr(result, "key", None)instead of withholding the repository. - A key read inside a handler for an imported class, which may subclass
KeyError, is no longer rewritten, and a result bound to a module-level name that another module imports or reads off the module is withheld. Image.createis refused when the model is written out as one that is nodall-e, sincegpt-image-1returns no url.- The pack reports a write of
OPENAI_API_BASEorOPENAI_ORGANIZATIONin code, which the new releases do not read (flag_only.variablesand theenvironment_writeshape); a patch target written under your own package,patch.object(myapp.llm.openai, "Leaf")and a dict literal keyed byopenaihanded topatch.dict(sys.modules, ...)orsys.modules.update; a legacy name inside a quoted annotation; and a read ofhttp_status,json_bodyoruser_messageoff the name of anexcept openai.APIErrororopenai.OpenAIErrorhandler. py-pdf/pypdf2-to-pypdf0.4.0 namesPyPDF2.pdf,PyPDF2.utilsandPyPDF2.merger, the modules only PyPDF2 1 ships, infrom.lacks, and reports them. A name the newest release lacks, written in a notebook, a Python 2 file, a stub, excluded code or prose, counts as evidence of an older release as the same name in a parsed file already did. The suggestion forPdfFileReader,PdfFileWriterandPdfFileMergernames what their members become and the renames that are wrong by name alone (rotateCounterClockwise, the fourth positional ofmerge*Page,removeImages,removeText); no method is rewritten.gemini/google-generativeai-to-google-genai0.4.0 carries the two warnings, thetask_typetable (value_maps) and thesystem_instructionrefusal above. A call that passesdisplay_namehas nothing to be told about it (warnings_unless), and the limitation about a bareconfigure()says it is rewritten toClient()with one warning about the key being checked at construction.
[0.5.0] - 2026-10-15¶
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, atox.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 before
fix --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.
[0.4.0] - 2026-10-12¶
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'sexit_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>), whereundoandverifyonly said the record was unreadable. - The terminal output of
scanandfixhas less in it that nobody acts on. A planned file showspath (N hunk(s))and not the list of rules that produced the hunks, whichREPORT.mdandplan.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 --applysays 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 noenable_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.
[0.3.0] - 2026-10-12¶
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 readshttp_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 thatPdfWriter(path)starts empty on PyPDF2 and clones the file on pypdf, that page boxes turn fromDecimalintofloat, 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,SIGTERMorSIGHUPsent 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 as
manifest_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 oneinput_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. libcst 1.8, which an Intel Mac installs, cannot parse the statement, so there such a file is aninput_does_not_parserow and is not written. - A legacy pin that nothing imports is no longer
not_a_usage. Code already ongoogle-genaiorpypdfwithgoogle-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 a
pyproject.tomllist or asetup.pyinstall_requires) was copied as it stood, which left apyproject.tomlthattomllibrefuses 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 reported
autowith 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 aneligiblerow 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 whatfixwould 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 ofREPORT.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 verification, and that pytest's exit5is a failure here.PACK_SPEC.mdno longer promises the rewrite matches your formatter.KNOWN_ISSUES.mdnames the half-migrated file, the import order, lockfiles and anopenainotebook on a pin that has already moved.PRODUCT.mdfixes Gate 5 at 2026-11-03 and updates the neighbouring tools. The issue templates name no single pack.BENCHMARK_RESULTS.md,bench/errors.yamland ADR-044 no longer give counts and levers that disagree with the tablebench/errors.pygenerates: analias_collisionis worth nothing alone, and ADR-044 says 25 migrated rows where it said 34.
Packs¶
openai/openai-0-to-1(0.2.0) migrates fromopenai0.27.0, not only 0.28.1. A second Gate 0 found one surface across 0.27.0 to 0.28.1 and identical results from the ten legacy fixtures; a pin below 0.27.0 blocks the pack. The weekly job runs the legacy fixtures on 0.27.0 too.- The openai and Gemini packs name model deprecations. All three suggest
<project python> -m pytest -qand say a patch target "stops standing in for the code once it has moved". The Gemini pack (0.2.0) also names the class-wrapper refusal and the optional typesgoogle-genaireturns. The openai pack names the Azure environment variables,httpx2, optional types, libraries that call 0.x themselves, and what a notebook does to the pin. The pypdf pack (0.2.0) names thatextract_textspaces text differently. The Gemini pack'spack_versionrises from 0.1.0, which 0.2.0 should have done.
Security¶
- Every git command obelize runs sets
core.fsmonitorto empty, so a hook the repository's own.git/confignames no longer runs while obelize reads the tree. A filter driver in that file, which a clone does not carry, and on Windows a DLL placed in the repository, remain. - Ctrl-C no longer leaves a verification command running on Linux and macOS: closing the command kills what is left of its process group.
- The command and the command line a run records are redacted, and the redactor knows more shapes:
AWS session keys, GitHub OAuth, server and fine-grained tokens, Hugging Face, Slack, Stripe, GitLab
and PyPI tokens, JWTs, a
Bearertoken, the password of a URL and a quotedpassword. - Every git command also takes
--no-optional-locks, sostatusdoes not rewrite a stale index and run apost-index-changehook that the repository's config names throughcore.hooksPath. - The JWT and PEM patterns of the redactor took minutes on a megabyte of repeated starts
(
eyJeyJ..., aBEGINline with noEND); both are read once now. A URL password with no user (redis://:secret@host) is redacted. A redaction that crossed a quote no longer leaves a record that cannot be split, which raised after the command had run: the record keeps the program and[REDACTED]. - A model proposal that repeats a
[REDACTEDplaceholder it was shown, where the lines it replaces hold none, is refused asreplacement_carries_redaction, a new value ofGuardRefusalin theproposalschema (therefusalofmodel/proposals-<n>.json);run.jsonis unchanged.
[0.2.0] - 2026-10-10¶
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 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. 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 thatPdfWriter(path)starts empty on PyPDF2 and clones the file on pypdf, or that page boxes turn fromDecimalintofloat. 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 rewritesopenai.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 = valuetoopenai.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 inmatch.symbolslegacy, and a shared pack has norename_importchange.rewrite_calltakesroot: 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.mdhas the same under Guidance, lists the limitations of every pack that ran, and lists a fix'sverification.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'sblockedmoved into each pack's row. A run folder holds each pack's copy underpacks/<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 exits
4. 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_callreaches 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) andalias_fallbacksin place oftypes_symbol_mapandtypes_alias_fallback. A pack with noconfigure_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[].replacementandgenerative_model_calls.default_model_name, which nothing read. A pack that sets one is refused, and the Gemini pack's sha256 changes. import_policyfrom every plan in a run folder, and the unused codesfile_object_fields_not_verified,tests_touched_by_migrationandstale_mock_target. A run folder written by 0.1.0 no longer loads, so undo a 0.1.0 apply with 0.1.0.
Fixed¶
- A
matchstatement with a mapping pattern that captures a name (case {"k": name}) crashed the scan of every pack. - A
setup.pystring literal Python cannot evaluate (an invalid\Uescape, a NUL) crashed the manifest reader. It now declares nothing. - A
setup.pythat a run changed as a source and as a manifest crashed it, whether one pack or two edited it, because the file is read as both. It is now one outcome from its first bytes to its last, and the manifest edit is made on the bytes the code edit wrote. - A source file whose rendering overran the stack (a chain of hundreds of terms) stopped the whole
scan with a
RecursionError. It is now oneparse_errorrow for that file. - A call after a form feed,
U+2028orU+0085in an earlier line was laid out from the wrong source line, sincestr.splitlinesbreaks where Python does not. The time to rewrite a file grew with the square of its legacy calls (about thirty seconds for four hundred in one file) and is linear now. - A pack with one distribution on both sides left the pin lines it could write when another line
of the same distribution could not be written (
openaiwith and without extras). Every legacy line is now held with it. importlib.import_module(".error", "openai"),pkgutil.resolve_nameandsys.modules.get,popandsetdefaultwith the module's name aredynamic_accessrows, assys.modules["openai"]was. A name in a string annotation ("openai.OpenAI",Annotated[int, "openai-beta"]) no longer counts as the module used as a value.
[0.1.0] - 2026-10-06¶
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 in
google-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,cachingand 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),
running
obelize 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 scandoes.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,
and
obelize 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-dirtyis 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, and
run.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.