Ganzua Manual
Quick Links: GitHub | PyPI | CLI Reference
Ganzua is tool for picking dependency information from Python lockfiles,
and manipulating the version constraints in pyproject.toml files.
For example, we can summarize the differences between two uv.lock files.
Ganzua is designed for scripting, so by default we get JSON output:
$ ganzua diff corpus/old-uv-project corpus/new-uv-project
{
"stat": {
"total": 2,
"added": 1,
"removed": 0,
"updated": 1
},
"packages": [
{
"name": "annotated-types",
"old": null,
"new": {
"name": "annotated-types",
"version": "0.7.0",
"source": "pypi"
}
},
{
"name": "typing-extensions",
"old": {
"name": "typing-extensions",
"version": "3.10.0.2",
"source": "pypi"
},
"new": {
"name": "typing-extensions",
"version": "4.14.1",
"source": "pypi"
},
"is_major_change": true
}
]
}
We can also opt in to Markdown (GFM) output, which will produce a summary and a table:
$ ganzua diff --format=markdown corpus/old-uv-project corpus/new-uv-project
2 changed packages (1 added, 1 updated)
| package | old | new | notes |
|-------------------|----------|--------|-------|
| annotated-types | - | 0.7.0 | |
| typing-extensions | 3.10.0.2 | 4.14.1 | (M) |
* (M) major change
Aside from inspecting or diffing lockfiles,
we can extract and manipulate constraints from pyproject.toml files:
$ ganzua constraints inspect --format=markdown corpus/new-uv-project/pyproject.toml
| package | version |
|-------------------|---------|
| annotated-types | >=0.7.0 |
| typing-extensions | >=4 |
For more examples and further background, see the announcement blog post.
What makes Ganzua special?
Ganzua is not a general-purpose tool.
It's focused solely on working with two modern Python project managers, uv and Poetry, and their native lockfile formats. In particular, there's no support for requirements.txt.
Ganzua strives to be complete, compliant, and correct. Ganzua is 0% AI and 100% human expertise, informed by reading the relevant PEPs, docs, and the source code of relevant tools. The tool is thoroughly tested with 100% branch coverage, and has seen extensive use in large-scale real-world projects. Ganzua is intentionally stupid and avoids dangerous stuff like editing lockfiles or interacting with Git.
Ganzua is designed for scripting. All subcommands are designed for JSON output first, with output that conforms to a stable schema. Where appropriate, Ganzua offers an optional Markdown view on the same data, which lets scripts generate human-readable summaries. Ganzua does not offer GitHub Actions, but it's really easy to integrate Ganzua into your CI workflows.
Next Steps
Continue reading the installation guide or the command line reference.
Installation
Ganzua is available on PyPI: https://pypi.org/project/ganzua/
Recommended: run or install via the uv package manager:
uvx ganzuato try Ganzua without installationuv tool install ganzuato install Ganzua on your machine
Alternative: run or install via the pipx tool:
pipx run ganzuato try Ganzua without installationpipx install ganzuato install Ganzua on your machine
When invoking Ganzua in scripts or in a CI job, consider pinning or constraining a version. This prevents your scripts from breaking when Ganzua has an incompatible change. For example:
uvx ganzua==0.4.0to pin an exact versionuvx 'ganzua>=0.4.0'to constraint to a version range (remember quotes to escape special characters like<)
To preview a bleeding-edge version without waiting for a PyPI release, you can install directly from the Ganzua repository on GitHub. For example:
uvx git+https://github.com/latk/ganzua.gitpipx run --spec git+https://github.com/latk/ganzua.git ganzua
Do not add Ganzua as a dependency to your project, instead prefer invoking Ganzua via uvx or pipx run.
You can technically install Ganzua into an existing venv using tools like uv, Poetry, or Pip.
But since Ganzua might require conflicting dependencies, and might even need a different Python version, this is likely to cause more problems than it solves.
Command Line Reference
This section documents all Ganzua subcommands.
ganzuaInspect Python dependency lockfiles (uv and Poetry).ganzua helpShow help for the application or a specific subcommand.ganzua inspectInspect a lockfile.ganzua diffCompare two lockfiles.ganzua constraintsWork withpyproject.tomlconstraints.ganzua constraints inspectList all constraints in thepyproject.tomlfile.ganzua constraints bumpUpdatepyproject.tomldependency constraints to match the lockfile.ganzua constraints resetRemove or relax any dependency version constraints from thepyproject.toml.
ganzua schemaShow the JSON schema for the output of the given command.
ganzua
Usage: ganzua [OPTIONS] COMMAND [ARGS]...
Inspect Python dependency lockfiles (uv and Poetry).
Options:
--helpShow this help message and exit.
Commands:
helpShow help for the application or a specific subcommand.inspectInspect a lockfile.diffCompare two lockfiles.constraintsWork withpyproject.tomlconstraints.schemaShow the JSON schema for the output of the given command.
For more information, see the Ganzua website at "https://github.com/latk/ganzua".
Ganzua is licensed under the Apache-2.0 license.
ganzua help
Usage: ganzua help [OPTIONS] [SUBCOMMAND]...
Show help for the application or a specific subcommand.
Options:
--allAlso show help for all subcommands.--markdownOutput help in Markdown format.
ganzua inspect
Usage: ganzua inspect [OPTIONS] [LOCKFILE]
Inspect a lockfile.
The LOCKFILE should point to an uv.lock or poetry.lock file,
or to a directory containing such a file.
If this argument is not specified,
the one in the current working directory will be used.
Options:
--name FILTERInclude/exclude packages to inspect by name. [default: show all]--format [json|markdown]Choose the output format, e.g. Markdown. [default: json]--helpShow this help message and exit.
Examples
We can load various example lockfiles:
$ ganzua inspect corpus/old-uv-project
{
"packages": [
{
"name": "example",
"version": "0.1.0",
"source": {
"direct": "."
}
},
{
"name": "typing-extensions",
"version": "3.10.0.2",
"source": "pypi"
}
]
}
$ ganzua inspect corpus/new-uv-project
{
"packages": [
{
"name": "annotated-types",
"version": "0.7.0",
"source": "pypi"
},
{
"name": "example",
"version": "0.1.0",
"source": {
"direct": "."
}
},
{
"name": "typing-extensions",
"version": "4.14.1",
"source": "pypi"
}
]
}
$ ganzua inspect corpus/old-poetry-project
{
"packages": [
{
"name": "typing-extensions",
"version": "3.10.0.2",
"source": "default"
}
]
}
$ ganzua inspect corpus/new-poetry-project
{
"packages": [
{
"name": "annotated-types",
"version": "0.7.0",
"source": "default"
},
{
"name": "typing-extensions",
"version": "4.14.1",
"source": "default"
}
]
}
Instead of producing JSON output, we can summarize lockfiles as Markdown:
$ ganzua inspect corpus/old-uv-project --format=markdown
| package | version |
|-------------------|----------|
| example | 0.1.0 |
| typing-extensions | 3.10.0.2 |
The input paths may point to directories or lockfiles. The following invocations are all equivalent:
$ ganzua inspect corpus/new-uv-project$ ganzua inspect corpus/new-uv-project/uv.lock
output for the above commands
{
"packages": [
{
"name": "annotated-types",
"version": "0.7.0",
"source": "pypi"
},
{
"name": "example",
"version": "0.1.0",
"source": {
"direct": "."
}
},
{
"name": "typing-extensions",
"version": "4.14.1",
"source": "pypi"
}
]
}
It is possible for a locked package to have no version
(see issue #4).
In this case, Ganzua will use the pseudo-version 0+undefined:
$ ganzua inspect corpus/setuptools-dynamic-version
{
"packages": [
{
"name": "setuptools-dynamic-version",
"version": "0+undefined",
"source": {
"direct": "."
}
}
]
}
Split versions
It is possible for a project to have multiple conflicting requirements, e.g. for different Python versions, extras, or groups:
$ cat $CORPUS/split/pyproject.toml
[project]
name = "split"
version = "0.1.0"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"typing_extensions >=4 ; python_version >= '3.14'",
"typing_extensions >=3,<4 ; python_version < '3.14'",
]
When inspecting the lockfile, Ganzua will show all candidates:
$ ganzua inspect $CORPUS/split/uv.lock
{
"packages": [
{
"name": "split",
"version": "0.1.0",
"source": {
"direct": "."
}
},
{
"name": "typing-extensions",
"version": "3.10.0.2",
"source": "pypi"
},
{
"name": "typing-extensions",
"version": "4.15.0",
"source": "pypi"
}
]
}
$ ganzua inspect $CORPUS/split/poetry.lock
{
"packages": [
{
"name": "typing-extensions",
"version": "3.10.0.2",
"source": "default"
},
{
"name": "typing-extensions",
"version": "4.15.0",
"source": "default"
}
]
}
Split version support was added in Ganzua 0.4.0. For background on this, see the issue ganzua#5.
Filtering
Normally, inspecting a lockfile will show all results:
$ ganzua inspect $EXAMPLE --format=markdown
| package | version |
|-------------------|---------|
| annotated-types | 0.7.0 |
| example | 0.1.0 |
| typing-extensions | 4.14.1 |
However, we can filter by --name to only look at a specific package:
$ ganzua inspect $EXAMPLE --name=annotated-types --format=markdown
| package | version |
|-----------------|---------|
| annotated-types | 0.7.0 |
Can also use glob patterns to select matching packages:
$ ganzua inspect $EXAMPLE --name='*ex*' --format=markdown
| package | version |
|-------------------|---------|
| example | 0.1.0 |
| typing-extensions | 4.14.1 |
For further details on filters, see the filter manual.
Filtering was added in Ganzua 0.4.0.
JSON Schema
Download: schema.inspect.json
Properties:
-
packages: array(LockedPackage)
All packages in the lockfile.In case of split versions, there can be multiple entries with the same package name.
Changed in Ganzua 0.4.0:
packagesis now a list. Previously, it was aname → LockedPackagetable.
type LockedPackage
Properties:
-
name: string
Name of the package.Added in Ganzua 0.4.0: previously, the package name was implicit.
-
version: string -
source:pypi|default|other| SourceRegistry | SourceDirect
type SourceRegistry
The package is sourced from a third party registry.
Properties:
registry: string
URL or path to the registry.
type SourceDirect
The package is sourced from a specific URL or path, e.g. a Git repo or workspace path.
Properties:
direct: string
URL or path to the package (directory or archive).subdirectory?: string | null
Only allowed if the source points to an archive file.
ganzua diff
Usage: ganzua diff [OPTIONS] OLD NEW
Compare two lockfiles.
The OLD and NEW arguments must each point to an uv.lock or poetry.lock file,
or to a directory containing such a file.
There is no direct support for comparing a file across Git commits,
but it's possible to retrieve other versions via git show.
Here is an example using a Bash redirect to show non-committed changes in a lockfile:
ganzua diff <(git show HEAD:uv.lock) uv.lock
Options:
--name FILTERInclude/exclude packages to diff by name. [default: diff all]--format [json|markdown]Choose the output format, e.g. Markdown. [default: json]--helpShow this help message and exit.
Examples
Can show JSON diffs:
$ ganzua diff corpus/old-uv-project corpus/new-uv-project
{
"stat": {
"total": 2,
"added": 1,
"removed": 0,
"updated": 1
},
"packages": [
{
"name": "annotated-types",
"old": null,
"new": {
"name": "annotated-types",
"version": "0.7.0",
"source": "pypi"
}
},
{
"name": "typing-extensions",
"old": {
"name": "typing-extensions",
"version": "3.10.0.2",
"source": "pypi"
},
"new": {
"name": "typing-extensions",
"version": "4.14.1",
"source": "pypi"
},
"is_major_change": true
}
]
}
Can also show the diff as Markdown:
$ ganzua diff corpus/old-uv-project corpus/new-uv-project --format=markdown
2 changed packages (1 added, 1 updated)
| package | old | new | notes |
|-------------------|----------|--------|-------|
| annotated-types | - | 0.7.0 | |
| typing-extensions | 3.10.0.2 | 4.14.1 | (M) |
* (M) major change
Here's the same diff in reverse, which now shows a (D) downgrade notice:
$ ganzua diff corpus/new-uv-project corpus/old-uv-project --format=markdown
2 changed packages (1 updated, 1 removed)
| package | old | new | notes |
|-------------------|--------|----------|---------|
| annotated-types | 0.7.0 | - | |
| typing-extensions | 4.14.1 | 3.10.0.2 | (M) (D) |
* (M) major change
* (D) downgrade
Test cases for demonstrating how the (M)/is_major_change note works:
| package | old | new | notes |
|---|---|---|---|
| epoch-changed | 1.2.3 | 1!1.2.3 | (M) |
| epoch-zero | 1.2.3 | 0!1.2.3 | |
| existence-added | - | 1.2.3 | |
| existence-removed | 1.2.3 | - | |
| major | 1.2.3 | 2.1.0 | (M) |
| minor | 1.2.3 | 1.3.4 | |
| validity-invalid-to-invalid | foo | bar | (M) |
| validity-invalid-to-valid | foo | 1.2.3 | (M) |
| validity-valid-to-invalid | 1.2.3 | foo | (M) |
| zerover-change | 0.1.2 | 0.2.0 | (M) |
| zerover-same | 0.1.2 | 0.1.3 |
Test cases for demonstrating how the (D)/is_downgrade note works:
| package | old | new | notes |
|---|---|---|---|
| downgrade | 1.3.4 | 1.0.1 | (D) |
| upgrade | 1.0.1 | 1.3.4 |
The Markdown diff can show notices when the source of a package changes. When multiple entries have the same note, their IDs are deduplicated:
$ ganzua diff corpus/sources-poetry corpus/sources-uv --format=markdown
6 changed packages (1 added, 5 updated)
| package | old | new | notes |
|--------------------|-------|-------|-------|
| click | 8.3.0 | 8.3.0 | (S1) |
| click-example-repo | 1.0.0 | 1.0.0 | (S2) |
| colorama | 0.4.6 | 0.4.6 | (S1) |
| idna | 3.11 | 3.11 | (S1) |
| propcache | 0.4.1 | 0.4.1 | (S1) |
| sources-uv | - | 0.1.0 | |
* (S1) source changed from default to pypi
* (S2) source changed from <git+https://github.com/pallets/click.git@309ce9178707e1efaf994f191d062edbdffd5ce6#subdirectory=examples/repo> to <git+https://github.com/pallets/click.git@f67abc6fe7dd3d878879a4f004866bf5acefa9b4#subdirectory=examples/repo>
If there are no notes, the entire column is omitted:
$ ganzua diff --format=markdown corpus/new-uv-project corpus/minor-uv-project
1 changed packages (1 updated)
| package | old | new |
|-------------------|--------|--------|
| typing-extensions | 4.14.1 | 4.15.0 |
When a there are no changes, only the summary is shown. The Markdown output omits the table:
$ ganzua diff corpus/new-uv-project corpus/new-uv-project
{
"stat": {
"total": 0,
"added": 0,
"removed": 0,
"updated": 0
},
"packages": []
}
$ ganzua diff corpus/new-uv-project corpus/new-uv-project --format=markdown
0 changed packages
The input paths may point to directories or lockfiles. The following invocations are all equivalent:
$ ganzua diff corpus/old-uv-project corpus/new-uv-project$ ganzua diff corpus/old-uv-project corpus/new-uv-project/uv.lock$ ganzua diff corpus/old-uv-project/uv.lock corpus/new-uv-project$ ganzua diff corpus/old-uv-project/uv.lock corpus/new-uv-project/uv.lock
output for the above commands
{
"stat": {
"total": 2,
"added": 1,
"removed": 0,
"updated": 1
},
"packages": [
{
"name": "annotated-types",
"old": null,
"new": {
"name": "annotated-types",
"version": "0.7.0",
"source": "pypi"
}
},
{
"name": "typing-extensions",
"old": {
"name": "typing-extensions",
"version": "3.10.0.2",
"source": "pypi"
},
"new": {
"name": "typing-extensions",
"version": "4.14.1",
"source": "pypi"
},
"is_major_change": true
}
]
}
Can filter the packages to be diffed by name. For more details, see the filter manual. Filtering was added in Ganzua 0.4.0.
$ ganzua diff $CORPUS/old-uv-project $CORPUS/new-uv-project --name='typing*' --format=markdown
1 changed packages (1 updated)
| package | old | new | notes |
|-------------------|----------|--------|-------|
| typing-extensions | 3.10.0.2 | 4.14.1 | (M) |
* (M) major change
$ ganzua diff $CORPUS/old-uv-project $CORPUS/new-uv-project --name='nonexistent' --format=markdown
0 changed packages
JSON Schema
Download: schema.diff.json
Properties:
type DiffStat
Properties:
total: intadded: intremoved: intupdated: int
type DiffEntry
Properties:
-
name: string
Name of the package being diffed.Added in Ganzua 0.4.0: previously, the package name was implicit.
-
old: LockedPackage | null -
new: LockedPackage | null -
is_major_change?: bool
True if there was a major version change.This doesn't literally mean "the SemVer-major version component changed", but is intended to highlight version changes that are likely to have breakage.
-
is_downgrade?: bool
True if the version was downgraded. -
is_source_change?: bool
True if the package source changed.
type LockedPackage
Properties:
-
name: string
Name of the package.Added in Ganzua 0.4.0: previously, the package name was implicit.
-
version: string -
source:pypi|default|other| SourceRegistry | SourceDirect
type SourceRegistry
The package is sourced from a third party registry.
Properties:
registry: string
URL or path to the registry.
type SourceDirect
The package is sourced from a specific URL or path, e.g. a Git repo or workspace path.
Properties:
direct: string
URL or path to the package (directory or archive).subdirectory?: string | null
Only allowed if the source points to an archive file.
ganzua constraints
Usage: ganzua constraints [OPTIONS] COMMAND [ARGS]...
Work with pyproject.toml constraints.
Options:
--helpShow this help message and exit.
Commands:
inspectList all constraints in thepyproject.tomlfile.bumpUpdatepyproject.tomldependency constraints to match the lockfile.resetRemove or relax any dependency version constraints from thepyproject.toml.
ganzua constraints inspect
Usage: ganzua constraints inspect [OPTIONS] [PYPROJECT]
List all constraints in the pyproject.toml file.
The PYPROJECT argument should point to a pyproject.toml file,
or to a directory containing such a file.
If this argument is not specified,
the one in the current working directory will be used.
Options:
--name FILTERInclude/exclude constraints to show by package name. [default: show all]--format [json|markdown]Choose the output format, e.g. Markdown. [default: json]--helpShow this help message and exit.
Examples
Can inspect the constraints of a pyproject.toml file as JSON or Markdown:
$ ganzua constraints inspect corpus/new-uv-project
{
"requirements": [
{
"name": "annotated-types",
"specifier": ">=0.7.0"
},
{
"name": "typing-extensions",
"specifier": ">=4"
}
]
}
$ ganzua constraints inspect corpus/new-uv-project --format=markdown
| package | version |
|-------------------|---------|
| annotated-types | >=0.7.0 |
| typing-extensions | >=4 |
The same package can appear in multiple constraints, in particular:
- in different extras:
[package.optional-dependencies],[tool.poetry.extras] - in different groups:
[dependency-groups],[tool.poetry.group.*.dependencies]
Due to inheritance mechanisms like {include-group = "…"}, the same constraint can appear in multiple extras (Poetry only) and multiple groups (all flavors).
Here are some examples of how the Ganzua output looks in these cases:
$ ganzua constraints inspect corpus/poetry-multiple-groups
{
"requirements": [
{
"name": "annotated-types",
"specifier": ">=0.7.0"
},
{
"name": "annotated-types",
"specifier": "<0.8.0",
"in_groups": [
"dev",
"types"
]
},
{
"name": "typing-extensions",
"specifier": "<5.0.0,>=4.15.0",
"in_groups": [
"types"
]
},
{
"name": "typing-extensions",
"specifier": "^4.15",
"in_extras": [
"dev",
"types"
]
}
]
}
$ ganzua constraints inspect corpus/poetry-multiple-groups --format=markdown
| package | version | group/extra |
|-------------------|-----------------|----------------------------|
| annotated-types | <0.8.0 | group `dev`, group `types` |
| annotated-types | >=0.7.0 | |
| typing-extensions | <5.0.0,>=4.15.0 | group `types` |
| typing-extensions | ^4.15 | extra `dev`, extra `types` |
We can use filters to restrict the output to certain requirements.
The --name filter lets us limit results by package name.
For further details on filters, read the filters manual.
Filters were added in Ganzua 0.4.0.
$ ganzua constraints inspect corpus/poetry-multiple-groups --format=markdown --name='!annotated*'
| package | version | group/extra |
|-------------------|-----------------|----------------------------|
| typing-extensions | <5.0.0,>=4.15.0 | group `types` |
| typing-extensions | ^4.15 | extra `dev`, extra `types` |
JSON Schema
Download: schema.constraints-inspect.json
Properties:
requirements: array(Requirement)
type Requirement
A resolver-agnostic Requirement model.
This corresponds to one dependency entry in a pyproject.toml file.
This is a lexical/textual concept about information in the file,
intended for inspection and edits.
Requirements can be difficult to interpret.
There might be multiple Requirements that point to the same package,
potentially with complementary or contradictory contents.
Features like [dependency-groups] or [tool.poetry.extras]
can include the same Requirement in multiple places,
which is why the in_groups and in_extras fields may have multiple values.
Properties:
-
name: string
The name of the required package. -
specifier: string
Version specifier for the required package, may use PEP-508 or Poetry syntax. -
extras?: array(string)
Extras enabled for the required package. -
marker?: string
Environment marker expression describing when this requirement should be installed. -
in_groups?: array(string)
Dependency groups that this requirement is part of.Changed in Ganzua 0.4.0: renamed
groupstoin_groups.Added in Ganzua 0.2.0.
-
in_extras?: array(string)
Extras that this optional requirement is part of.Requirements can only be part of one extra (with some exceptions).
The
groupsandin_extrasfields are effectively mutually exclusive.Special cases for legacy Poetry:
- When using
[tool.poetry.extras], one requirement can be part of multiple extras. - The
markermight also reference extras.
Added in Ganzua 0.4.0.
- When using
ganzua constraints bump
Usage: ganzua constraints bump [OPTIONS] [PYPROJECT]
Update pyproject.toml dependency constraints to match the lockfile.
Of course, the lockfile should always be a valid solution for the constraints. But often, the constraints are somewhat relaxed. This tool will increment the constraints to match the currently locked versions. Specifically, the locked version becomes a lower bound for the constraint.
This tool will try to be as granular as the original constraint.
For example, given the old constraint foo>=3.5 and the new version 4.7.2,
the constraint would be updated to foo>=4.7.
The PYPROJECT argument should point to a pyproject.toml file,
or to a directory containing such a file.
If this argument is not specified,
the one in the current working directory will be used.
Options:
--lockfile PATHWhere to load versions from. Inferred if possible.- file: use the path as the lockfile
- directory: use the lockfile in that directory
- default: use the lockfile in the
PYPROJECTdirectory
--backup PATHStore a backup in this file.--name FILTERInclude/exclude constraints to edit by package name. [default: edit all]--helpShow this help message and exit.
Examples
These examples demonstrate the command line interface. Further below are examples of how different version constraint operators are bumped.
Uv Example
Ganzua will edit standard/uv pyproject.toml files.
Given an example directory with a pyproject.toml file with outdated constraints:
$ cp $CORPUS/constraints-uv-pyproject.toml $EXAMPLE/pyproject.toml
And given an uv.lock file with the following contents:
| name | version |
|---|---|
| annotated-types | 0.7.0 |
| example | 0.2.0 |
| typing-extensions | 4.14.1 |
When we bump the constraints (and make a backup):
$ ganzua constraints bump $EXAMPLE --backup=$EXAMPLE/backup-pyproject.toml
Then the pyproject.toml file contains updated constraints,
whereas the backup contains the old version.
$ cat $EXAMPLE/pyproject.toml$ cat $EXAMPLE/backup-pyproject.toml$ cat $CORPUS/constraints-uv-pyproject.toml
output for the above commands
Output for:
$ cat $EXAMPLE/pyproject.toml
[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"Typing.Extensions>=4,<5", # moar type annotations
"merrily-ignored",
[42, "also ignored"], # we ignore invalid junk
]
[project.optional-dependencies]
extra1 = [
"annotated-types>=0.7.0,==0.7.*",
]
extra2 = false # known invalid
extra3 = ["ndr"]
[dependency-groups]
group-a = ["typing-extensions~=4.14"]
group-b = [{include-group = "group-a"}, "annotated-types~=0.7.0"]
Output for:
$ cat $EXAMPLE/backup-pyproject.toml$ cat $CORPUS/constraints-uv-pyproject.toml
[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"Typing.Extensions>=3,<4", # moar type annotations
"merrily-ignored",
[42, "also ignored"], # we ignore invalid junk
]
[project.optional-dependencies]
extra1 = [
"annotated-types >=0.6.1, ==0.6.*",
]
extra2 = false # known invalid
extra3 = ["ndr"]
[dependency-groups]
group-a = ["typing-extensions ~=3.4"]
group-b = [{include-group = "group-a"}, "annotated-types ~=0.6.1"]
Poetry example
We can also do the same with Poetry pyproject.toml files.
Given an example directory with a pyproject.toml file with outdated constraints:
$ cp corpus/constraints-poetry-pyproject.toml $EXAMPLE/pyproject.toml
And given a poetry.lock file with the following content:
| name | version |
|---|---|
| annotated-types | 0.7.0 |
| example | 0.2.0 |
| typing-extensions | 4.14.1 |
When we bump the constraints:
$ ganzua constraints bump $EXAMPLE
Then the pyproject.toml file contains updated constraints.
$ cat $EXAMPLE/pyproject.toml
[tool.poetry.dependencies]
Typing_Extensions = "^4.14"
ignored-garbage = { not-a-version = true }
[build-system]
[tool.poetry.group.poetry-a.dependencies]
typing-extensions = { version = "^4.14" }
already-unconstrained = "*"
No-op example
When the constraints are already up to date, Ganzua doesn't modify them.
Given a pyproject.toml file:
$ cp corpus/new-uv-project/pyproject.toml $EXAMPLE/pyproject.toml
When we bump constraints using an already-matching lockfile:
$ ganzua constraints bump --lockfile=corpus/new-uv-project/uv.lock $EXAMPLE/pyproject.toml
Then the old and new file are the same.
$ cat corpus/new-uv-project/pyproject.toml$ cat $EXAMPLE/pyproject.toml
output for the above commands
[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"annotated-types>=0.7.0",
"typing-extensions>=4",
]
Empty example
Since Ganzua does not validate that the pyproject.toml file is semantically valid,
it's also possible to edit an empty file.
Given an empty pyproject.toml file:
$ touch $EXAMPLE/pyproject.toml
When we bump the constraints in the empty pyproject.toml file,
then Ganzua succeeds,
and the pyproject.toml file remains empty:
$ ganzua constraints bump --lockfile=corpus/new-uv-project/uv.lock $EXAMPLE/pyproject.toml
$ cat $EXAMPLE/pyproject.toml
Split versions
If a project has split versions for a package, matching requirements will be skipped.
It is not possible to determine which requirement is supposed to be matched up with which locked version, so these instances will have to be fixed manually.
Note that depending on workflow, this means the lockfile and the pyproject.toml might become inconsistent.
The exact behavior here is not considered stable. Future Ganzua versions may change the handling of split versions if solutions to these problems are found.
Let's look at a concrete example with a split version:
$ cp $CORPUS/split/old.pyproject.toml $EXAMPLE/pyproject.toml
$ cp $CORPUS/split/uv.lock $EXAMPLE/uv.lock
Current pyproject.toml file:
$ cat $EXAMPLE/pyproject.toml
[project]
name = "split"
version = "0.1.0"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"typing_extensions >=4.7.1 ; python_version >= '3.14'",
"typing_extensions >=3.7.2,<4 ; python_version < '3.14'",
]
Currently locked versions:
| package | version |
|---|---|
| split | 0.1.0 |
| typing-extensions | 3.10.0.2 |
| typing-extensions | 4.15.0 |
Now we try to bump the old constraints:
$ ganzua constraints bump $EXAMPLE
ganzua: package `typing-extensions` has multiple candidate versions: 3.10.0.2, 4.15.0
But all we get is a warning. The constraints remain unchanged:
$ cat $EXAMPLE/pyproject.toml
[project]
name = "split"
version = "0.1.0"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"typing_extensions >=4.7.1 ; python_version >= '3.14'",
"typing_extensions >=3.7.2,<4 ; python_version < '3.14'",
]
Default files example
Ganzua tries to infer the pyproject.toml and lockfile.
Empty directory:
Running Ganzua against an empty directory will fail, as it cannot locate a pyproject.toml file in there.
$ env -C $EXAMPLE ganzua constraints bump --lockfile=$CORPUS/new-uv-project/uv.lock
Usage: ganzua constraints bump [OPTIONS] [PYPROJECT]
Try 'ganzua constraints bump --help' for help.
Error: Did not find default `pyproject.toml`.
[command exited with status 2]
Infering pyproject.toml:
If no pyproject.toml file is specified explicitly, Ganzua will look for it in the current working directory.
If a path to a directory is given, the file in that directory will be used.
For this example, let's create a pyproject.toml file:
$ cp $CORPUS/old-uv-project/pyproject.toml $EXAMPLE/pyproject.toml
$ ls $EXAMPLE
pyproject.toml
Now, all of the following commands are equivalent:
$ env -C $EXAMPLE ganzua constraints bump --lockfile=$CORPUS/new-uv-project/uv.lock
$ env -C $EXAMPLE ganzua constraints bump --lockfile=$CORPUS/new-uv-project/uv.lock pyproject.toml
$ env -C $EXAMPLE ganzua constraints bump --lockfile=$CORPUS/new-uv-project/uv.lock .
$ ganzua constraints bump --lockfile=$CORPUS/new-uv-project/uv.lock $EXAMPLE/pyproject.toml
$ ganzua constraints bump --lockfile=$CORPUS/new-uv-project/uv.lock $EXAMPLE
Lockfile is required:
If we leave out the explicit --lockfile argument, then Ganzua will fail, because there's currently no lockfile in the example directory:
$ ls $EXAMPLE
pyproject.toml
$ env -C $EXAMPLE ganzua constraints bump
Usage: ganzua constraints bump [OPTIONS] [PYPROJECT]
Try 'ganzua constraints bump --help' for help.
Error: Could not infer `--lockfile` for `.`.
[command exited with status 2]
But an explicit lockfile succeeds:
$ env -C $EXAMPLE ganzua constraints bump --lockfile=$CORPUS/new-uv-project/uv.lock
Infering lockfiles:
If no explicit --lockfile is given, then Ganzua will look for an uv.lock or poetry.lock file in the directory of the pyproject.toml file.
The lockfile may also be given as a different directory, in which case Ganzua will look for the lockfile in that directory.
If we add a lockfile in the example directory, then we can leave out the lockfile.
$ cp $CORPUS/new-uv-project/uv.lock $EXAMPLE/uv.lock
$ ls $EXAMPLE
pyproject.toml
uv.lock
So now these commands are equivalent:
$ ganzua constraints bump $EXAMPLE
$ ganzua constraints bump --lockfile=$EXAMPLE $EXAMPLE
$ ganzua constraints bump --lockfile=$EXAMPLE/uv.lock $EXAMPLE
$ env -C $EXAMPLE ganzua constraints bump
$ env -C $EXAMPLE ganzua constraints bump --lockfile=./
$ env -C $EXAMPLE ganzua constraints bump --lockfile=uv.lock
Conflicting lockfiles: If there are multiple candidate lockfiles in the directory, then Ganzua will report a conflict, unless a full path to a specific lockfile is given:
$ touch $EXAMPLE/poetry.lock
$ ls $EXAMPLE
poetry.lock
pyproject.toml
uv.lock
$ ganzua constraints bump $EXAMPLE
Usage: ganzua constraints bump [OPTIONS] [PYPROJECT]
Try 'ganzua constraints bump --help' for help.
Error: Could not infer `--lockfile` for `${EXAMPLE}`.
Note: Candidate lockfile: ${EXAMPLE}/uv.lock
Note: Candidate lockfile: ${EXAMPLE}/poetry.lock
[command exited with status 2]
$ ganzua constraints bump $EXAMPLE --lockfile=$EXAMPLE/uv.lock
Filters
Can use filters to only bump certain constraints. Read the filter manual for further details on how filters work.
Added in Ganzua 0.4.0: --name filters.
Let's recall the above uv example.
$ cp $CORPUS/constraints-uv-pyproject.toml $EXAMPLE/pyproject.toml
| name | version |
|---|---|
| annotated-types | 0.7.0 |
| example | 0.2.0 |
| typing-extensions | 4.14.1 |
We can now decide that we only want to bump the typing-extensions constraints, not the annotated-types constraints:
$ ganzua constraints bump $EXAMPLE --name=typing-extensions --backup=$EXAMPLE/old.toml
Old constraints:
$ ganzua constraints inspect $EXAMPLE/old.toml --format=markdown
| package | version | group/extra |
|-------------------|-----------------|----------------------------------|
| annotated-types | ==0.6.*,>=0.6.1 | extra `extra1` |
| annotated-types | ~=0.6.1 | group `group-b` |
| merrily-ignored | | |
| ndr | | extra `extra3` |
| typing-extensions | <4,>=3 | |
| typing-extensions | ~=3.4 | group `group-a`, group `group-b` |
Updated constraints:
$ ganzua constraints inspect $EXAMPLE --format=markdown
| package | version | group/extra |
|-------------------|-----------------|----------------------------------|
| annotated-types | ==0.6.*,>=0.6.1 | extra `extra1` |
| annotated-types | ~=0.6.1 | group `group-b` |
| merrily-ignored | | |
| ndr | | extra `extra3` |
| typing-extensions | <5,>=4 | |
| typing-extensions | ~=4.14 | group `group-a`, group `group-b` |
Examples of different constraints
The following examples illustrate how version bumping works.
The different version specifier operators are defined in PEP-440, with the up-to-date specifications on packaging.python.org.
~=1.2.3compatible release examples==1.2.3strict version matching examples==1.*prefix version matching examples!=1.2.3version exclusion examples>=1.2.3lower version bound examples<=1.2.3,>1.2.3,<1.2.3ordered comparison examples===1.2.3+build123arbitrary equality examples
Certain combinations of operators are often used to describe SemVer constraints,
e.g. >=1.2.3,<2 or >=1.2.3,==1.* (see SemVer examples).
Poetry supports certain additional operators:
1.2.3strict version matching examples1.*prefix version matching examples~1.2.3Poetry tilde examples^1.2.3SemVer examples
Syntax for example tables:
- The
lockedcolumn shows the locked version in format<package> <version>, e.g.foo 1.2.3. - The
oldandnewcolumn show the constraint before and after bumping.- may use PEP-508/PEP-440 expressions, e.g.
foo >=1.2.3 - may describe Poetry version constraints using a
<package> = <version>format but without quoting that would appear in a TOML file, e.g.foo = *
- may use PEP-508/PEP-440 expressions, e.g.
Basic examples
Will only bump packages that are present in the lockfile. Other constraints are not affected:
| locked | old | new |
|---|---|---|
locked 1.2.3 | other >=4,<5 | other >=4,<5 |
locked 1.2.3 | other = ^4 | other = ^4 |
Bumping matches the granularity of the bounds that are currently present.
If there are no constraints for that package, bumping will not add any bounds:
| locked | old | new |
|---|---|---|
package 1.2.3 | package | package |
package 1.2.3 | package = * | package = * |
Lower version bound examples
When the major version changes, a >= bound is bumped with matching granularity:
| locked | old | new |
|---|---|---|
major 7.1.2 | major>=4 | major>=7 |
minor 7.1.2 | minor>=4.3 | minor>=7.1 |
patch 7.1.2 | patch>=4.3.2 | patch>=7.1.2 |
minor-poetry 7.1.2 | minor-poetry = >=4.3 | minor-poetry = >=7.1 |
When the minor or patch version changes, a >= bound is bumped with matching granularity.
Here, no changes to the major constraint are needed.
Granularity is determined based on how many version elements have been explicitly provided.
So whereas the versions 4 and 4.0 are semantically equivalent, they have different granularity.
| locked | old | new |
|---|---|---|
major 4.5.6 | major>=4 | major>=4 |
minor0 4.5.6 | minor0>=4.0 | minor0>=4.5 |
minor 4.5.6 | minor>=4.3 | minor>=4.5 |
patch 4.5.6 | patch>=4.3.2 | patch>=4.5.6 |
minor-poetry 4.5.6 | minor-poetry = >=4.3 | minor-poetry = >=4.5 |
patch-poetry 4.5.6 | patch-poetry = >=4.3.2 | patch-poetry = >=4.5.6 |
Constraints may also be downgraded if this is necessary to match the locked version:
| locked | old | new |
|---|---|---|
major 4.5.6 | major>=7 | major>=4 |
minor 4.5.6 | minor>=4.9 | minor>=4.5 |
patch 4.5.6 | patch>=4.5.9 | patch>=4.5.6 |
SemVer examples
Poetry has the ^ SemVer operator.
It is bumped the same way as >= bounds:
| locked | old | new |
|---|---|---|
major 7.1.2 | major = ^4 | major = ^7 |
minor 7.1.2 | minor = ^7.0 | minor = ^7.1 |
patch 7.1.2 | patch = ^7.1.1 | patch = ^7.1.2 |
downgrade 7.1.2 | downgrade = ^9.8 | downgrade = ^7.1 |
However, SemVer bounds are also frequently expressed via PEP-440 expressions.
For example, the Poetry bound ^4.1 can also be expressed via the following idioms:
- upper bounds
>=4.1,<5 - version matching prefixes
>=4.1,==4.*
Upper bounds will be bumped when they look like a SemVer bound (>=N, <N+1) and would be outdated by the new version:
| locked | old | new |
|---|---|---|
major 7.1.2 | major>=4,<5 | major>=7,<8 |
minor0 7.1.2 | minor0>=4.0,<5 | minor0>=7.1,<8 |
minor 7.1.2 | minor>=4.9,<5 | minor>=7.1,<8 |
patch 7.1.2 | patch>=4.0.1,<5 | patch>=7.1.2,<8 |
However, upper bounds are not changed if they remain valid for the locked version:
| locked | old | new |
|---|---|---|
minor-upgrade 4.5.6 | minor-upgrade>=4.3,<5 | minor-upgrade>=4.5,<5 |
minor-downgrade 4.5.6 | minor-downgrade>=4.9,<5 | minor-downgrade>=4.5,<5 |
wide 7.1.2 | wide>=4.5,<9 | wide>=7.1,<9 |
The prefix version matching SemVer idiom behaves more intuitively because each bound in the constraint will be bumped independently to match the new version:
| locked | old | new |
|---|---|---|
major 7.1.2 | major>=4,==4.* | major>=7,==7.* |
minor0 7.1.2 | minor0>=4.0,==4.* | minor0>=7.1,==7.* |
minor 7.1.2 | minor>=4.9,==4.* | minor>=7.1,==7.* |
minor-match 7.1.2 | minor-match>=4.9,==4.9.* | minor-match>=7.1,==7.1.* |
patch 7.1.2 | patch>=4.0.1,==4.* | patch>=7.1.2,==7.* |
downgrade 7.1.2 | downgrade >=9.8,==9.* | downgrade>=7.1,==7.* |
minor-upgrade 4.5.6 | minor-upgrade>=4.3,==4.* | minor-upgrade>=4.5,==4.* |
minor-downgrade 4.5.6 | minor-downgrade>=4.9,==4.* | minor-downgrade>=4.5,==4.* |
Strict version matching examples
The == operator can be used for exact version matching (without * wildcards for prefix matching).
Exact bounds will be bumped to match the locked version exactly.
The == operator is also implied when a Poetry version constraint doesn't use any operators.
expand PEP-440 examples
| locked | old | new |
|---|---|---|
foo 7.1.2 | foo==4.3.2 | foo==7.1.2 |
foo 7.1.2 | foo==4.3 | foo==7.1.2 |
foo 7.1.2 | foo==4 | foo==7.1.2 |
foo 7.1.2 | foo==7 | foo==7.1.2 |
foo 7.1.2 | foo==7.1 | foo==7.1.2 |
foo 7.1.2 | foo==7.1.2 | foo==7.1.2 |
expand Poetry examples
| locked | old | new |
|---|---|---|
foo 7.1.2 | foo = 4.3.2 | foo = 7.1.2 |
foo 7.1.2 | foo = 4.3 | foo = 7.1.2 |
foo 7.1.2 | foo = 4 | foo = 7.1.2 |
foo 7.1.2 | foo = 7 | foo = 7.1.2 |
foo 7.1.2 | foo = 7.1 | foo = 7.1.2 |
foo 7.1.2 | foo = 7.1.2 | foo = 7.1.2 |
Prefix version matching examples
When the == operator is combined with * wildcards, it performs prefix matching.
Such constraints are updated with matching granularity.
The == operator is implied when a Poetry version constraint doesn't use any operators.
expand PEP-440 examples
| locked | old | new |
|---|---|---|
foo 7.1.2 | foo==4.3.* | foo==7.1.* |
foo 7.1.2 | foo==4.* | foo==7.* |
foo 7.1.2 | foo==7.* | foo==7.* |
foo 7.1.2 | foo==7.0.* | foo==7.1.* |
foo 7.1.2 | foo==7.1.* | foo==7.1.* |
expand Poetry examples
| locked | old | new |
|---|---|---|
foo 7.1.2 | foo = 4.3.* | foo = 7.1.* |
foo 7.1.2 | foo = 4.* | foo = 7.* |
foo 7.1.2 | foo = 7.* | foo = 7.* |
foo 7.1.2 | foo = 7.0.* | foo = 7.1.* |
foo 7.1.2 | foo = 7.1.* | foo = 7.1.* |
Compatible release examples
The ~= compatible release operator (spec) is bumped the same way as >= lower bounds:
it will be bumped so that it is a lower bound for the currently locked version, matching granularity.
Note that a ~= compatible release clause needs to consist at least of ~=major.minor.
A single element is insufficient.
See also the Poetry ~ tilde operator.
expand PEP-440 examples
| locked | old | new |
|---|---|---|
major 7.1.2 | major~=4.5 | major~=7.1 |
minor0 4.5.6 | minor0~=4.0 | minor0~=4.5 |
minor 4.5.6 | minor~=4.3 | minor~=4.5 |
patch 4.5.6 | patch~=4.3.2 | patch~=4.5.6 |
minor-poetry 4.5.6 | minor-poetry = ~=4.3 | minor-poetry = ~=4.5 |
patch-poetry 4.5.6 | patch-poetry = ~=4.3.2 | patch-poetry = ~=4.5.6 |
major-downgrade 4.5.6 | major-downgrade~=7.9 | major-downgrade~=4.5 |
minor-downgrade 4.5.6 | minor-downgrade~=4.9 | minor-downgrade~=4.5 |
patch-downgrade 4.5.6 | patch-downgrade~=4.5.9 | patch-downgrade~=4.5.6 |
noop 1.2.3 | noop~=1.2.3 | noop~=1.2.3 |
Poetry tilde examples
Tilde requirements (spec) can only occur within the [tool.poetry] table.
They work similarly to compatible release constraints,
but describe different version ranges.
Notably, a single segment like ~1 is allowed.
Poetry bumps such constraints the same way as >= lower version bounds.
expand Poetry examples
| locked | old | new |
|---|---|---|
major 7.1.2 | major = ~4 | major = ~7 |
major 7.1.2 | major = ~4.5 | major = ~7.1 |
minor0 4.5.6 | minor0 = ~4.0 | minor0 = ~4.5 |
minor 4.5.6 | minor = ~4.3 | minor = ~4.5 |
patch 4.5.6 | patch = ~4.3.2 | patch = ~4.5.6 |
major-downgrade 4.5.6 | major-downgrade = ~7 | major-downgrade = ~4 |
major-downgrade 4.5.6 | major-downgrade = ~7.9 | major-downgrade = ~4.5 |
minor-downgrade 4.5.6 | minor-downgrade = ~4.9 | minor-downgrade = ~4.5 |
patch-downgrade 4.5.6 | patch-downgrade = ~4.5.9 | patch-downgrade = ~4.5.6 |
noop 1.2.3 | noop = ~1.2.3 | noop = ~1.2.3 |
noop 1.2.3 | noop = ~1.2 | noop = ~1.2 |
noop 1.2.3 | noop = ~1 | noop = ~1 |
Version exclusion examples
The != version exclusion clause (spec) is the inverse of == version matching.
It can be used to exclude specific versions like !=1.2.3 or prefixes like !=1.2.*.
When Ganzua bumps a version exclusion specifier, the specifier will be retained as-is, unless it would exclude the currently locked version.
exclusions are kept by default
| locked | old | new |
|---|---|---|
foo 7.1.2 | foo!=4.3.2 | foo!=4.3.2 |
foo 7.1.2 | foo!=4.* | foo!=4.* |
foo 7.1.2 | foo!=7.9.* | foo!=7.9.* |
foo 7.1.2 | foo!=7.0.1 | foo!=7.0.1 |
exclusions are removed if they would exclude the current version
| locked | old | new |
|---|---|---|
foo 7.1.2 | foo!=7.* | foo |
foo 7.1.2 | foo!=7.1.* | foo |
foo 7.1.2 | foo!=7.1.2 | foo |
Arbitrary equality examples
The === arbitrary equality comparison (spec) matches only an exact version, without any semantic interpretation of the version number.
When Ganzua bumps arbitrary equality clauses, it always sets the currently locked version. Ganzua requires that the currently locked version is valid, but allows the old version constraint to contain an invalid version number.
expand examples
| locked | old | new |
|---|---|---|
foo 7.1.2.post1+abc | foo===7.1.2 | foo===7.1.2.post1+abc |
foo 7.1.2 | foo===7.1.2.post1+abc | foo===7.1.2 |
foo 7.1.2 | foo===whatever | foo===7.1.2 |
unmodified 7.1.2 | unmodified===7.1.2 | unmodified===7.1.2 |
Ordered comparison examples
The version constraint operators <=, <, and > (spec) cannot be bumped meaningfully.
Ganzua will retain them as-is if they allow the locked version, else remove them.
expand examples
| locked | old | new |
|---|---|---|
foo 7.1.2 | foo>4 | foo>4 |
foo 7.1.2 | foo>8 | foo |
foo 7.1.2 | foo<5 | foo |
foo 7.1.2 | foo<8 | foo<8 |
foo 7.1.2 | foo<=5 | foo |
foo 7.1.2 | foo<=8 | foo<=8 |
foo 7.1.2 | foo<=7.1 | foo |
foo 7.1.2 | foo<=7.2 | foo<=7.2 |
ganzua constraints reset
Usage: ganzua constraints reset [OPTIONS] [PYPROJECT]
Remove or relax any dependency version constraints from the pyproject.toml.
This can be useful for allowing uv/Poetry to update to the most recent versions, ignoring the previous constraints. Approximate recipe:
ganzua constraints reset --to=minimum --backup=pyproject.toml.bak
uv lock --upgrade # perform the upgrade
mv pyproject.toml.bak pyproject.toml # restore old constraints
ganzua constraints bump
uv lock
The PYPROJECT argument should point to a pyproject.toml file,
or to a directory containing such a file.
If this argument is not specified,
the one in the current working directory will be used.
Options:
--backup PATHStore a backup in this file.--to [none|minimum]How to reset constraints.none(default): remove all constraintsminimum: set constraints to the currently locked minimum, removing upper bounds
--lockfile PATHWhere to load current versions from (for--to=minimum). Inferred if possible.- file: use the path as the lockfile
- directory: use the lockfile in that directory
- default: use the lockfile in the
PYPROJECTdirectory
--name FILTERInclude/exclude constraints to edit by package name. [default: edit all]--helpShow this help message and exit.
Examples
Removing constraints
By default, ganzua constraints reset will remove all version constraints.
This is useful when we want poetry lock/uv lock to compute a completely unconstrainted, up-to-date dependency solution.
Let's set up an example with a pyproject.toml file.
$ cp $CORPUS/new-uv-project/pyproject.toml $EXAMPLE/pyproject.toml
$ cat $EXAMPLE/pyproject.toml
[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"annotated-types>=0.7.0",
"typing-extensions>=4",
]
We can now reset the constraints and create a backup:
$ ganzua constraints reset $EXAMPLE --backup=$EXAMPLE/old.pyproject.toml
$ ls $EXAMPLE
old.pyproject.toml
pyproject.toml
This edits the pyproject.toml file.
All dependencies are still present, but they no longer constrain any versions:
$ cat $EXAMPLE/pyproject.toml
[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"annotated-types",
"typing-extensions",
]
We can more directly compare the changes to the constriants via ganzua constraints inspect:
constraints in the backup
| package | version |
|---|---|
| annotated-types | >=0.7.0 |
| typing-extensions | >=4 |
constraints in the edited project
| package | version |
|---|---|
| annotated-types | |
| typing-extensions |
Removing constraints in extras and dependency groups
The ganzua constraints reset tool will also look into [project.optional-dependencies] and dependency-groups.
Let's set up a new example with a more complicated pyproject.toml file.
$ cp $CORPUS/constraints-uv-pyproject.toml $EXAMPLE/pyproject.toml
$ cat $EXAMPLE/pyproject.toml
[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"Typing.Extensions>=3,<4", # moar type annotations
"merrily-ignored",
[42, "also ignored"], # we ignore invalid junk
]
[project.optional-dependencies]
extra1 = [
"annotated-types >=0.6.1, ==0.6.*",
]
extra2 = false # known invalid
extra3 = ["ndr"]
[dependency-groups]
group-a = ["typing-extensions ~=3.4"]
group-b = [{include-group = "group-a"}, "annotated-types ~=0.6.1"]
Note that the file contains an invalid structure, such as an array where the schema would expect a string.
Running the reset succeeds. Any errors in the file are ignored silently.
$ ganzua constraints reset $EXAMPLE
The resulting pyproject.toml file is stripped of all dependency version constraints.
$ cat $EXAMPLE/pyproject.toml
[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"Typing.Extensions", # moar type annotations
"merrily-ignored",
[42, "also ignored"], # we ignore invalid junk
]
[project.optional-dependencies]
extra1 = [
"annotated-types",
]
extra2 = false # known invalid
extra3 = ["ndr"]
[dependency-groups]
group-a = ["typing-extensions"]
group-b = [{include-group = "group-a"}, "annotated-types"]
Removing constraints for Poetry
When using Poetry with dependencies in the [tool.poetry] table,
resetting a constraint will change it to *.
Let's set up a new example. This example file also demonstrates Poetry dependency groups.
$ cp $CORPUS/constraints-poetry-pyproject.toml $EXAMPLE/pyproject.toml
$ cat $EXAMPLE/pyproject.toml
[tool.poetry.dependencies]
Typing_Extensions = "^3.2"
ignored-garbage = { not-a-version = true }
[build-system]
[tool.poetry.group.poetry-a.dependencies]
typing-extensions = { version = "^3.4" }
already-unconstrained = "*"
We can now reset the constraints:
$ ganzua constraints reset $EXAMPLE
The versions will now be set to *, including for dependencies in groups:
| package | version | group/extra |
|---|---|---|
| already-unconstrained | * | group poetry-a |
| typing-extensions | * | |
| typing-extensions | * | group poetry-a |
$ cat $EXAMPLE/pyproject.toml
[tool.poetry.dependencies]
Typing_Extensions = "*"
ignored-garbage = { not-a-version = true }
[build-system]
[tool.poetry.group.poetry-a.dependencies]
typing-extensions = { version = "*" }
already-unconstrained = "*"
Resetting constraints to minimum
If we want to upgrade dependencies in a project, it's usually desirable to prevent unexpected downgrades.
Ganzua can assist here with the --to=minimum option.
Instead of removing all version constraints, we rewrite the constraints to use the currently locked version as a lower bound.
Let's set up an example using UV:
$ cp $CORPUS/constraints-uv-pyproject.toml $EXAMPLE/pyproject.toml
Current constraints:
| package | version | group/extra |
|---|---|---|
| annotated-types | ==0.6.*,>=0.6.1 | extra extra1 |
| annotated-types | ~=0.6.1 | group group-b |
| merrily-ignored | ||
| ndr | extra extra3 | |
| typing-extensions | <4,>=3 | |
| typing-extensions | ~=3.4 | group group-a, group group-b |
Current locked versions:
| name | version |
|---|---|
| annotated-types | 0.7.0 |
| example | 0.2.0 |
| typing-extensions | 4.14.1 |
Now let's reset the constraints:
$ ganzua constraints reset --to=minimum $EXAMPLE
The constraints in the pyproject.toml file have been reset to a lower bound with the locked version:
| package | version | group/extra |
|---|---|---|
| annotated-types | >=0.7.0 | extra extra1 |
| annotated-types | >=0.7.0 | group group-b |
| merrily-ignored | ||
| ndr | extra extra3 | |
| typing-extensions | >=4.14.1 | |
| typing-extensions | >=4.14.1 | group group-a, group group-b |
$ cat $EXAMPLE/pyproject.toml
[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"Typing.Extensions>=4.14.1", # moar type annotations
"merrily-ignored",
[42, "also ignored"], # we ignore invalid junk
]
[project.optional-dependencies]
extra1 = [
"annotated-types>=0.7.0",
]
extra2 = false # known invalid
extra3 = ["ndr"]
[dependency-groups]
group-a = ["typing-extensions>=4.14.1"]
group-b = [{include-group = "group-a"}, "annotated-types>=0.7.0"]
We can do the same using Poetry:
$ cp $CORPUS/constraints-poetry-pyproject.toml $EXAMPLE/pyproject.toml
Current constraints:
| package | version | group/extra |
|---|---|---|
| already-unconstrained | * | group poetry-a |
| typing-extensions | ^3.2 | |
| typing-extensions | ^3.4 | group poetry-a |
Current locked versions:
| name | version |
|---|---|
| annotated-types | 0.7.0 |
| example | 0.2.0 |
| typing-extensions | 4.14.1 |
Now let's reset the constraints:
$ ganzua constraints reset --to=minimum $EXAMPLE
The constraints in the pyproject.toml file have been reset to a lower bound with the locked version:
| package | version | group/extra |
|---|---|---|
| already-unconstrained | * | group poetry-a |
| typing-extensions | >=4.14.1 | |
| typing-extensions | >=4.14.1 | group poetry-a |
Resetting to minimum with split versions
If a project has split versions for a package, all requirements will be reset to the minimum of all candidate versions.
This is not entirely correct and might break subsequent package resolution, because markers/extras/groups are not currently taken into account. It can also lead to implicit downgrades.
The exact behavior here is not considered stable and might change in the future, as ways are found to resolve these problems.
Let's look at an example project with split version:
$ cp $CORPUS/split/pyproject.toml $EXAMPLE/pyproject.toml
$ cp $CORPUS/split/uv.lock $EXAMPLE/uv.lock
Current pyproject.toml file:
$ cat $EXAMPLE/pyproject.toml
[project]
name = "split"
version = "0.1.0"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"typing_extensions >=4 ; python_version >= '3.14'",
"typing_extensions >=3,<4 ; python_version < '3.14'",
]
Currently locked versions:
| package | version |
|---|---|
| split | 0.1.0 |
| typing-extensions | 3.10.0.2 |
| typing-extensions | 4.15.0 |
Now we reset their constraints to the minimum, which will show a warning about the potential conflict:
$ ganzua constraints reset --to=minimum $EXAMPLE
ganzua: package `typing-extensions` has multiple candidate versions: 3.10.0.2, 4.15.0
Constraints afterwards:
$ cat $EXAMPLE/pyproject.toml
[project]
name = "split"
version = "0.1.0"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"typing_extensions>=3.10.0.2; python_version >= \"3.14\"",
"typing_extensions>=3.10.0.2; python_version < \"3.14\"",
]
same for Poetry
Set up a fresh example:
$ cp $CORPUS/split/pyproject.toml $EXAMPLE/pyproject.toml
$ cp $CORPUS/split/poetry.lock $EXAMPLE/poetry.lock
Run Ganzua, which will again emit a warning:
$ ganzua constraints reset --to=minimum $EXAMPLE
ganzua: package `typing-extensions` has multiple candidate versions: 3.10.0.2, 4.15.0
Constraints afterwards:
$ cat $EXAMPLE/pyproject.toml
[project]
name = "split"
version = "0.1.0"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"typing_extensions>=3.10.0.2; python_version >= \"3.14\"",
"typing_extensions>=3.10.0.2; python_version < \"3.14\"",
]
Resolving the minimum version requires a lockfile
When we try to reset versions to their minimum but the project doesn't have a lockfile, then we get an error.
$ cp $CORPUS/new-poetry-project/pyproject.toml $EXAMPLE/pyproject.toml
$ ganzua constraints reset --to=minimum $EXAMPLE
Usage: ganzua constraints reset [OPTIONS] [PYPROJECT]
Try 'ganzua constraints reset --help' for help.
Error: Could not infer `--lockfile` for `${EXAMPLE}`.
Note: Using `--to=minimum` requires a `--lockfile`.
[command exited with status 2]
But this succeeds when passing an explicit lockfile, or to a directory containing a lockfile:
$ ganzua constraints reset --to=minimum --lockfile=$CORPUS/new-uv-project/uv.lock $EXAMPLE
$ ganzua constraints reset --to=minimum --lockfile=$CORPUS/new-uv-project $EXAMPLE
This also works if the lockfile can be inferred from the pyproject.toml file.
$ cp $CORPUS/new-uv-project/uv.lock $EXAMPLE/uv.lock
$ ganzua constraints reset --to=minimum $EXAMPLE
Inferring pyproject.toml location
Let's consider an empty example project:
$ ls $EXAMPLE
Running Ganzua within this directory will fail, as there's no pyproject.toml file:
$ env -C $EXAMPLE ganzua constraints reset
Usage: ganzua constraints reset [OPTIONS] [PYPROJECT]
Try 'ganzua constraints reset --help' for help.
Error: Did not find default `pyproject.toml`.
[command exited with status 2]
Once we add a pyproject.toml, it will be picked up implicitly,
and all oft he below Ganzua commands are equivalent.
$ cp $CORPUS/old-uv-project/pyproject.toml $EXAMPLE/pyproject.toml
$ env -C $EXAMPLE ganzua constraints reset
$ env -C $EXAMPLE ganzua constraints reset pyproject.toml
$ env -C $EXAMPLE ganzua constraints reset .
$ ganzua constraints reset $EXAMPLE/pyproject.toml
$ ganzua constraints reset $EXAMPLE
That is, we can specify a directory or a file path, and omit this argument entirely if we want to act on the project in the current working directory.
Filters
Can use filters to only reset certain constraints. Read the filter manual for further details on how filters work.
Added in Ganzua 0.4.0: --name filters.
Let's set up a fresh example project.
$ cp $CORPUS/constraints-uv-pyproject.toml $EXAMPLE/pyproject.toml
We can look at the current constraints:
$ ganzua constraints inspect $EXAMPLE --format=markdown
| package | version | group/extra |
|-------------------|-----------------|----------------------------------|
| annotated-types | ==0.6.*,>=0.6.1 | extra `extra1` |
| annotated-types | ~=0.6.1 | group `group-b` |
| merrily-ignored | | |
| ndr | | extra `extra3` |
| typing-extensions | <4,>=3 | |
| typing-extensions | ~=3.4 | group `group-a`, group `group-b` |
We can use the --name filter to only reset the Typing-Extensions versions:
$ ganzua constraints reset $EXAMPLE --name typing-extensions
In the updated constraints, we see that the version numbers have been removed, but not for other dependencies:
$ ganzua constraints inspect $EXAMPLE --format=markdown
| package | version | group/extra |
|-------------------|-----------------|----------------------------------|
| annotated-types | ==0.6.*,>=0.6.1 | extra `extra1` |
| annotated-types | ~=0.6.1 | group `group-b` |
| merrily-ignored | | |
| ndr | | extra `extra3` |
| typing-extensions | | |
| typing-extensions | | group `group-a`, group `group-b` |
ganzua schema
Usage: ganzua schema [OPTIONS] {inspect|diff|constraints-inspect}
Show the JSON schema for the output of the given command.
Options:
--format [json|markdown]Choose the output format, e.g. Markdown. [default: json]--helpShow this help message and exit.
Filters
Filters can be used to restrict Ganzua to operate only on certain dependencies. Which filters are supported depends on the exact subcommand.
-
ganzua inspect(--name)
Select which packages are shown in the output.
Added in Ganzua 0.4.0. -
ganzua diff(--name)
Select which packages are included in the diff.
Added in Ganzua 0.4.0. -
ganzua constraints inspect(--name)
Select which constraints are shown in the output.
Added in Ganzua 0.4.0. -
ganzua constraints bump(--name)
Select which constraints are edited.
Added in Ganzua 0.4.0. -
ganzua constraints reset(--name)
Select which constraints are edited.
Added in Ganzua 0.4.0.
The following sections describe the syntax of filters, how filters are evaluated, and provide examples.
Syntax
A PEG-style grammar for the filter syntax would look as follows:
filter → ws? pattern ws? (
,ws? filter ws? )*pattern →
!? ( literal |*|?)+literal →
/[a-zA-Z0-9._-]+/ws →
/\s+/
A filter consists of one or more glob patterns, separated by comma. Trailing commas are not allowed. Each pattern may be surrounded by whitespace.
Each pattern is a Unix glob expression, similar to what is supported in .gitignore files.
A pattern can contain literal content, * asterisk metacharacters, and ? question mark metacharacters.
The pattern must not be empty.
A negated pattern has a leading ! exclamation mark.
There may not be any space between the exclamation mark and the rest of the pattern.
The * asterisk metacharacter matches zero or more arbitrary characters.
The ? question mark metacharacter matches a single arbitrary character.
Literal content can only consist of ASCII letters, ASCII digits, and PyPI name separators (._-).
Filters are only used to match names (e.g. PyPI package names), so literal content is matched in a normalized manner.
Literal content is not case sensitive, so the patterns A and a are equivalent.
Separators all match each other as per the Python packaging name normalization rules,
so the patterns a_b, a.b, and a--b are all equivalent.
Differences to .gitignore:
- filter patterns are separated by commas, not by newlines
- comments are not supported
- escapes are not needed – metacharacters can never occur in a name
- no special rules for directory separators – slashes can never occur in a name
- other fnmatch syntax is not supported (see below)
- no
**double asterisk metacharacter
Differences to fnmatch(3), glob(3), or glob(7):
- no character classes
[abc], ranges[a-z], or complementation[!a-z](also called bracket expressions) - no brace expansion
{a,b,c}-{1,2,3} - no special handling for leading
.periods – names can never start with a separator - matching is always case-insensitive
References:
- Python package name format and normalization: https://packaging.python.org/en/latest/specifications/name-normalization/
- gitignore pattern format: https://git-scm.com/docs/gitignore#_pattern_format
- fnmatch(3): https://man7.org/linux/man-pages/man3/fnmatch.3.html
- glob(3): https://man7.org/linux/man-pages/man3/glob.3.html
- glob(7): https://man7.org/linux/man-pages/man7/glob.7.html
- fnmatch() in POSIX.1-2024: https://pubs.opengroup.org/onlinepubs/9799919799/functions/fnmatch.html (links to syntax specifications)
Evaluation
When matching a name against a filter, all patterns are evaluated in order. When a normal pattern matches, the name is explicitly included. When a negated pattern matches, the name is explicitly excluded. A name can flip between included and excluded state arbitrarily often, and only the final state matters. Filter matching does not short-circuit.
If a filter contains at least one normal (non-negated) pattern, each name is excluded by default, and at least one of the normal patterns must match.
This is as-if such a filter started with a !* pattern.
If a filter only contains negated patterns, names are included by default, and the negated patterns can exclude names.
This is as-if the filter started with a * pattern.
These semantics are exactly how glob patterns work in ripgrep: https://github.com/BurntSushi/ripgrep/blob/4519153e5e461527f4bca45b042fff45c4ec6fb9/GUIDE.md#manual-filtering-globs.
Examples
Let's consider a project with the following lockfile:
full lockfile contents
| name | version |
|---|---|
| annotated-types | 0.7.0 |
| click | 8.3.1 |
| colorama | 0.4.6 |
| coverage | 7.12.0 |
| dirty-equals | 0.11 |
| executing | 2.2.1 |
| idna | 3.11 |
| iniconfig | 2.3.0 |
| inline-snapshot | 0.31.1 |
| multidict | 6.7.0 |
| mypy | 1.18.2 |
| mypy-extensions | 1.1.0 |
| packaging | 25.0 |
| pluggy | 1.6.0 |
| propcache | 0.4.1 |
| pydantic | 2.12.4 |
| pydantic-core | 2.41.5 |
| pygments | 2.19.2 |
| pytest | 9.0.1 |
| pytest-cov | 7.0.0 |
| tomlkit | 0.13.3 |
| typing-extensions | 4.15.0 |
| typing-inspection | 0.4.2 |
| yarl | 1.22.0 |
We can use a name filter to select one or more specific packages from the lockfile:
$ ganzua inspect $EXAMPLE --name=pydantic --format=markdown
| package | version |
|----------|---------|
| pydantic | 2.12.4 |
$ ganzua inspect $EXAMPLE --name=pydantic,mypy,pytest-cov --format=markdown
| package | version |
|------------|---------|
| mypy | 1.18.2 |
| pydantic | 2.12.4 |
| pytest-cov | 7.0.0 |
We can use glob patterns to select all packages that start with py:
$ ganzua inspect $EXAMPLE --name='py*' --format=markdown
| package | version |
|---------------|---------|
| pydantic | 2.12.4 |
| pydantic-core | 2.41.5 |
| pygments | 2.19.2 |
| pytest | 9.0.1 |
| pytest-cov | 7.0.0 |
Or all packages that contain py but do not start with py:
$ ganzua inspect $EXAMPLE --name='*py*, !py*' --format=markdown
| package | version |
|-----------------|---------|
| mypy | 1.18.2 |
| mypy-extensions | 1.1.0 |
Using the question mark operator, we can select all packages that have a 4-letter name:
$ ganzua inspect $EXAMPLE --name='????' --format=markdown
| package | version |
|---------|---------|
| idna | 3.11 |
| mypy | 1.18.2 |
| yarl | 1.22.0 |
When only using negated patterns, results are included by default.
Here, we exclude all patterns that contain a hyphen, or start with the letters p or c:
$ ganzua inspect $EXAMPLE --name='!*-*, !p*, !c*' --format=markdown
| package | version |
|-----------|---------|
| executing | 2.2.1 |
| idna | 3.11 |
| iniconfig | 2.3.0 |
| multidict | 6.7.0 |
| mypy | 1.18.2 |
| tomlkit | 0.13.3 |
| yarl | 1.22.0 |
Filters are case-insensitive, and hyphens/underscores/periods are all equivalent. Thus, all of these filters produce the same results:
$ ganzua inspect $EXAMPLE --name='*ex*,!*-*' --format=markdown$ ganzua inspect $EXAMPLE --name='*EX*,!*...*' --format=markdown$ ganzua inspect $EXAMPLE --name='*eX*,!*_*' --format=markdown
output for the above commands
| package | version |
|-----------|---------|
| executing | 2.2.1 |
Some further filter syntax details that describe edge cases:
filters may be surrounded by spaces
$ ganzua inspect $EXAMPLE --name=' foo , bar, pytest ' --format=markdown
| package | version |
|---------|---------|
| pytest | 9.0.1 |
filters must not be empty
$ ganzua inspect $EXAMPLE --name='' --format=markdown
Usage: ganzua inspect [OPTIONS] [LOCKFILE]
Try 'ganzua inspect --help' for help.
Error: Invalid value for '--name': expected filter pattern
at offset 0 (EOF):
|
|^
[command exited with status 2]
$ ganzua inspect $EXAMPLE --name='foo,' --format=markdown
Usage: ganzua inspect [OPTIONS] [LOCKFILE]
Try 'ganzua inspect --help' for help.
Error: Invalid value for '--name': expected filter pattern
at offset 4 (EOF):
|foo,
| ^
[command exited with status 2]
syntax errors
$ ganzua inspect $EXAMPLE --name='must not contain spaces' --format=markdown
Usage: ganzua inspect [OPTIONS] [LOCKFILE]
Try 'ganzua inspect --help' for help.
Error: Invalid value for '--name': unexpected content after filter pattern
at offset 5 (char 'n' U+006E LATIN SMALL LETTER N):
|must not contain spaces
| ^
[command exited with status 2]
$ ganzua inspect $EXAMPLE --name='/slashes/not/supported/' --format=markdown
Usage: ganzua inspect [OPTIONS] [LOCKFILE]
Try 'ganzua inspect --help' for help.
Error: Invalid value for '--name': expected filter pattern
at offset 0 (char '/' U+002F SOLIDUS):
|/slashes/not/supported/
|^
[command exited with status 2]
$ ganzua inspect $EXAMPLE --name='Ÿñiçøðœ' --format=markdown
Usage: ganzua inspect [OPTIONS] [LOCKFILE]
Try 'ganzua inspect --help' for help.
Error: Invalid value for '--name': expected filter pattern
at offset 0 (char 'Ÿ' U+0178 LATIN CAPITAL LETTER Y WITH DIAERESIS):
|Ÿñiçøðœ
|^
[command exited with status 2]
Bracket expressions and brace expansion are not supported
Conventional fnmatch syntax allows character classes like [a-z] or [!a-z].
This doesn't seem overly helpful for matching package names, so hasn't been implemented yet.
$ ganzua inspect $EXAMPLE --name='ex[a-z]mple' --format=markdown
Usage: ganzua inspect [OPTIONS] [LOCKFILE]
Try 'ganzua inspect --help' for help.
Error: Invalid value for '--name': bracket expressions not supported
at offset 2 (char '[' U+005B LEFT SQUARE BRACKET):
|ex[a-z]mple
| ^
[command exited with status 2]
Similarly, brace expansion is not supported. Instead, it's usually possible to write multiple separate filters.
$ ganzua inspect $EXAMPLE --name='foo{,-bar}' --format=markdown
Usage: ganzua inspect [OPTIONS] [LOCKFILE]
Try 'ganzua inspect --help' for help.
Error: Invalid value for '--name': brace expansion not supported
at offset 3 (char '{' U+007B LEFT CURLY BRACKET):
|foo{,-bar}
| ^
[command exited with status 2]
Changelog
Unreleased
Breaking changes:
New features:
Fixes:
Other:
Full diff: https://github.com/latk/ganzua/compare/v0.4.0...HEAD
v0.4.0 (2026-04-11)
This release brings fixes and quality-of-life improvements that were found through continued real-world usage.
This includes a new --name filter system, support for split versions, and information about which extras each constraint is part of.
Unfortunately, this required breaking changes to the JSON schema.
Breaking changes:
- Schema change for
ganzua inspect:packagesis now a list, not a dictionary. See “split versions” below. Previously, Ganzua would silently pick the last occurrence of each package. Now, all versions are shown, and no information is lost. - Schema change for
ganzua constraints inspect: renamed thegroupsJSON field toin_groups.
New features:
- Greatly improved docs and a new website at https://ganzua.latk.de.
- The examples in the docs now serve as the primary test suite.
- Each command that can output JSON now has an explicit output format specification.
- (https://github.com/latk/ganzua/issues/6) Add
--name=FILTERto all commands that operate on lockfiles or constraints. This can be used to restrict which packages are included in the output, or which constraints are modified. Supports comma-separated package names, glob expressions, and gitignore-style exclusions, e.g.--name 'foo*, !foo-bar'. - (https://github.com/latk/ganzua/issues/5) Support packages with “split versions”.
When a lockfile contains solutions for multiple Python versions or other environment differences, there might not be a single package version that is compatible with all environments.
Then, a lockfile might contain multiple versions for the same package.
This is now handled properly in all commands that interact with lockfiles.
Some operations like
ganzua constraints bumpwill issue a warning when a package has an ambiguous version. - Schema change for
ganzua constraints inspect: track information about extras (optional dependencies) with a newin_extrasfield ganzua constraints inspect: show information about groups and extras in Markdown output- Markdown output: hide certain columns if empty, e.g. “notes”.
Fixes:
- Sets are now shown in JSON output as sorted arrays. This ensures output remains stable across Ganzua runs.
ganzua constraints bumpnow treats certain version constraint operators more sensibly. This was discovered while rewriting the tests as a specification document.===arbitrary equality is now always updated, not removed.- Poetry
~now behaves more like>=than like~=. This means a~1expression can now be handled correctly.
Other:
- Rewrote test suite to prefer CLI-level end-to-end tests over unit tests.
Full diff: https://github.com/latk/ganzua/compare/v0.3.0...v0.4.0
v0.3.0 (2025-11-24)
This release focuses on extracting information about package sources, but also includes quality-of-life improvements and bug fixes. There are no breaking changes.
New features:
- Arguments for
pyproject.tomland lockfile paths are automatically inferred in the common cases where Ganzua is invoked from the project root. Instead of writing full paths, it is also sufficient to point to a directory containing these files. For example,ganzua inspect,ganzua inspect .andganzua inspect uv.lockare generally equivalent. - Keep some information about package sources (PyPI, Git, path dependencies, …) when inspecting lockfiles.
- Indicate certain kinds of differences that might need special attention in diff output (JSON/Markdown):
is_major_change(M),is_downgrade(D),is_source_change(S).
Fixes:
- (https://github.com/latk/ganzua/issues/3) Normalize names of packages, extras, and dependency groups when loading
pyproject.tomlfiles, as required by the packaging specifications. - (https://github.com/latk/ganzua/issues/4) Handle packages without versions in
uv.lockfiles. The fake version0+undefinedwill be substituted instead.
Other:
- Run tests under Python 3.14.
- Added a
CHANGELOG.mdfile. - Various internal changes and testing improvements.
Full diff: https://github.com/latk/ganzua/compare/v0.2.0...v0.3.0
v0.2.0 (2025-09-11)
This release fixes some bugs that were found through real-world usage, adds convenience features like diff summaries, and implements new constraint edits.
Breaking changes:
- Renamed
ganzua constraints removetoganzua constraints reset. - Schema change for
ganzua diffJSON output: diff is nested underpackageskey. - Schema change for
ganzua inspectJSON output: data is nested underpackageskey to match the diff schema.
New features:
- New command
ganzua constraints inspectlists all constraints in apyproject.tomlfile, including extras, environment markers, and dependency groups. This is particularly helpful for debugging Ganzua. - New option
ganzua constraints reset --to=minimumedits constraints to require at least the currently locked version, while removing any previous constraints. Essentially, this makes all direct dependencies upgradeable. - (https://github.com/latk/ganzua/issues/2) Add a summary line to
ganzua diffMarkdown output that counts the number of changes. - Add a
statsection toganzua diffJSON output that counts the number of changes.
Fixes:
- Support loading lockfiles regardless of name. Previously, lockfiles had to be named
poetry.lockoruv.lock. - (https://github.com/latk/ganzua/issues/1) Support
pyproject.tomlfiles with out-of-order tables.
Other:
- Various internal changes. Improvements to
pyproject.tomlmanipulation. Fewer special cases for Poetry.
Full diff: https://github.com/latk/ganzua/compare/v0.1.0...v0.2.0
v0.1.0 (2025-08-16)
Initial release.
- add
ganzua inspect - add
ganzua diff - add
ganzua constraints bump - add
ganzua constraints remove
Support
Ganzua is Open Source software, provided to you free of charge and on an "as is" basis. You are not entitled to support, help, or bugfixes of any kind.
Nevertheless, the Ganzua project may occasionally offer help.
- If you have questions about using Ganzua, you may search existing posts at https://github.com/latk/ganzua/discussions and start a new discussion if necessary.
- If you have discovered a bug in Ganzua, please report it at https://github.com/latk/ganzua/issues.
Ganzua intends to maintain a backwards-compatible command line interface, and intends to use SemVer version numbers.
Only those parts of the CLI that are relevant for scripting are covered by this stability policy:
- commands that inspect or modify files
- machine-readable output, e.g. the schema of JSON output
For example, Ganzua might increment the "minor" version number if a new field is added to JSON output or if new command line options are added, and increment the "major" version if output fields are removed or new required command line arguments are added.
Out of scope are:
- interacting with the
ganzuaPython module - Python versions or dependency versions used by Ganzua
- formatting of human-readable output (e.g. Markdown)
- formatting of error messages
- commands and flags that relate to help messages
Testing Strategy
Correctness is a core value for the Ganzua project. To this end, Ganzua has an exhaustive test suite. Large parts of this test suite are part of the documentation: the specification is the test. This doesn't just ensure exhaustive documentation for humans, but also makes it easier to keep docs in sync with the actual behavior.
Guiding values
-
If it can be tested automatically, do test it automatically. Human attention to detail doesn't scale. Thus, there are lots of automated checks within these docs. All internal links are checked, so there cannot be broken references. Nearly all command line examples are checked automatically.
- Corollary: if it can be generated automatically, do generate it automatically. For example, the CLI reference documentation is auto-generated by doing a bit of reflection on the code that defines the CLI. There is a single source of truth.
-
If it cannot be tested, it doesn't exist. Ganzua configures Pytest to require 100% code coverage. This encourages thorough tests, and discourages implementing unnecessary functionality. Coverage metrics aren't perfect, though. For example, testing different combinations of CLI options is difficult to guide via code coverage.
-
Write end-to-end tests. The tests double as documentation, so should be meaningful for users. This is strongly inspired by Behavior-Driven Development (BDD) practices. Instead of unit-testing internal functionality, as much as possible should be tested via the same command line interface that's accessible to end users.
How it works
Ganzua uses the Pytest testing framework, but uses a custom doctest-like approach for running examples embedded in the documentation.
For each documentation Markdown file, a custom tool parses the Markdown to extract examples, and rewrites it to include up-to-date outputs. Instead of writing the new version back directly (which might override intentional changes!), the test suite uses the Inline-Snapshot plugin to diff the file contents, and optionally update the file on disk.
Here's the entire driver for that test:
@pytest.mark.parametrize(
"path",
resources.DOCS.glob("**/*.md"),
ids=lambda p: str(p.relative_to(resources.DOCS)),
)
def test_docs(path: pathlib.Path) -> None:
markdown = Runner.run(path)
assert markdown == external_file(str(path), format=".txt")
For example, we might have an outdated example of running Ganzua:
```console $ touch $EXAMPLE/pyproject.toml $ ganzua constraints inspect $EXAMPLE {"outdated": "shape"} ```
Inline-Snapshot will show a diff with the actual output, as part of the Pytest session:
═══════════════════════ inline-snapshot ═══════════════════════
╭────────────────────── docs/testing.md ──────────────────────╮
│ @@ -75,5 +75,7 @@ │
│ │
│ ```console │
│ $ touch $EXAMPLE/pyproject.toml │
│ $ ganzua constraints inspect $EXAMPLE │
│ -{"outdated": "shape"} │
│ +{ │
│ + "requirements": [] │
│ +} │
│ ``` │
╰─────────────────────────────────────────────────────────────╯
Do you want to fix these snapshots? [y/n] (n):
Resulting in a replaced Markdown fragment:
```console $ touch $EXAMPLE/pyproject.toml $ ganzua constraints inspect $EXAMPLE { "requirements": [] } ```
Recognized Markdown patterns
This section documents the available Markdown patterns that can be used for running tests and automatically updating documentation files.
Each kind of pattern triggers on a marker line that matches a given regex, and can then transform that line and the following lines.
If no marker pattern matches, content is passed through verbatim.
no transformations by default
foo
bar
baz
foo
bar
baz
unknown <-- doctest: directives raise an error
<!-- doctest: foo bar -->
Error: unknown directive
note: at example:1
note: around here: <!-- doctest: foo bar -->
Doctest example
Used for self-documenting the supported Markdown directives.
Consists of a <div> or <details> tag with class doctest-example,
and contains a Markdown code block with info-string md,doctest-example.
A second Markdown code block (autogenerated) with info-string md,doctest-output
will contain the output.
If there's an error during processing, the autogenerated output block will have the info-string doctest-error.
<div class="doctest-example">
```md,doctest-example
<!-- command output: echo foo bar -->
<!-- command output end -->
```
</div>
<div class="doctest-example">
```md,doctest-example
<!-- command output: echo foo bar -->
<!-- command output end -->
```
```md,doctest-output
<!-- command output: echo foo bar -->
foo bar
<!-- command output end -->
```
</div>
input code block must be closed
```md,doctest-example
not closed
Error: must have matching closing fence
note: at example:1
note: around here: ```md,doctest-example
Command Output
Replace the content between to marker comments with the output from the given command. This is useful for auto-generating Markdown sections.
<!-- command output: echo foo bar -->
<!-- command output end -->
<!-- command output: echo foo bar -->
foo bar
<!-- command output end -->
output region must be closed
<!-- command output: echo foo bar -->
old content
Error: must have matching `<!-- command output end -->`
note: at example:1
note: around here: <!-- command output: echo foo bar -->
Console code block
Given a fenced code block of type console which contains $ command lines,
execute the commands (sandboxed!) and insert their output into the code block.
```console
$ echo print this
$ echo another command
```
```console
$ echo print this
print this
$ echo another command
another command
```
console block must contain commands
```console
not a $ command
```
Error: must have at least one `$ ...` command line
note: at example:1
note: around here: ```console
must have closing fence
````console
$ echo some command
```
Error: must have matching closing fence
note: at example:1
note: around here: ````console
Count executed commands
Consistency check to count how many shell commands were executed by the doctest runner so far. Only counts commands inside console code blocks.
```console
$ echo 1
$ echo 2
$ echo 3
```
<!-- doctest: ran ??? commands -->
```console
$ echo 1
1
$ echo 2
2
$ echo 3
3
```
<!-- doctest: ran 3 commands -->
Collapsible output blocks
When the full output would be distracting, a <details> element can be used to hide it.
The <summary> must contain a <code> element with the command.
<details><summary><code>$ echo foo bar</code></summary>
</details>
<details><summary><code>$ echo foo bar</code></summary>
```
foo bar
```
</details>
If the collapsible block should be open by default, the <details open> attribute may be used.
May have the open attribute
<details open><summary><code>$ echo foo bar</code></summary>
</details>
<details open><summary><code>$ echo foo bar</code></summary>
```
foo bar
```
</details>
Check Ganzua diff notes
A special utility for testing the notes feature of ganzua diff.
Given a marker comment that's followed by a diff table,
old and new lockfiles are constructed from the data in the table,
and the table is recreated from ganzua diff output.
This may change the notes column.
<!-- doctest: check ganzua diff notes -->
| package | old | new | notes |
|--|--|--|--|
| foo | 0.1.2 | 3.4.5 | |
<!-- doctest: check ganzua diff notes -->
| package | old | new | notes |
|---------|-------|-------|-------|
| foo | 0.1.2 | 3.4.5 | (M) |
Check Ganzua constraints bump
A special utility for checking how constraints are bumped. Consists of a marker comment and a table that will be updated. Columns:
locked: contains<package> <version>to define a single-package lockfile content.old: contains a constraint/requirement in one of the following forms:<package> <constraint>for PEP-508 requirements<package> = <constraint>for Poetry requirements (without extra TOML-style quoting)
new(autogenerated): contains a constraint/requirement after bumping theoldconstraint in the context of the given lockfile.
Each column value may be quoted as inline code.
<!-- doctest: check ganzua constraints bump -->
| locked | old | new |
| --- | --- | --- |
| `foo 1.2.3` | `foo>=0.1` | |
| `bar 4.5.6` | `bar = ^3.2` | |
<!-- doctest: check ganzua constraints bump -->
| locked | old | new |
|-------------|--------------|--------------|
| `foo 1.2.3` | `foo>=0.1` | `foo>=1.2` |
| `bar 4.5.6` | `bar = ^3.2` | `bar = ^4.5` |
Compare output
Given a list of multiple commands, check whether they all produce the same output. Consists of a marker comment and a list of commands, and will generate a details block with outputs if appropriate.
Example when the output is the same for all commands:
<!-- doctest: compare output -->
* `$ echo foo bar`
* `$ echo 'foo bar'`
<!-- doctest: compare output -->
* `$ echo foo bar`
* `$ echo 'foo bar'`
<details><summary>output for the above commands</summary>
```
foo bar
```
</details>
Example when some commands produce different output
<!-- doctest: compare output -->
* `$ echo foo bar`
* `$ echo different output`
* `$ echo 'foo bar'`
<!-- doctest: compare output -->
* `$ echo foo bar`
* `$ echo different output`
* `$ echo 'foo bar'`
<details><summary>output for the above commands</summary>
Output for:
* `$ echo foo bar`
* `$ echo 'foo bar'`
```
foo bar
```
Output for:
* `$ echo different output`
```
different output
```
</details>
must contain at least one command
<!-- doctest: compare output -->
not a command list
Error: must be followed by at least one command list item
note: at example:1
note: around here: <!-- doctest: compare output -->
Clean example
Reset the $EXAMPLE directory to a clean state.
It's a good idea to invoke this at the beginning of each section of a specification.
```console
$ touch $EXAMPLE/a
$ touch $EXAMPLE/b
$ ls $EXAMPLE
```
<!-- doctest: clean example -->
```console
$ touch $EXAMPLE/c
$ ls $EXAMPLE
```
```console
$ touch $EXAMPLE/a
$ touch $EXAMPLE/b
$ ls $EXAMPLE
a
b
```
<!-- doctest: clean example -->
```console
$ touch $EXAMPLE/c
$ ls $EXAMPLE
c
```
Create lockfile
Create a lockfile from an example table. Triggered by a marker comment that must be followed by a table.
Syntax for the marker comment:<!-- doctest: create <format> lockfile <path> -->
where:
formatis eitheruvorpoetrypathis the name of the file to be created, which MUST start with$EXAMPLE/
The table defines the locked dependency versions. Available columns (all optional):
name(default:example) is the package nameversion(default:0.1.0) is the locked versionsource_tomlis a TOML fragment describing the source of the locked package. Expected formats and default values are format-specific.
example creating an uv lockfile
<!-- doctest: create uv lockfile $EXAMPLE/uv.lock -->
| name | version |
|------|---------|
| foo | 1.2.3 |
```console
$ cat $EXAMPLE/uv.lock
```
<!-- doctest: create uv lockfile $EXAMPLE/uv.lock -->
| name | version |
|------|---------|
| foo | 1.2.3 |
```console
$ cat $EXAMPLE/uv.lock
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "foo"
version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
```
example creating a Poetry lockfile
<!-- doctest: create poetry lockfile $EXAMPLE/poetry.lock -->
| name | version |
|------|---------|
| foo | 1.2.3 |
```console
$ cat $EXAMPLE/poetry.lock
```
<!-- doctest: create poetry lockfile $EXAMPLE/poetry.lock -->
| name | version |
|------|---------|
| foo | 1.2.3 |
```console
$ cat $EXAMPLE/poetry.lock
[[package]]
name = "foo"
version = "1.2.3"
[metadata]
lock-version = "2.1"
python-versions = ">=3.12"
content-hash = "0000000000000000000000000000000000000000000000000000000000000000"
```
Supported shell commands
For security reasons, supported shell commands are tightly allowlisted.
Each shell command is processed as follows:
- word splitting is performed per the usual Posix rules
- variables are expanded – also within single quotes, unlike Posix rules
- resulting word lists are matched against the command allowlist and emulated
Other shell features like redirects, pipelines, and substitutions other than the given variables are not supported.
Supported variables (may use either $name or ${name} syntax):
$EXAMPLEcontains a temporary directory in which files may be edited$CORPUSpoints to the corpus of example projects
A path is writable if it is below the $EXAMPLE directory.
ganzua
ganzua ARGS...
Can run arbitrary Ganzua commands.
Error handling:
In most cases, errors will be printed out as part of the output.
A line like [command exited with status 2] may be appended to the output.
Output post-processing: Parts of the output that match known variables will be un-substituted. The type of the output (JSON or plaintext) will be sniffed.
Example of an error:
$ ganzua inspect $EXAMPLE
Usage: ganzua inspect [OPTIONS] [LOCKFILE]
Try 'ganzua inspect --help' for help.
Error: Could not infer `LOCKFILE` for `${EXAMPLE}`.
[command exited with status 2]
After we create an example lockfile, we can observe that the output format is detected as JSON and properly syntax-highlighted:
| name | version |
|---|---|
| foo | 1.2.3 |
$ ganzua inspect $EXAMPLE
{
"packages": [
{
"name": "foo",
"version": "1.2.3",
"source": "pypi"
}
]
}
echo
echo ARGS...
Just print out the arguments, separated by spaces. Mostly useful for testing the doctest system.
cat
cat FILE
Print the contents of the given file.
If the filename looks like a TOML file, then syntax highlighting will be added.
This is particularly useful for looking at pyproject.toml files.
$ cat $CORPUS/old-uv-project/pyproject.toml
[project]
name = "example"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"typing-extensions>=3,<4",
]
cp
cp SOURCE DEST
Copy the SOURCE file to DEST.
Both arguments must be filenames.
The DEST path must be writable.
$ ls $EXAMPLE
$ cp $CORPUS/new-uv-project/pyproject.toml $EXAMPLE/pyproject.toml
$ ls $EXAMPLE
pyproject.toml
touch
touch DEST
Touch the DEST file, i.e. create an empty file under that path if it doesn't exist.
The DEST path must be writable.
ls
ls DIRNAME
List the contents of a directory.
env
env -C DEST ganzua ARGS...
Change the current working directory to DEST before running Ganzua.
For example, these two commands end up inspecting the same project:
$ ganzua inspect $CORPUS/new-uv-project$ env -C $CORPUS/new-uv-project ganzua inspect
output for the above commands
{
"packages": [
{
"name": "annotated-types",
"version": "0.7.0",
"source": "pypi"
},
{
"name": "example",
"version": "0.1.0",
"source": {
"direct": "."
}
},
{
"name": "typing-extensions",
"version": "4.14.1",
"source": "pypi"
}
]
}
License
Copyright 2025-2026 Lukas Atkinson
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.