Skip to content

Releases

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 27 Oct 20:55

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 .pyw script and a file obelize
    could not open still hold the whole repository, and so do fix, scan, --pack and an upgrade
    with an agent set up.
  • A declaration of the library that rules out the release --from names, or that obelize cannot
    read, stops the plan: 14 of the 80 stop on legacy_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.py and bench/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 of upgrade --apply that 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.8 and pydantic = "^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 in REPORT.md, and a dry run does
    not check it; --apply does, 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 --from was not asked
    about before. Where the pack would take a release of its own for such a declaration (its newest,
    for pydantic>=1.8), your --from is the release the plan takes and says, and run.json
    records it as stated and not as assumed. fix, scan, --pack and an upgrade without
    --from are gated as they were
    (the decision).
  • obelize upgrade writes 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 and trial.json (held) name the files left and say that a pass does not show that
    the rest has moved. Without --branch the command ends 4 for such a move, as fix --apply does
    where places are left for you, and 0 where none is, whether a verification command of your own
    ran or not; with --branch it ends 0 after the commit either way, so read the text. A dry run
    that plans part of a repository ends with the upgrade command that writes it, not fix --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 for fix, scan and --pack, and for an
    upgrade with an agent set up (--agent, or agent.backend in 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 numpy and
    upgrade openai write 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 upgrade that changed no file says what held the repository: a place left in a
    file,...
Read more

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 27 Oct 11:49

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-utcnow rewrites calls only: field(default_factory=datetime.utcnow) and a
    SQLAlchemy default=datetime.utcnow are reported and keep their file as it was. The value stays
    naive, as utcnow() made it; a test that mocks datetime in a module of its own is not seen and
    fails after the rewrite.
  • setuptools/pkg-resources-to-importlib writes nothing and is gated at Python 3.10.
  • python/removed-stdlib-modules renames pipes.quote and reports the rest. It leaves out imp,
    uu, nis, crypt and chunk, whose names sit inside import, uuid, finish, encrypt and
    chunk.
  • A default scan, which runs every known pack, parses the files that hold pkg_resources or the
    name of a removed module once more for each of these packs, and those that spell utcnow or
    utcfromtimestamp for 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-2 rewrites 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) to numpy>=2 and 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's UP035 and AIR301, django-upgrade, pyupgrade) is not registered
    (ADR-074).
  • --guide has 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).
  • upgrade with 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 --from names a release inside the pack's range: a declaration
    decides, and --from counts 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 synth and
    upgrade take --guide <url|file> (up to three), and a model you set up in your own
    configuration (the endpoint in model, else the agent in agent) reads the guide and drafts
    claims: a name renamed, a meth...
Read more

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 23 Oct 22:10

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, self and cls in 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, by type(...), in an excluded directory or through from 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 synth renames only a method whose deprecation message says no more than which method to
    use; copy and from_orm of 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() becomes user.model_dump()
    where User derives from the library's BaseModel. The scan types a receiver from what the file
    shows (a call of such a class, self and cls in 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 $name for any expression, such as pydantic.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 group obelize.kinds, which a pack names with kind: plugin beside requires_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.yml may not, and a bundled pack never asks); a pack that needs one you have not allowed
    stops with exit code 7 before any file is read. The run folder records each plugin's release and
    the hash of its RECORD. The interface is obelize.plugin, described in
    Writing a plugin; obelize does not contain what a plugin does.
  • pack synth writes rename_member for 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, and dict, json and update_forward_refs
    with takes). 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

Choose a tag to compare

