Command-line interface¶
Every command, flag and exit code of obelize. obelize <command> --help prints the same text in
your terminal, and a release is checked against this page (RELEASING.md). The
decisions behind it are indexed in DECISIONS.md.
Surface¶
obelize scan [--repo .] [--pack <id|path>]... [--library <distribution> [--module <module>]...]
[--json | --sarif] [--fail-on <status>]... [--jobs N] [--list-files]
obelize fix [--pack <id|path>]... [--repo .] [--apply] [--allow-dirty] [--verify "<cmd>"]... [--timeout <s>]
[--non-interactive] [--trust-repo-config] [--model <provider>] [--accept-model]
[--show-context] [--json]
obelize verify --run <id> [--repo .] [--verify "<cmd>"]... [--timeout <s>]
[--non-interactive] [--trust-repo-config] [--current] [--json]
obelize undo --run <id> [--repo .] [--json]
obelize mcp [--repo .] [--trust-repo-config] [--read-only]
obelize outdated [--repo .] [--all] [--json] [--offline]
obelize upgrade [<distribution>] [--from <version>] [--to <version>] [--module <module>] [--repo .]
[--offline] [--apply] [--yes] [--python <version>] [--test-command "<cmd>"]
[--timeout <seconds>] [--branch] [--branch-name <name>] [--agent <backend>]
[--max-asks <n>] [--max-rounds <n>]
obelize pack validate <id|path>
obelize pack test <id|path>
obelize pack new <id> --from "<requirement>" --to "<requirement>" --guide <url>
[--module <module>] [--to-module <module>] [--requires-python <range>] [--dir <path>]
obelize pack synth <distribution> --from <version> [--to <version>] [--module <module>]
[--used-by <path>] [--dir <path>] [--offline]
python -m obelize runs the same commands where the obelize script is not
on PATH. Every command prints its run folder's path under --repo as you
typed it, so the path opens from where you ran it.
Global options¶
| Flag | Description |
|---|---|
--version |
Print the version and exit. |
--no-color |
No colour, in --help and usage errors too. NO_COLOR does the same, following no-color.org: any non-empty value, 0 included. |
-h, --help |
Show help and exit, in under 300 ms. |
obelize scan¶
Find the imports, call sites and manifest entries a pack affects. Never edits the repository.
| Flag | Description |
|---|---|
--repo <path> |
Repository root. Default: the current directory. |
--pack <id\|path> |
A pack id or a pack file path; repeat it to run several, and only those run. Without it every known pack is tried over one read of the repository, and each one with a finding that is not not_a_usage runs (several packs). |
--library <distribution> |
List every place the repository uses a library, with no pack (an inventory). It cannot be combined with --pack. |
--module <module> |
A module of --library to list, as it is imported. Repeat it to name several. |
--json |
Findings as JSON on stdout; the run folder's path on stderr. |
--sarif |
Findings as SARIF 2.1.0, the format GitHub code scanning reads, on stdout instead of the table; the run folder's path on stderr. It cannot be combined with --json. One result per finding except not_a_usage; its rule is the finding's reason, or eligible, and its level is note for eligible, warning for needs_review and error for unsupported. Two runs over the same input print the same bytes. |
--fail-on <status> |
Exit 4 when any finding has this status: eligible, needs_review or unsupported. Repeat it to name several. The output is the one you asked for, and the run folder records the exit code. |
--jobs N |
Worker processes; 1 is in-process. Default min(8, cpu_count - 1), in-process below 32 prefilter survivors; an explicit N is always used, up to the 61 a Windows process pool takes. Never changes the output. |
--list-files |
Print the selected files in order and stop, reading no file. The pack is still loaded (an invalid one exits 7). |
Writes a run folder and updates .obelize/latest
(RUN_FOLDER.md). --json output equals the folder's
findings.json byte for byte and holds nothing time-derived (run id, timings
and paths are in run.json), so runs over the same input compare equal.
Evidence is written before anything is printed; failing to write it exits 1.
Exit codes: 0 done, findings or not, and also when no pack applies; 4 a finding has a status
--fail-on names; 7 invalid pack; 2 usage
error, including a --pack named twice, a --fail-on that names another status, --json with --sarif, two packs that cannot run together, a pack_dirs entry
that is not an existing directory, a --repo that is not a directory, --jobs below 1 or above 61 on Windows, and an unusable
.obelize.yml; 1 unexpected error, including an unwritable run folder.
Listing a library with no pack¶
When no pack applies, scan prints the libraries the repository imports most. To see every place
the code uses one, name it:
uvx obelize scan --library requests
An inventory lists each import, call and attribute of the library's modules and rewrites nothing.
Every place is needs_review with the reason no_pack_for_usage, since no migration pack says how
to move it, and the library's lines in a dependency manifest are context. The modules are, in this
order: the ones --module names; the ones the library installed in a .venv, venv or env
directory in the repository, read from its RECORD without importing anything; the library's name.
A library imported under another name (PyYAML is yaml) needs --module when it is not
installed in the repository. The run folder records the pack as inventory/<distribution>, with a
hash of the modules it listed and no copy of a pack.
A CI check¶
To fail a CI job while the repository still has something left to migrate, name the statuses that count:
uvx obelize@X.Y.Z scan --fail-on eligible --fail-on needs_review --fail-on unsupported
It exits 4 if any finding has one of those statuses and 0 otherwise. not_a_usage is context
only (a mention in a comment or string, or a legacy pin something else may still need), so no
status you can name matches it. A legacy pin that nothing imports is a finding,
manifest_code_mismatch: remove it. The scan still writes its run folder under .obelize/ in the
checkout, and the .gitignore inside keeps it out of git status. Replace X.Y.Z with the version
you tested, since any 0.x release can change the JSON (CHANGELOG.md).
In GitHub Actions¶
The repository's action runs that scan, prints one annotation per place on its file and line, adds a summary, uploads the findings to code scanning if you ask, and then fails the job:
permissions:
contents: read
security-events: write # only for sarif: true
steps:
- uses: actions/checkout@<commit>
with:
persist-credentials: false
- uses: hakanbogan/obelize@<commit> # vX.Y.Z
with:
version: X.Y.Z
sarif: true
| Input | Description |
|---|---|
version |
Required. The obelize release to run. Pin it: any 0.x release can change what a scan reports. |
packs |
Pack ids, separated by spaces. Default: every pack the repository uses. |
library, modules |
A distribution that has no pack and the modules it is imported as: an inventory. Not with packs. |
fail-on |
The statuses that fail the job, separated by spaces. Default eligible needs_review unsupported; empty fails on none. |
sarif |
true uploads the findings to code scanning. Default false. |
working-directory |
The repository root to scan. Default .. |
The outputs are exit-code and sarif-file. GitHub shows the first ten annotations of each level;
code scanning shows all of them. The action checks each input against the shape it may have before
it reaches a command line. Hold the action to a commit like any other, and never run it on
pull_request_target: that event gives the job a token that can write, and a scan reads the pull
request's files.
With pre-commit¶
repos:
- repo: https://github.com/hakanbogan/obelize
rev: vX.Y.Z
hooks:
- id: obelize-scan
The hook runs obelize scan on the whole repository whenever a Python file is staged, and fails
while a finding has the status eligible, needs_review or unsupported. Set args to change
that, for example args: [--pack, py-pdf/pypdf2-to-pypdf, --fail-on, needs_review]. obelize needs
Python 3.12 or newer, so the hook's environment must be built on one.
Monorepos. The root is --repo (default: the current directory). The configuration,
.obelize/ and every include and exclude pattern are relative to it, and every dependency
manifest below it is read. Run once at the top to migrate everything in one run, or once per package
with --repo to keep one package's run folder and one undo apart. A pack that moves a whole
repository (openai) moves the whole root or none of it. A module only the legacy distribution
installed counts as declared for a file where a manifest in its directory or above it declares it, so
one app's declaration does not cover another app.
obelize fix¶
Plan a migration and, with --apply, write it. Without --apply nothing is
modified.
| Flag | Description |
|---|---|
--pack <id\|path> |
As for scan. |
--repo <path> |
Repository root. Default: the current directory. |
--apply |
Write the planned edits. Without it no source file is written: the terminal lists each file's hunks, then prints up to 200 lines of the unified diff, with secrets redacted and control characters escaped, and the path of .obelize/runs/<id>/patch.diff, which holds all of it as it is. |
--allow-dirty |
Permit --apply with uncommitted changes. Without it, any modified or staged tracked file in the whole repository, or an untracked file on the plan, refuses the apply (other untracked files do not count, and the report counts the paths and never names them), so the git diff is the migration alone; so does tree_unknown (git failed, timed out or refused the directory). A run with no file to write and no model configured does not look at the tree. A dirty tree is recorded in the report. |
--verify "<cmd>" |
A verification command. Runs only with --apply, which refuses a program that cannot start (not on PATH, or not an executable file) with exit 2 before anything is read. Repeatable. Replaces the repository's, and runs without asking. |
--timeout <s> |
Override verify.timeout_s (at least 1). Neither set, it is 600. |
--non-interactive |
Never prompt; refuse a repository command not on the user allowlist unless --trust-repo-config is passed. |
--trust-repo-config |
Trust the repository's verify.commands in a non-interactive or CI run. |
--model <provider> |
none or openai_compat, replacing model.provider from your own file for this run. With more than one pack, or a pack that moves a whole repository, a model is not asked: --model openai_compat, or --accept-model while a model is configured, exits 2, and a model only your own file configures is skipped with one line on stderr. --model none, and --accept-model with no model configured, change nothing. |
--accept-model |
Also write the model's edits that passed validation. Requires --apply (exit 2 otherwise). Without this flag a proposal is only in plan.json, patch.diff and model/. |
--show-context |
Print what would be sent to a model and stop: no request, no run folder. Works with provider: none; not with --apply; needs exactly one pack, and not one that moves a whole repository. |
--json |
plan.json's bytes on stdout; the run folder's path on stderr. |
Phase order: check the working tree and every planned path; consult a
configured model; run the baseline verification; write the patch; compile every
.py file written; verify again. The last two belong to the verification: with
no command to run, or after a failing baseline, they do not run, and the only
compile check of the written bytes is the one the rules already made in memory
(output_does_not_parse). Consequences, each a case in
tests/fixtures/commands/ or tests/fixtures/providers/runs.yaml:
- The gate refuses an apply before any command runs, and before anything is sent to a model unless the file is one a model proposed. A refusal no gate can see in advance comes later, with the baseline already run: a tracked file a baseline command changed, or a race.
- A verification refused by the trust rule
does not stop the write: the run exits
5withnot_run/policy_refusedrather than looking like a repository that needed no work. - A failing baseline does not stop the write; the after-phase is skipped and
the run is
inconclusive/baseline_failed.
Exit codes: 0 an apply with nothing outstanding, and every dry run, review
items or not; 3 verification failed after the patch; 6 patch applied, no
verdict; 4 an apply wrote nothing or left review items; 5 policy refusal
(dirty tree, untrusted command, path escape); 7 invalid pack; 2 usage error,
including a pack path with nothing there, a --verify that needs a shell or
does not split into arguments, an unusable .obelize.yml and an invalid
~/.config/obelize/config.yml; 1 unexpected error, including an unwritable
run folder.
Review items are run.json's withheld[] rows, so 4 is the usual result
on a real repository and 0 claims the whole repository migrated. A pipeline
should accept 0 and 4. --json prints plan.json, which holds the edits the
rules made or refused; the findings left for review, one row each with the code
that held it, are in run.json's withheld[], so read that file to list them.
The summary ends with the next step: a Next: line holding the command to run after a dry run
that planned a file (obelize fix --apply), after an apply that exits 0, 4 with review items,
5 or 6, and after a scan with an eligible row and no blocked pack (obelize fix). A command
repeats the --pack and --repo you gave. After an apply that wrote a manifest pin with a lock file
(uv.lock, poetry.lock, Pipfile.lock and the like) in the manifest's directory or above it, one
more Next: line comes before it, naming those files, which obelize does not edit, and saying to
regenerate each with the tool that made it; it holds no command, since a command would have to come
from the repository. A lock in a sibling or a subdirectory belongs to another project and is not
named. REPORT.md says the same under Dependency manifests, for a dry run too.
Findings left for you are summarised whenever there are any, with a file written or not: how
many, the most common reasons with a sentence on what to change for each (the last column of
SCAN_VOCABULARY.md), up to five of the rows that cause them (path:line symbol code,
with and N more in REPORT.md after them), the files that hold the pin without a row (a notebook
that calls the legacy API, which obelize reads and never writes, a file it does not analyse, or, for
a pack with one distribution for both APIs, a .py file it could not read; migrate them by hand,
raise max_file_bytes, or list one under exclude to migrate the rest), and a link to what
each code means. A pack whose one distribution holds both APIs adds a line saying the whole
repository moves or none of it.
A warning on an edit that is written (or would be, in a dry run) is printed as
Check <file>:<line>: <code>: <what to look at>. After --apply the line is the one before the
change, and the line says so.
What a pack says about the findings it flagged follows the counts, in scan and in fix: for
each of its flagged changes that claimed a finding, its id, how many findings, its message and its
suggestion. REPORT.md has the same under Guidance, and lists every limitation of the packs that
ran under Limitations and their verification suggestions under Verification. --json prints none
of it.
With a model configured, one stderr line first names the provider, the host
of base_url (never the URL, which can carry a credential) and the model. The
run adds run.json's model object and a model/ directory with one file per
consultation (RUN_FOLDER.md). A written model proposal stays
in withheld[]: validation shows that an edit is safe to apply, not that it is correct.
With verification commands, one stderr line comes before each phase of tests, before the write
and after it (verify prints it for its one phase): how many commands are about to run and after
how many seconds each is stopped. It never names a command, which can carry a credential.
Several packs in one run¶
scan and fix read the repository once and scan it with every pack they try. A pack runs when
--pack names it, or, without --pack, when it has a finding that is not not_a_usage or a file
that uses its legacy API and that no rule writes (a notebook, a stub); a run
that chooses none prints No pack applies. Checked: <ids>., writes a run folder with packs: []
and exits 0.
Two packs that cannot run together are refused (exit 2): their legacy modules overlap, one writes
a module the other migrates, or they name one distribution. Otherwise the packs run in id order,
each over what the ones before it wrote, in memory, and the run has one gate, one write, one
verification and one undo. Each pack keeps the file and manifest rules of a single run, so one can
leave a file untouched while another writes it, and a pack the repository rules out (a declared
Python it cannot install on, a legacy pin it was not measured on, a pin of the new library below
the releases it writes) is withheld alone, for that reason. An edit's rule_id is
<pack id>:<rule id>, and its line is in the file as its pack saw it.
A pack whose new library is the same distribution as the old one (openai/openai-0-to-1: openai
0.x and 1.x are one install) is all or nothing inside its own turn, and its tests can run against
only one release at a time. To check it: pass your tests on 0.x, run obelize fix --apply, install
the release the pin names (uv pip install --python .venv/bin/python "openai>=1.109.1"), then
obelize verify --run <id> with the <id> the Next: line or .obelize/latest gives. --verify on
the fix itself cannot work once 1.x is installed, because the baseline then fails. One pin serves both APIs, so
while any row of the pack is withheld, whatever the reason, it writes no file and leaves the pin,
each row of another file it would have written reads repo_not_fully_migrated, and an apply exits 4. scan still
grades each file on its own, so it can show eligible rows that fix then withholds. A declaration
of openai that already admits only 1.109.1 or later is left as written. One that cannot be rewritten
in place, with extras, a URL, a hash or an editable requirement, holds every file too: scan marks it manifest_pin_shape_unsupported.
A model is never asked about such a pack: a proposal is one row, and the pack writes a whole
repository or none. --model openai_compat, --accept-model while a model is configured, and --show-context exit 2
with it, as with several packs, and a model
only your own file configures is skipped with one line on stderr:
The configured model was not asked: <pack> moves a whole repository. For several packs the line
ends this run uses more than one pack. fix prints it, and No pack applies, on stderr, so
--json keeps stdout to one document.
obelize verify¶
Re-run an existing run's verification and update its evidence.
| Flag | Description |
|---|---|
--run <id> |
Required. The run id under .obelize/runs/. |
--repo <path> |
Repository root. Default: the current directory. |
--verify "<cmd>" |
A verification command. Repeatable. Replaces the repository's. |
--timeout <s> |
Override verify.timeout_s (at least 1). Neither set, it is 600. |
--non-interactive |
Never prompt; refuse a repository command instead. |
--trust-repo-config |
Trust the repository's verify.commands for this run. |
--current |
Check the tree as it stands, even where it differs from what the run wrote, and record which files differ in verify.edited instead of answering tree_changed. An --apply run that wrote no file can be checked this way. |
--json |
The run's record as JSON on stdout instead of the summary: the bytes of the run.json it wrote. The run folder's path goes to stderr, and the exit code is the verdict's. |
If the files still match the run's after_sha256, the after-phase runs again;
otherwise the result is inconclusive / tree_changed and nothing runs, unless you pass
--current, which runs it on the tree as it stands (a person or an agent finished the migration by
hand) and lists the files that differ. If
the run's baseline did not pass, nothing runs and the recorded verdict stands.
Commands come from your configuration and trust is decided again, never from
the run folder, which a repository could forge (a forged record). Only the
verification parts of the folder are replaced
(RUN_FOLDER.md); the original baseline is kept but not
carried into the new record.
Exit codes: 0 pass; 3 fail; 6 inconclusive, or not_run other than
policy_refused; 5 policy_refused; 2 usage error, including a malformed
run id, a missing or unreadable run folder, a run that wrote no file (or, with --current, one
that was not an --apply), a
--verify that needs a shell or does not split into arguments, and an invalid
~/.config/obelize/config.yml; 1 unexpected error, including a result that
could not be recorded.
obelize undo¶
Revert the files an applied run wrote.
| Flag | Description |
|---|---|
--run <id> |
Required. The run id under .obelize/runs/. |
--repo <path> |
Repository root. Default: the current directory. |
--json |
What became of each file as JSON on stdout instead of the summary: the bytes of the undo.json it wrote. The run folder's path goes to stderr, and the exit code is 0 or 4 as before. |
A file is reverted only if its current hash equals the run's after-hash, and
only from a snapshot whose bytes match its content-addressed name. Otherwise it
is skipped and reported with both hashes and its snapshot path under
.obelize/runs/<id>/snapshots/before/. No --force: obelize never overwrites
content it did not write. undo.json gets a row for every file the run
wrote, so a partial undo cannot read as complete
(RUN_FOLDER.md). A file that already holds its original counts as put
back, so a second undo of the same run exits 0, and it leaves the first undo.json as it was.
undo reads no .obelize.yml, so a mistake in it never stands between you and putting files back.
The record formats change between 0.x releases. A run another release wrote, whose run.json this
one cannot read, exits 2 and names that release with the command that undoes the run:
uvx obelize@<version> undo --run <id>. verify says the same.
Why a file was not put back¶
A closed set (models.UndoSkip).
| Value | Means |
|---|---|
hash_mismatch |
Edited after the run. Both hashes and the snapshot path are printed, and "(already the original)" when the file holds its original again, after an earlier undo for example. |
missing |
Nothing is at that path. |
not_a_file |
Not a regular file. |
outside_root |
The recorded path leaves the repository, or is in .git/ or .obelize/, which a run never writes: the run folder was edited by hand. |
snapshot_unusable |
The snapshot is absent or does not match its name; patch.diff still describes the change. |
symlink |
Now a symbolic link, which obelize never follows. |
unreadable |
Could not be read, or the replacement could not be written. |
Exit codes: 0 all reverted; 4 nothing reverted or files skipped, including
an apply that wrote no file; 2 usage error, including a malformed run id, a
missing run folder, and a plan or scan run; 1 unexpected error, including
files restored but undo.json unwritten (the message says which).
obelize mcp¶
Serve obelize to a coding agent over the Model Context Protocol, on standard input and output. Start it from the agent's own configuration, not in a terminal:
{ "mcpServers": { "obelize": { "command": "uvx", "args": ["obelize", "mcp"] } } }
| Flag | Description |
|---|---|
--repo <path> |
The repository to serve. Default: the current directory. The tools take no path. |
--trust-repo-config |
Let the verification tools run the commands in this repository's .obelize.yml without asking, as for verify. Only you can set it: no tool can. |
--read-only |
Leave out obelize_apply and obelize_undo, the two tools that write source files. The others still write the run folder under .obelize/, and obelize_verify runs your tests. |
Each tool runs the command of the same name as a child process and returns what it printed, so a tool does what the command does: a dirty tree is refused, a verification command that is not trusted does not run, and no model is consulted. The result is JSON text holding the exit code and what it means, the run id and the path of its folder, and the document the command printed.
| Tool | Runs | Arguments |
|---|---|---|
obelize_scan |
scan --json |
packs, or library with modules |
obelize_plan |
fix --json |
packs |
obelize_apply |
fix --apply --json --non-interactive |
packs |
obelize_residue |
scan --json, then the code around each place left |
packs, limit |
obelize_verify |
verify --json --non-interactive |
run, current |
obelize_report |
reads REPORT.md |
run, default the latest |
obelize_undo |
undo --json |
run |
An argument is a pack id, a distribution name, a module name, a run id, a number or a boolean, each
checked against its shape before it reaches a command line, and a list holds at most 32 names. A pack
must be one obelize knows, from the wheel or a directory your own configuration lists; a file in the
repository cannot be named. obelize_verify and obelize_undo take only a run this session made,
so a run folder the repository carries is not one. No tool takes a command to run, a path, a model or
a consent. obelize_residue gives each place a scan could not migrate with its reason,
what to change and the code around it (the window a model would be shown, redacted by the same
redactor), and the pack's guidance; an agent edits those places and calls obelize_verify with
current set. What a tool returns quotes the repository's code and is data, not instructions.
--trust-repo-config trusts the .obelize.yml the repository had when the server started: an edit
of that file, or one created later, ends the trust until you restart the server. Whichever commands
you allow, they run your tests, and your tests run the code the repository holds, an agent's edits
included; the tools cannot tell a test from an edit, so give the server to an agent you would let
run the tests yourself. A result is cut to about 200 KB, saying so; the child process starts with -P,
so a file of the repository cannot stand in for one of obelize's imports.
Exit codes of the command: 0 the client closed the stream; 2 --repo is not a directory.
obelize outdated¶
List the dependencies a repository declares that the package index says have moved on. It reads the manifests, asks the index about each distribution, and installs nothing and writes no file in the repository; the answers are cached for you. Every host is printed on standard error before it is contacted.
| Flag | Description |
|---|---|
--repo <path> |
Repository root. Default: the current directory. |
--all |
Also list the dependencies that are current. |
--json |
The rows as JSON on stdout, a document described by outdated.schema.json. |
--offline |
Reach no host: use only what an earlier run kept in the cache. |
A row has the distribution, the declaration as written, the release it starts at (the one it pins,
else its lower bound), the newest release that is neither withdrawn nor a pre-release, and the gap
between them: major, minor, patch, none, or unknown where the declaration names no
release. It carries the flags inactive (the library marks itself Development Status :: 7 -
Inactive), yanked (the release in use was withdrawn) and advisory (the index lists a
vulnerability for that release; the ids are in advisories), and what obelize can migrate it with:
bundled (a pack that migrates that release; the id is in pack), synthesizable (two releases to
compare with pack synth) or none. A distribution the index does not have is a row whose error
says so, as a private one would be. By default only rows with a flag, a major gap or an error are
listed, worst first: an advisory, then the size of the gap.
Exit codes: 0 listed; 2 the request is wrong (a --repo that is not a directory, an invalid
configuration file); 6 the index answered no question for any distribution.
obelize upgrade¶
Show what moving one dependency to a newer release would change, and write no file of your code. It plans the migration as fix does without --apply, with the pack
that suits the move.
| Flag | Description |
|---|---|
<distribution> |
The dependency to move, as the manifests name it. Without one, the command lists the dependencies that outdated shows (a flag, or a major release behind) and obelize can migrate, and where a person can be asked, asks which (a number or a name). Where no one can be asked it prints the list and exits 2. |
--from <version> |
The release the code is written for. Default: the one the manifests pin, else their lower bound; required when they name none. |
--to <version> |
The release to move to. Default: the newest that is neither withdrawn nor a pre-release, or a pre-release if the library has no other. |
--module <module> |
The module to compare, as for pack synth. |
--repo <path> |
Repository root. Default: the current directory. |
--offline |
Reach no host: use only what an earlier run kept in the cache. |
--apply |
Try the move in a copy of the repository, and write it only if the tests agree (a trial). |
--yes |
With --apply, agree to the trial without being asked. |
--python <version> |
The Python the trial's environments use. Default: the repository's .python-version, else uv's own. |
--test-command "<cmd>" |
The tests to run in the trial. Default: pytest, if the repository is set up for it. |
--timeout <seconds> |
How long the tests of the trial may run, each time. Default: 1800 (at least 1). Tests that do not finish are an inconclusive trial that says so. |
--branch |
With --apply, commit the move to a new branch and leave the working tree as it is. Nothing is pushed. |
--branch-name <name> |
The branch --branch makes. Default: obelize/upgrade-<the release moved to>, such as obelize/upgrade-pydantic-2.5.0. Giving a name implies --branch. |
--agent <backend> |
With --apply and --branch, put the places the pack only flags to your own coding agent, in the trial's copy (claude_code). Overrides agent.backend. |
--max-asks <n> |
The most places --agent is asked about in one round (at least 1). Default 50. |
--max-rounds <n> |
The most rounds --agent is put the tests that broke after the move (at least 0). Default 3; 0 asks none. |
The pack is, in this order: a known one (a bundled pack, or one in a directory of your own) that
migrates the dependency from that release and, if you named --to, to that one; else the pack
made from the public API of the two releases, kept in the cache under the name of both, so the same
move reads the index once. The first line says which, and the rest is what fix prints, with its
run folder under .obelize/. Exit codes are fix's, plus 2 for a dependency the manifests do not
name when --from is missing, a version or a library the index does not have, and nothing that
changed between the releases, 6 for an index that could not be reached, and 1 for a pack
obelize made that fails its own checks.
A trial¶
upgrade --apply writes nothing until the move has been tried. It asks first, because a trial
installs packages, which runs their build scripts, and runs the repository's tests: --yes, or an
answer at a terminal when CI is not set; otherwise it exits 2. A tree with uncommitted changes
to tracked files is refused (exit 5) before anything starts. Then, in a copy of the repository made from the files
git lists and in two environments made with uv, outside the
repository:
- the dependencies are installed (from
uv.lock, elsepyproject.toml, else therequirements*.txtat the top of the repository), plus pytest, once with the release in use and once with the release moved to installed over it; - the tests run on the unchanged copy with the first environment;
- the move is written to the copy;
- the tests run on the changed copy with the second environment, and the tests that failed are run once more.
The tests are python -m pytest --tb=native -q --continue-on-collection-errors: a module that does
not import is an error in both runs and does not hide the tests of the others. A module that
imports before the move and not after it is a break.
A test is its junit classname::name. The move is written to your repository only if no test
that passed in step 2 now fails, errors or is missing, not counting one that passed on the second
try. The write itself is fix --apply, whose exit code upgrade returns, except that the lack of a
verification command of its own, which the trial stood in for, is 0. Otherwise nothing is written:
exit 3 names the tests that broke, and exit 6 says the trial could not judge (no tests, no
result obelize can read, no test that passed before, tests that did not finish, a move that changed
no file, or a place left for a person). Exit 6 also ends a trial whose
environments could not be made.
With --branch, a passed trial is committed instead of written: one commit on a new branch of the
name you gave (or the default), on top of HEAD, holding the files the move wrote, with the trial's
id as a trailer (Obelize-Trial:). It is built with git's plumbing from the bytes the trial wrote
into its copy, so your working tree, your index and your uncommitted work are untouched, no hook runs and the
commit is not signed. A branch that exists is never moved (exit 2), and the command never pushes.
Review it with git log -p -1 <branch>.
The places a pack only flags¶
A library that keeps its import name across the move is one distribution for both APIs, so the
rules write nothing while one flagged place is left, and a pack made from two releases flags most
of what changed. --agent claude_code (with --apply and --branch) puts each flagged place to
your own coding agent, in the trial's copy and before the rules run again, when the first run of
the rules wrote nothing, which a library with one release for both APIs always does until every
place has moved: the agent is the
claude program on your PATH, started in an empty directory with every tool off, no session, no
MCP server, no skill and none of your settings (so no hook of yours runs), and with an allowlist of
environment variables (which); a program that does not offer
those restrictions is not used. It is sent the pack's own words about the place, the closest new
names hints.json offers if there are any, and the lines around it, redacted, as a text question, and asked
for one edit of those lines or none. The guard that checks every model proposal checks it: it must
stay inside what was sent, parse, and name or import nothing beyond the question. An accepted edit
goes into the copy, the rules then write the rest, and your tests judge the whole. What the agent
changed is named in the commit message and in trial.json, because it is the part to read first.
Nothing it writes reaches your working tree: that is why --agent needs --branch.
If tests that passed before the move now fail, the agent is put them too, up to --max-rounds
times (3). A failing test points, in its Python traceback, at the lines where it broke; where such a
line is in a file the move wrote and is not a test, that place goes to the agent with the error, and
the tests run again. These rounds never edit a test file, since it is the evidence; a flagged place
in a test file is asked about like any other, in the first pass. The rounds stop when the agent
offers nothing or no fewer tests break, and a move whose tests still fail is not written.
Your own login decides where the excerpts go, as it does for anything you ask claude; see
the privacy notes. The evidence is .obelize/trials/<id>/: trial.json, the junit
file and output of each run, and what each environment held (old.txt, new.txt).
obelize pack validate¶
Validate a pack against PACK_SPEC.md: every field, vocabulary
and cross-field rule, each error naming the
key's path. Declared fixture paths are checked as paths, not for existence,
since a local pack need not ship fixtures (the last output line says so).
source.url is never requested.
| Argument | Description |
|---|---|
<id\|path> |
A bundled pack id (gemini/google-generativeai-to-google-genai, openai/openai-0-to-1, py-pdf/pypdf2-to-pypdf) or a pack file path (./pack.yaml). An id wins over a path spelled the same; anything not shaped like an id is a path. |
Exit codes: 0 valid; 7 invalid YAML or schema; 2 nothing to read there;
1 a bundled pack is invalid (an obelize defect; the message links the
issue tracker).
obelize pack test¶
Run a pack over its own fixtures and sources. pack validate reads the pack alone; this
command checks it against the files beside it, with the checks the three bundled packs pass in
the test suite:
- every fixture the pack names is on disk, every file under
fixtures/is named, and every answer key (<name>.after.<ext>) sits beside its input; - every change has a positive and a negative fixture;
- every Python fixture parses, reads back byte for byte and compiles;
- a positive fixture holds a prefilter token and a negative one does not, so the scan skips it (a shared module's negatives are scanned instead and must find nothing to migrate);
- an answer key exists for exactly the fixtures a run migrates whole, and each is what the rules write into its fixture, the dependency fixture included;
- applying the pack twice writes what applying it once did, and two runs write the same bytes;
SOURCES.mdhas a section for every change, none for a change that does not exist, and names the source url and, if the pack records one, the hash of the page.
| Argument | Description |
|---|---|
<id\|path> |
A pack id, a path to a pack file, or the folder that holds pack.yaml. |
The output is one line per check, ok or FAIL, and under each failure the file and the reason.
Exit codes: 0 every check passed; 7 a check failed, or the pack is invalid; 2 nothing to read
there; 1 a bundled pack failed (an obelize defect).
obelize pack new¶
Make a pack that loads, with fixtures and sources to edit. The skeleton renames one module and moves
the dependency line, and its fixtures pass pack test as made. The .after files are what the
rules wrote into the fixtures, so read them before you trust them.
| Flag | Description |
|---|---|
<id> |
Required. The pack's id, <provider>/<name>, in lowercase letters, digits and hyphens. |
--from "<requirement>" |
Required. The distribution to migrate from and the releases, with a lower bound: "PyPDF2>=3,<4". |
--to "<requirement>" |
Required. The distribution to migrate to and the releases: "pypdf>=6.19". |
--guide <url> |
Required. The https address of the official guide the migration follows. It is cited and never requested. |
--module <module> |
The module code imports from the old distribution. Default: its name, with hyphens and dots as underscores. |
--to-module <module> |
The module code imports from the new distribution. Default: its name, the same way. |
--requires-python <range> |
The Pythons the new distribution installs on. Default >=3.9. |
--dir <path> |
Where to make the pack's folder, <provider>/<name>. Default: the current directory. |
It never writes into a folder that exists, and removes the one it made if the pack would not load.
Exit codes: 0 made; 2 the request is wrong or the folder exists. To use the pack from any
repository, put its directory in pack_dirs in your own ~/.config/obelize/config.yml, or pass
--pack <folder>/pack.yaml.
obelize pack synth¶
Make a pack from what two releases of a library offer, for a library that has none. The command
asks the package index for the project, downloads the 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 files that name its modules (METADATA, RECORD,
top_level.txt and entry_points.txt) and nothing else, reads what each exports without importing
or building anything, and compares them. Every host is printed on standard error before
it is contacted.
The pack renames a name the library's own deprecation message says to use another for (old_greet
is deprecated, use greet instead), moves up a name that left a submodule for its package, flags the
rest for a person (removed, moved elsewhere, a signature that changed, deprecated) with the
library's words where it gave any, and moves the dependency line. Its fixtures are written from the
comparison and its .after files from the rules, as pack new does, and the pack is held to
pack test before it is kept. A change the rules cannot make as the comparison says is not made.
| Flag | Description |
|---|---|
<distribution> |
Required. The library on the index, such as acme-kit. |
--from <version> |
Required. The release the code is written for. |
--to <version> |
The release to move to. Default: the newest that is neither withdrawn nor a pre-release, or a pre-release if the library has no other. |
--module <module> |
The module to compare, when the library has more than one. Default: the one both releases have, or the one named like the library. |
--used-by <path> |
Flag only the names the Python code in this folder reaches through its imports, for a library that changed more names than a pack can hold (pandas, for one): the command says so and exits 2 without it. A star import of the library leaves no telling which names are used, and is refused. The pack's id ends with a digest of the names. |
--dir <path> |
Where to make the pack's folder, <provider>/<from>-to-<to>. Default: the current directory. |
--offline |
Reach no host: read only what an earlier run kept in the cache. |
A name is renamed or moved only on evidence the two surfaces carry: it kept its name and signature and has a body, or the library's message names one name beside it. Everything else that left the API is flagged, never rewritten, and a pack made from two releases says in its limitations that a change in what a function does is not in it. The tests of the repository show that.
It never writes into a folder that exists, and removes the one it made if the pack fails its own
checks. Which index it asks, and whether it may ask at all, are in
your own configuration file, never in a repository's. Exit codes:
0 made; 2 the request is wrong (a version or a library the index does not have, a module to
choose, nothing that changed, a folder that exists, a release that cannot be read); 6 the index
could not be reached or answered, or --offline found nothing kept; 1 a pack obelize made fails
its own checks.
Exit codes¶
Shared by every command; unstable through 0.x (CHANGELOG.md).
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Unexpected error |
2 |
Usage error |
3 |
Verification failed after patch |
4 |
Nothing applied / manual review required |
5 |
Policy refusal (dirty tree or one git cannot describe, untrusted verification command, path escape) |
6 |
Verification produced no verdict (inconclusive, or not_run for any reason other than policy_refused); for pack synth, outdated and upgrade, the index could not be reached; for upgrade, a trial that could not judge or whose environments could not be made |
7 |
The pack is not valid |
2 (argument parsing) and 1 (the top-level handler) take precedence; among
the rest the first match wins, in detection order: 7, 5, 3, 6, 4,
0.
Verification status and exit code¶
verify.status |
verify.reason |
Exit | When |
|---|---|---|---|
pass |
none | 0 |
Every command succeeded after the patch |
fail |
command_failed, changed_file_does_not_compile |
3 |
A command failed after the patch, or a changed file does not compile |
inconclusive |
timeout, tree_changed, baseline_failed, command_not_executable |
6 |
A verdict was expected and none was obtained |
not_run |
no_verify_commands |
6 |
The patch was applied and no command was configured |
not_run |
policy_refused |
5 |
A repository-sourced command refused in CI without trust |
not_run |
dry_run, no_changes_to_verify |
n/a | No verdict was expected; the exit code comes from the plan |
Several commands give the worst status: fail > inconclusive > not_run >
pass. Trust is evaluated only when verification will run, so a run that wrote
nothing never refuses a command.
The verification vocabulary¶
Closed sets, declared as Literals in src/obelize/models/vocabulary.py and held equal
to these tables by tests/unit/test_verify_vocabulary.py.
verify.status (4 values)¶
| Value | Means |
|---|---|
pass |
Every command that ran succeeded; never over an empty list. |
fail |
A command, or the compile check of changed files, said no. |
inconclusive |
A command gave no answer that is evidence about the patch. |
not_run |
No command ran; the reason says why. |
verify.reason (10 values)¶
null only under pass.
| Value | Under | Means |
|---|---|---|
baseline_failed |
inconclusive |
The commands failed before the patch, so the after-run is skipped. Fix your tests. |
changed_file_does_not_compile |
fail |
A .py file obelize wrote does not compile. Checked before the commands of the phase after the write, and so only where there are commands to run. |
command_failed |
fail |
A command exited non-zero, any non-zero exit: pytest's 5, which means no test was collected, is a failure here. |
command_not_executable |
inconclusive |
A command never started: no such program, or no permission. |
dry_run |
not_run |
No --apply. No exit code of its own. |
no_changes_to_verify |
not_run |
An apply that wrote no file. No exit code of its own. |
no_verify_commands |
not_run |
Files written, no command configured. Pass --verify or set verify.commands. |
policy_refused |
not_run |
A repository command was not trusted in this mode; one refusal refuses the whole phase. |
timeout |
inconclusive |
A command outlasted verify.timeout_s; its process group was killed, or its job object on Windows. |
tree_changed |
inconclusive |
obelize verify found hashes other than the run recorded. |
verify.commands[].source (3 values)¶
Why a command could run, not where it was read: a repository command also on
your allowlist is user_allowlist.
| Value | Means |
|---|---|
cli |
--verify on this invocation. Level 1. |
user_allowlist |
Matches ~/.config/obelize/config.yml. Level 2. |
repo_config |
.obelize.yml, allowed by an approval or --trust-repo-config. Level 3. |
Configuration file (.obelize.yml)¶
At the repository root; flags take precedence. A file that does not parse, is larger than
1 MiB, or has an unknown, duplicate or wrongly typed key, exits 2 before anything is
read; it is never partially applied. A number or a flag is not read from text: allow_dirty: "yes"
and verify.timeout_s: "60" are errors, and so is a YAML set, which has no order.
| Key | Type | Default | Description |
|---|---|---|---|
include |
string | **/*.py |
Files the scan considers, after all exclusions. Same semantics as exclude. |
exclude |
list of strings | empty | Patterns to skip, with git's .gitignore semantics (pathspec.GitIgnoreSpec): legacy/* excludes everything under legacy/, at any depth. Added to the always-excluded set; a ! pattern re-includes only what this list excluded. |
verify.commands |
list of strings | empty | Verification commands, under the trust rules. Split with shlex and run without a shell, so a bare \|, && or > is refused on load; use sh -c "<command>", or cmd /c "<command>" on Windows. |
verify.timeout_s |
integer | 600 |
Per-command timeout in seconds; on expiry the process group (the job object on Windows) is killed and the status is inconclusive / timeout. |
verify.junit |
boolean | true |
Add --junitxml into the run folder to a pytest or python -m pytest command that has none, to compare per-test results with the baseline. |
allow_dirty |
boolean | false |
Same as --allow-dirty. |
max_file_bytes |
integer | 2000000 |
Larger files are recorded as a limitation, not parsed. |
A model: block, pack_dirs, network and agent are refused (exit 2): where your code and
credentials go, which packs may rewrite your files, which index obelize reads code from and which
program your code is put to are not a repository's choice. Configure them in
your own file, or name a pack with --pack.
The always-excluded set¶
Applied first on every run; no ! pattern brings these back.
| Pattern | Why |
|---|---|
.obelize/ |
Run folders; otherwise a scan reports the previous report. |
.*/ |
Every dot-directory: .git, .venv, .tox, caches, editor state. |
__pycache__/ |
Bytecode. |
site-packages/ |
Installed packages, in a virtual environment of any name. |
venv/ |
The usual undotted virtual environment. |
node_modules/ |
Not Python. |
vendor/, _vendor/ |
Vendored source. |
build/, dist/, *.egg-info/ |
Build output, a second copy of your source. |
Only these, and the fixtures/ directory of a pack (below), skip a directory whole. Your own
exclude never does: an excluded file that still imports the legacy distribution is still named,
because the dependency pin it breaks is not excluded. Excluding means do not
migrate, not do not look.
The fixtures/ directory beside a pack file inside the repository is skipped the same way, for
every pack of your own the run loaded (--pack <path>, or a directory of your own packs; the packs
the wheel carries add none): its fixtures are legacy code by design, and scanned they would be
rewritten. Only that directory goes. A file the pack sits beside is your own, is read, and still
holds the dependency pin when it imports the legacy distribution. A pack.yaml at the repository
root takes nothing, since a fixtures/ there is as likely your own. REPORT.md says Not scanned
and names the directory, and --list-files leaves it out. run.json records no change: this is not
an exclude.
In a git repository, files come from
git ls-files --cached --others --exclude-standard, so .gitignore applies.
Elsewhere a fallback walk ignores nothing but skips directories holding a
pyvenv.cfg. Symbolic links are never followed and submodules never entered;
each is reported, so a low finding count is not mistaken for a clean
repository.
Merging the command line with the file¶
Other flags have no file equivalent.
| Flag | Key | Rule |
|---|---|---|
--verify "<cmd>" |
verify.commands |
Replaces the list, so every command is one you typed. |
--timeout <s> |
verify.timeout_s |
Replaces. |
--allow-dirty |
allow_dirty |
Adds permission; it cannot turn off allow_dirty: true. |
--model <provider> |
model.provider in ~/.config/obelize/config.yml |
Replaces; the result is re-validated, so openai_compat without a base_url fails here. |
What a selected file still has to survive¶
- Size: over
max_file_bytes, not read; a limitation names both numbers. - Byte prefilter: without any of the pack's tokens, reported as nothing.
compile(), a depth check, then libcst: a refusal by any is oneparse_errorand no edit.compile()also catches what libcst accepts, such asreturnoutside a function or a late__future__import, and an expression nested so deeply that libcst's parser would crash the process. Some versions ofcompile()accept a longer one, so the depth check refuses a file nested more than 300 levels deep (a chain of that many operands, attributes or calls, a dotted name, anelifladder; brackets inside brackets count for more, so about a hundred of them are the limit).- Byte round-trip: if libcst does not reproduce the file exactly (bare-CR line endings, for example), every finding in it is withheld.
An unexpected error while obelize reads or rewrites one file does not end the
run: a scan reports the file as one parse_error, and fix leaves it as it was
and withholds its rows as internal_error. run.json's limitations names
the exception's class, never its message. That is a defect in obelize, so
please report it.
compile() is the running interpreter's: under 3.12, syntax from a later
Python (3.14's unparenthesised except A, B:) is refused as unparsable, while
3.14 accepts Python 2's except E, e: (PEP 758). Run obelize under a Python at
least as new as your code.
Worked example¶
# .obelize.yml: repository-level configuration for obelize.
# Every key is optional; the defaults are documented in docs/CLI.md.
include: "**/*.py"
exclude:
- "migrations/**"
- "docs/examples/**"
verify:
commands:
- "pytest -q tests/unit"
- "python -m mypy src"
timeout_s: 900
junit: true
allow_dirty: false
max_file_bytes: 2000000
Trust rules for verification commands¶
A command's source decides whether it may run, following the trust rule in THREAT_MODEL.md. Highest first:
--verify "<cmd>": always.- The allowlist in
~/.config/obelize/config.yml. verify.commandsin.obelize.yml.- A pack's
verification.suggestions: never, under any flag; listed inREPORT.mdas "suggested, not run".
Level 3 depends on the mode:
- Local interactive (a TTY,
CIunset, no--non-interactive): each command is printed and confirmed once withy; the approval, keyed by repository path and command sha256, is kept in~/.config/obelize/approved.json, and changed text asks again. - Non-interactive or CI: runs only if allowlisted or with
--trust-repo-config; otherwisenot_run/policy_refused, exit5.
Commands run in the repository root, without a shell (shlex.split,
shell=False), stdin closed, in their own session, with your environment plus
OBELIZE_RUN=1. They are not isolated: a command can do anything your
shell can. On timeout the whole process group is terminated, then killed. On
Windows a command runs in a job object of its own, which the deadline terminates
at once, and only an .exe or .com program runs: a batch file is refused.
The user files (~/.config/obelize/)¶
Outside every repository, so no checkout can write them ($XDG_CONFIG_HOME is
honoured). On Windows the folder is %USERPROFILE%\.config\obelize unless HOME
is set. config.yml holds the five settings a repository may not make: the
allowlist, the model, the directories of your own packs, the index a command that reads releases
asks and the coding agent upgrade may put places to. Unknown keys are an error, and so is
a file larger than 1 MiB.
# ~/.config/obelize/config.yml
verify:
allow:
- "pytest -q"
- "python -m mypy src"
pack_dirs:
- /home/me/obelize-packs # absolute; each holds <provider>/<slug>/pack.yaml
network:
index: https://pypi.org # https, or http to this machine; no credential or query
offline: false # true: a command that reads releases uses only its cache
model:
provider: none # the default; no network call is made while it is `none`
# base_url: "http://localhost:11434/v1"
# allow_insecure_http: false
# model: "qwen2.5-coder"
# api_key_env: OBELIZE_MODEL_API_KEY
# log_prompts: false
pack_dirs adds to the packs the wheel carries: a pack there is chosen by id like a bundled one,
and an id the wheel or another directory already holds is refused. Each entry must be an
absolute path to an existing directory (~ is expanded), and a pack in one is validated like
any other file (exit 7), never trusted more. A repository's .obelize.yml may not set it. A
directory inside a repository you scan is scanned too, so list it in exclude.
The model keys are acted on by obelize fix. A value of another type is refused, not converted
(log_prompts: 'yes', a number for model).
| Key | Type | Default | Description |
|---|---|---|---|
model.provider |
none | openai_compat |
none |
Model adapter for ambiguous cases; none makes no network call. |
model.base_url |
string | unset | An OpenAI-compatible endpoint, such as a local Ollama. http or https, with no credential, query string or fragment (the key goes in api_key_env): the host goes into the evidence. |
model.allow_insecure_http |
boolean | false |
Permit a plain http:// base_url that is not localhost, 127.0.0.0/8 or ::1, which sends the key and your source across the network in the clear. Unset, such a URL is refused. |
model.model |
string | unset | Model name sent to the endpoint. |
model.api_key_env |
string | OBELIZE_MODEL_API_KEY |
Environment variable holding the key; the key never reaches configuration, logs or evidence. |
model.log_prompts |
boolean | false |
Keep raw prompts, which contain source, in the run folder. |
The agent keys are acted on by obelize upgrade --apply --branch; network by pack synth,
outdated and upgrade.
| Key | Type | Default | Description |
|---|---|---|---|
network.index |
string | https://pypi.org |
The package index pack synth asks, as a Simple API address (/simple is added if it is missing). OBELIZE_INDEX_URL replaces it for one run; OBELIZE_INDEX_AUTHORIZATION is the value of an Authorization header, sent to this host and to none other. |
network.offline |
boolean | false |
Read releases only from the cache and keep a trial's installs to uv's own cache, as --offline does; OBELIZE_OFFLINE set to anything does too. |
agent.backend |
none | claude_code |
none |
The coding agent upgrade --apply --branch puts flagged places to, as --agent does. none asks no one. |
agent.model |
string | unset | Passed to the program as its own model name or alias. |
An allowlist entry matches a command that splits into the same arguments. An
unreadable or invalid file is an error, never an empty allowlist.
approved.json only caches your answers: if it is missing or unreadable you
are asked again.