@github-actions github-actions released this 20 Oct 18:16

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
    upgrade writes to the whole repository or to none of it. A trial of a real repository ends in
    exit 6 until 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 claude is 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 --sarif prints the findings as SARIF 2.1.0, which GitHub code scanning reads.
  • obelize scan --fail-on <status> exits 4 when a finding has the status you name (eligible,
    needs_review or unsupported), and the run's exit_code records it.
  • obelize verify --json and obelize undo --json print the run.json and undo.json they wrote.
  • obelize scan --library <distribution> lists every place a repository uses a library that has
    no pack, with --module to name the modules when the library is imported under another name.
    Each place is needs_review with the new reason no_pack_for_usage and nothing is rewritten. The
    run records source: inventory in packs[] 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 --current runs a run's verification on the tree as it stands instead of
    answering tree_changed, and records the files that differ from what the run wrote in
    verify.edited (a new field of run.json, empty for any other verification). An --apply run
    that wrote no file can be checked this way, so a migration that an agent made by hand can be
    verified.
  • obelize mcp serves 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_verify and obelize_undo run the commands and return what they print,
    obelize_report returns a run's REPORT.md, and obelize_residue lists 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-only leaves out the two that write files.
  • action.yml is 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.yaml offers the same scan as the hook obelize-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
    pass pack test or it is not kept. Every host is printed before it is contacted, answers are
    cached for you alone, and --offline uses 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).
      upgrade does 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 outdated lists 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. --json prints the rows, described by the new outdated.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 as fix does without --apply. It writes no file of your code, only its run folder
    under .obelize/. With no dependency it lists those that outdated shows and obelize can
    migrate, and asks which to move; where no one can be asked it prints the list and exits 2.
  • obelize upgrade <distribution> --apply --yes tries 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 exits 3 for tests that broke and 6 for 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. --offline keeps uv to its cache. --branch commits a passed move to a new branch
    instead o...
Read more

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 18 Oct 11:05

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 a requirement.txt is now read. Three
    moved to blocked: a pypdf pin that admits nothing the pack writes (twice) and a Python floor read
    from setup.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 of response.text inside a bare, Exception, BaseException or ValueError
    handler is now refused, which is the price of failing closed. One moved from held to blocked on a
    Python floor read from a Pipfile or setup.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 name openai,
    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 -r of 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 on PyPDF2 while the code moves to pypdf. Releases 1.x and 2.x are flagged and
    never migrated. Installer settings outside the repository (a pip.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 as proposal_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 plain http:// model base_url to a host other than localhost,
    127.0.0.0/8 or ::1 is refused unless it is true.
  • An unexpected error on one file no longer ends a run. A scan reports the file as one parse_error,
    and fix leaves it as it was and withholds its rows as the new bail internal_error;
    run.json's limitations names 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 as new_version_unsupported and names the path.
  • pack validate refuses a pack whose two flag_only changes name one surface, naming both.
  • After a manifest pin is written, the summary and REPORT.md name the lock files to regenerate,
    and the fix summary says what to change for each reason a finding was left (also in
    REPORT.md and SCAN_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_none where the result's .text is
    read (it is None now where it raised ValueError), and upload_display_name_unset on a
    rewritten upload_file that leaves out display_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 record forbidden_reported, the findings at a position the
    answer key forbids, and bench/CASES.md prints how many of the 38 forbidden positions the scan
    reported.

Changed

  • A file that an -r or -c line 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 .py file that the scan cannot read (over max_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_bytes or list the file under exclude. 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's retrieved_at refuse a value of another type
    instead of converting it (log_prompts: 'yes', a quoted date).

  • The code of a model reply that compile() refuses is output_does_not_compile, and
    output_does_not_parse is for a result compile() 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 as replacement_too_large.

  • A model base_url with 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, an elif ladder, about a hundred brackets inside brackets) is refused as one that does not
    parse, the same on every --jobs and 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.yml or a pack is refused with a line and column, where it was read in
    hash order.

  • .obelize.yml, the user's pack_dirs and verify.allow, and a pack's layout.line_length,
    match.shared and coroutine refuse 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_version no 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 beside pack.yaml that imports the
    legacy API is scanned and holds the pin. REPORT.md says Not scanned and names the directory,
    and scan --list-files leaves it out.

  • The Next: regenerate each lock file line names only the lock files in the written manifest's
    directory or above it. The --jobs help 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.txt or any other `*requ...

Read more

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 15 Oct 12:35

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). The PyPDF2 pack names 3.0.1 and its
    three removed classes. Nothing else in a pack changes: a pack that names no newest is gated
    as before, and to takes neither field.
  • A run that took such a release says so: packs[].assumed in run.json holds it, and the
    terminal and REPORT.md say 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 PyPDF2 repository 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 uses PdfFileReader,
    PdfFileWriter or PdfFileMerger; a lock, a constraints file, a pip.conf or another
    installer setting; an index setting in pyproject.toml or Pipfile; a pip option that moves
    the index; a manifest that names PyPDF2 where no requirement was read or cannot be read; a
    version of PyPDF2 written 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. The openai and 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.x version is read as Poetry reads it: ^0.27 stops below 0.28 and
    ^0.0.3 below 0.0.4, not below 1 and 0.1. Nothing in a run changes yet, since only the
    floor of a declaration is read today.
  • A setup.py requirement that code builds around its string ("PyPDF2" + VERSION,
    "PyPDF2" ">=3", "PyPDF2=={}".format(v), a subscript) is named without a pin, so fix no
    longer rewrites the literal into "pypdf>=6.19" + VERSION; the row needs review.

Packs

  • py-pdf/pypdf2-to-pypdf 0.3.0 names from.newest 3.0.1 and from.lacks PdfFileMerger,
    PdfFileReader and PdfFileWriter, 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

Choose a tag to compare

@github-actions github-actions released this 12 Oct 20:57

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 --apply and verify print 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_config or safety_settings held by a name, where it
    refused as generation_config_not_static or safety_settings_not_static (COVERAGE gap 29).
    The name needs one plain assignment, a mapping, a list of mappings or a GenerationConfig(...),
    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 a GenerationConfig(...) it holds becomes the new SDK's GenerateContentConfig(...);
    a function's own goes with the model, and so does the GenerationConfig(...) it holds. A name
    assigned twice, changed by a stored key or update, 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

  • scan and fix do 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). fix no 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, fix takes 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.diff for 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, and git apply reads the patch the same.

Fixed

  • A second obelize undo of a run exits 0 when every file already holds its original, where it
    exited 4 as if something had gone wrong, and it leaves the first undo.json, which says what
    was put back, instead of replacing it with a row of hash_mismatch skips. undo.json's
    exit_code is now derived from files that were reverted or already original.
  • The note obelize verify appends to REPORT.md names 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>), where undo
    and verify only said the record was unreadable.
  • The terminal output of scan and fix has 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, which REPORT.md and
    plan.json keep. An eligible row has a blank reason, where it printed the code that resolved
    its name. A dry run prints no Verification: line and its REPORT.md no empty phase, since no
    test was attempted, and two packs that suggest the same tests say them once.
  • fix --apply no longer refuses a dirty tree when it has nothing to write. A repository where
    every finding is held back used to exit 5 with "uncommitted changes", which hid the real
    reason; it now exits 4 with 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/python that is not there, is a usage error (exit 2) and nothing is written; it
    used to write the patch and end inconclusive (exit 6) with command_not_executable. A
    command from .obelize.yml or the allowlist is still started, and still ends that way.
  • When the commands you pass to --verify change a tracked file, the refusal of fix --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, which git_dirty: false already recorded.
  • A verification command that timed out gets a hint naming --timeout <s> and the default of 600
    seconds, which the flag's help and docs/CLI.md now state.

Security

  • fix --apply tries to make a file in each directory it will write into before it writes any,
    and refuses the whole apply as unreadable for 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 with obelize undo in its Next: 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 in REPORT.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/latest is replaced by a rename below a .obelize opened 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.yml is read without following a link, so a repository
    cannot make a parse error quote another file. A relative XDG_CONFIG_HOME is 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.lock moves 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.
    tomllib reads TOML 1.1 from 3.15 on, so a pyproject.toml that 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-genai 0.3.0 writes google-genai>=1.55 where 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 >=1 admitted. A google-genai line 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 newest google-genai, and the py-pdf/pypdf2-to-pypdf facts on the newest pypdf; the
    Gemini facts also check that every keyword a rewritten call carries is one its method takes.

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 12 Oct 11:49

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 the openai.error classes are reported and never rewritten. A write to a module setting
    through exec, globals(), sys.modules or setattr, OPENAI_API_BASE set in code, and a base
    URL formed once at assignment are not seen. A handler for top-level openai.APIError that reads
    http_status or json_body fails 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
    Decimal into float, or that extract_text puts different whitespace between the pieces of a
    line. A repository that does not declare PyPDF2 at 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_error row, not a rewrite. On
    every Python compile() accepts a chain of about thirteen thousand and or or operands, 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/config and, on
    Windows, a DLL placed in the repository; obelize verify or obelize undo run 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, SIGTERM or
    SIGHUP sent to obelize itself, which leaves a verification command running where Ctrl-C does
    not; and, on Windows, a process that WMI, Task Scheduler or docker starts 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_unsupported and 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 of nots and killed the process; the file is one
    input_does_not_parse row. A tree that parses but overruns the analysis or the rewrite is that
    row, or is kept as it was with output_does_not_parse. A file compile() and libcst both refuse
    is now blamed on the line compile() names. A setup.py that does not compile declares nothing.

  • A PEP 810 lazy import of 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 one needs_review row, lazy_import, a new value of
    the closed bail vocabulary in findings, plan, run and proposal schemas; the file and
    the pin stay.

  • A legacy pin that nothing imports is no longer not_a_usage. Code already on google-genai or
    pypdf with google-generativeai or PyPDF2 still pinned is now a needs_review row,
    manifest_code_mismatch, so the CI check in docs/CLI.md exits 1 on it and a pack that only a leftover pin
    names is chosen. fix still writes nothing for it. An excluded file that names the library, a
    module only it installs, and openai (one distribution for both APIs) keep not_a_usage.
    legacy_version_unsupported is no longer raised for a pin below the floor when no code uses the
    library.

  • A notebook, stub or windowed script that the default include leaves out and that names the
    library kept the pin whatever exclude said. exclude now releases it: the file is named as
    excluded and blocks nothing, so the rest migrates. A module a narrower include leaves 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.toml list
    or a setup.py install_requires) was copied as it stood, which left a pyproject.toml that
    tomllib refuses and a setup.py requirement of PyPDF2==3.0.1pypdf>=6.19. The row is now
    withheld as manifest_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 auto
    with the manifest unchanged, so the run said Verification: pass and exited 0 or 6. It is now
    in withheld[], is not counted in counts.auto, and the exit is 4.

  • A setup.py with 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 (a SyntaxError on 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 ended scan and fix with a RecursionError.

  • A SyntaxWarning in the code obelize reads (an invalid escape, is with a literal) is no longer
    printed to stderr, where compile() and libcst printed one for each use, with the line that
    caused it.

Changed

  • The fix summary 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, and Next: obelize fix ... shows what it would change. after a scan with an eligible
    row and no blocked pack. Both repeat --pack and --repo. After exit 6 the step is now one path
    for every pack: undo, fix --apply, install what it wrote, then verify --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.
  • scan names the first three files that block a pin and, in REPORT.md, the files that import a
    module only the old distribution installed. A blocked pack's rows are introduced as what fix
    would try once the block is lifted. The limitations of a pack name its guide.
  • --verify says 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; a Check line after --apply says 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 is needs_review or unsupported; a
    verification command with a | and the words of a model endpoint no longer break the tables of
    REPORT.md.
  • A pack's source.url may hold no whitespace, control character or Markdown or HTML character,
    since the report prints it.
  • REPORT.md no 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 for 3), gives openai a four-step way to check a migration whose two versions are one
    install, and has a "With a coding agent" section. skills/obelize/SKILL.md is the same workflow as
    a skill file, and a test parses every command in it.
  • docs/CLI.md's CI check prints one ::error annotation per finding and pins X.Y.Z, and has a
    paragraph on monorepos. The phase order says the compile check belongs to the ver...
Read more

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 10 Oct 07:26

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.error classes are reported and never rewritten. A write to a module setting through
    exec, globals(), sys.modules or setattr, OPENAI_API_BASE set 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-level openai.APIError that reads http_status or json_body fails
    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
    Decimal into float. A repository that does not declare PyPDF2 at 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_error row, not a rewrite.
  • The four safety gaps in docs/THREAT_MODEL.md
    are still open.

Added

  • One run can use several packs. obelize scan and obelize fix try every known pack over one read
    of the repository, and run each one with a finding that is not not_a_usage; --pack is
    repeatable and names exactly the packs that run. A run no pack applies to says so and exits 0.
    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 exit 2.
  • pack_dirs in ~/.config/obelize/config.yml adds directories of your own packs, chosen by id like
    the bundled ones. A repository's .obelize.yml may 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, from PyPDF2 3 to pypdf 6.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 migrates openai 0.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.transcribe and Audio.translate to 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 to openai>=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
    or contextlib.suppress surrounds the read. It rewrites the assignment openai.api_base = value
    to openai.base_url = value with the value ending in /, which the new module client needs, and
    refuses any other use of api_base. It reports async calls, twelve
    module settings the new releases ignore or read differently, the openai.error classes, 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 reads repo_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 by scan). It does
    not report top-level openai.APIError, which both sides have and code on the new release writes, so a
    handler that reads http_status or json_body fails 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. settings maps the legacy dotted attribute to
    its new name, and value_ends_with is 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 is attribute_removed, and a file that already uses the new name is alias_collision. pack.schema.json lists 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.shared makes only the names in
    match.symbols legacy, and a shared pack has no rename_import change. rewrite_call takes
    root: module (the call stays on the root the author wrote, and no configure_to_client is
    needed), keywords (parameters carried when written as keywords) and result_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). A manifest_dependency may name one distribution on both sides,
    the two told apart by from.version and to.version, and a declaration to.version already admits is
    left as written. arg_map may not rename a parameter onto config_kwarg, which named the keyword twice.
  • scan and fix print what a pack says about the findings it flagged: for each flag_only change
    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.suggestions as suggested and not run. The pack format has promised all three since
    0.1.0 and nothing printed them. REPORT.md escapes the Markdown and HTML characters in pack text,
    and display text refuses U+2028 and U+2029.

Changed

  • findings.json, plan.json and run.json carry packs[] in place of pack, and run.json's
    blocked moved into each pack's row. A run folder holds each pack's copy under
    packs/<provider>/<slug>/ in place of pack.yaml and pack.sha256. An edit's rule_id is
    <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 is runtime_unsupported, or
    the new legacy_version_unsupported: the pack's from.version has 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".
  • --pack has no default, and cli.DEFAULT_PACK is 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) as dynamic_access, a read of its
    __dict__ or __getattribute__ as module_alias_rebound, and a star import only from the module
    itself or a legacy name.
  • repo_not_fully_migrated also lands on the source rows of a pack whose new distribution is its
    legacy one, import rows included, manifest_pin_shape_unsupported is also raised by scan for such
    a pack, and from_import_unmigrated_symbol is also raised for a call a module-rooted rewrite_call
    reaches through from M import Name. No code was added to the vocabularies.
  • A pack describes its target more generally. to.requires_python is required and replaces the
    fixed Python 3.10 floor. rename_import takes symbol_map (Name or <submodule>.Name) and
    alias_fallbacks in place of types_symbol_map and types_alias_fallback. A pack with no
    configure_to_client is valid when no change rewrites calls onto a client.
  • obelize fix never 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-model and
    --show-context with such a pack exit 2, and a configured model is skipped with a line on stderr.
  • rewrite_call counts 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[].replacement and
    generative_model_calls.default_model_name, whi...
Read more

v0.1.0

Choose a tag to compare

@github-actions github-actions released this 06 Oct 10:13

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.5 models, 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.patch targets, dynamic imports and surfaces with no rule, such as protos, caching
    and tuning, are reported and never rewritten. A sys.modules stub 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 verify again or obelize undo twice 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 .gitignore holding *, so git add -A never picks up a run
    folder.
  • A dry run of obelize fix prints up to 200 lines of the diff after its file list, with
    secrets redacted and control characters escaped, then the path of the whole patch.diff.
    --json output 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
    --verify program that never started is named, with the interpreter form to use instead.
    --json output is unchanged.
  • python -m obelize runs the CLI where the obelize script is not on PATH.
  • obelize scan explains its table's columns in one line under it.

Changed

  • obelize pack validate --help links the pack format on GitHub instead of a docs/ 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, and obelize --help names the migration
    it supports.
  • obelize fix uses the bundled Gemini pack when --pack is not given, as obelize scan
    does.
  • obelize fix --help names the two --model providers, none and openai_compat.
  • Help, terminal output, REPORT.md and error messages use plainer words and cite no design
    records. A refused --verify names the flag without pydantic's "Value error," prefix, and an
    apply refused over a file git does not track says to commit it, since git stash would
    leave it in place. ~/.config/obelize/config.yml lists 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 --repo as typed, so it opens from the directory
    the command ran in. A second obelize undo of a run marks each file that holds its
    original again "(already the original)"; it still exits 4. Terminal output and
    --help carry 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.
  • --json writes its document as UTF-8 bytes whatever the terminal's encoding, so stdout
    holds the same bytes as findings.json or plan.json.
  • A file or directory whose name holds a backslash or is not valid UTF-8 no longer stops
    obelize scan before it writes any evidence. run.json lists it as unusable_name with 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 fix prints 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 --apply refuses to rewrite a file git does not track unless --allow-dirty
    is given, since git diff could not show that change. Other untracked files, the run
    folder among them, still do not count.
  • obelize verify --run records 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.json keeps their results as the baseline and
    their time in timings.verify_ms.
  • The note obelize verify adds to REPORT.md says 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 --model refused over the model block names the configuration file obelize read,
    wherever XDG_CONFIG_HOME puts it.
  • A --verify command that needs a shell or has an unclosed quote exits 2 with the reason
    instead of a traceback, and so does an unusable allowlist entry in obelize verify.