Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 ganzua to try Ganzua without installation
  • uv tool install ganzua to install Ganzua on your machine

Alternative: run or install via the pipx tool:

  • pipx run ganzua to try Ganzua without installation
  • pipx install ganzua to 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.0 to pin an exact version
  • uvx '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.git
  • pipx 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.

ganzua

Usage: ganzua [OPTIONS] COMMAND [ARGS]...

Inspect Python dependency lockfiles (uv and Poetry).

Options:

  • --help Show this help message and exit.

Commands:

  • help Show help for the application or a specific subcommand.
  • inspect Inspect a lockfile.
  • diff Compare two lockfiles.
  • constraints Work with pyproject.toml constraints.
  • schema Show 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:

  • --all Also show help for all subcommands.
  • --markdown Output 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 FILTER Include/exclude packages to inspect by name. [default: show all]
  • --format [json|markdown] Choose the output format, e.g. Markdown. [default: json]
  • --help Show 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: packages is now a list. Previously, it was a name → LockedPackage table.

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 FILTER Include/exclude packages to diff by name. [default: diff all]
  • --format [json|markdown] Choose the output format, e.g. Markdown. [default: json]
  • --help Show 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:

packageoldnewnotes
epoch-changed1.2.31!1.2.3(M)
epoch-zero1.2.30!1.2.3
existence-added-1.2.3
existence-removed1.2.3-
major1.2.32.1.0(M)
minor1.2.31.3.4
validity-invalid-to-invalidfoobar(M)
validity-invalid-to-validfoo1.2.3(M)
validity-valid-to-invalid1.2.3foo(M)
zerover-change0.1.20.2.0(M)
zerover-same0.1.20.1.3

Test cases for demonstrating how the (D)/is_downgrade note works:

packageoldnewnotes
downgrade1.3.41.0.1(D)
upgrade1.0.11.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: int
  • added: int
  • removed: int
  • updated: 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:

  • --help Show this help message and exit.

Commands:

  • inspect List all constraints in the pyproject.toml file.
  • bump Update pyproject.toml dependency constraints to match the lockfile.
  • reset Remove or relax any dependency version constraints from the pyproject.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 FILTER Include/exclude constraints to show by package name. [default: show all]
  • --format [json|markdown] Choose the output format, e.g. Markdown. [default: json]
  • --help Show 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:

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 groups to in_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 groups and in_extras fields are effectively mutually exclusive.

    Special cases for legacy Poetry:

    • When using [tool.poetry.extras], one requirement can be part of multiple extras.
    • The marker might also reference extras.

    Added in Ganzua 0.4.0.

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 PATH Where 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 PYPROJECT directory
  • --backup PATH Store a backup in this file.
  • --name FILTER Include/exclude constraints to edit by package name. [default: edit all]
  • --help Show 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:

nameversion
annotated-types0.7.0
example0.2.0
typing-extensions4.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:

nameversion
annotated-types0.7.0
example0.2.0
typing-extensions4.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:

packageversion
split0.1.0
typing-extensions3.10.0.2
typing-extensions4.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
nameversion
annotated-types0.7.0
example0.2.0
typing-extensions4.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.

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:

Syntax for example tables:

  • The locked column shows the locked version in format <package> <version>, e.g. foo 1.2.3.
  • The old and new column 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 = *

Basic examples

Will only bump packages that are present in the lockfile. Other constraints are not affected:

lockedoldnew
locked 1.2.3other >=4,<5other >=4,<5
locked 1.2.3other = ^4other = ^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:

lockedoldnew
package 1.2.3packagepackage
package 1.2.3package = *package = *

Lower version bound examples

When the major version changes, a >= bound is bumped with matching granularity:

lockedoldnew
major 7.1.2major>=4major>=7
minor 7.1.2minor>=4.3minor>=7.1
patch 7.1.2patch>=4.3.2patch>=7.1.2
minor-poetry 7.1.2minor-poetry = >=4.3minor-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.

lockedoldnew
major 4.5.6major>=4major>=4
minor0 4.5.6minor0>=4.0minor0>=4.5
minor 4.5.6minor>=4.3minor>=4.5
patch 4.5.6patch>=4.3.2patch>=4.5.6
minor-poetry 4.5.6minor-poetry = >=4.3minor-poetry = >=4.5
patch-poetry 4.5.6patch-poetry = >=4.3.2patch-poetry = >=4.5.6

Constraints may also be downgraded if this is necessary to match the locked version:

lockedoldnew
major 4.5.6major>=7major>=4
minor 4.5.6minor>=4.9minor>=4.5
patch 4.5.6patch>=4.5.9patch>=4.5.6

SemVer examples

Poetry has the ^ SemVer operator. It is bumped the same way as >= bounds:

lockedoldnew
major 7.1.2major = ^4major = ^7
minor 7.1.2minor = ^7.0minor = ^7.1
patch 7.1.2patch = ^7.1.1patch = ^7.1.2
downgrade 7.1.2downgrade = ^9.8downgrade = ^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:

lockedoldnew
major 7.1.2major>=4,<5major>=7,<8
minor0 7.1.2minor0>=4.0,<5minor0>=7.1,<8
minor 7.1.2minor>=4.9,<5minor>=7.1,<8
patch 7.1.2patch>=4.0.1,<5patch>=7.1.2,<8

However, upper bounds are not changed if they remain valid for the locked version:

lockedoldnew
minor-upgrade 4.5.6minor-upgrade>=4.3,<5minor-upgrade>=4.5,<5
minor-downgrade 4.5.6minor-downgrade>=4.9,<5minor-downgrade>=4.5,<5
wide 7.1.2wide>=4.5,<9wide>=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:

lockedoldnew
major 7.1.2major>=4,==4.*major>=7,==7.*
minor0 7.1.2minor0>=4.0,==4.*minor0>=7.1,==7.*
minor 7.1.2minor>=4.9,==4.*minor>=7.1,==7.*
minor-match 7.1.2minor-match>=4.9,==4.9.*minor-match>=7.1,==7.1.*
patch 7.1.2patch>=4.0.1,==4.*patch>=7.1.2,==7.*
downgrade 7.1.2downgrade >=9.8,==9.*downgrade>=7.1,==7.*
minor-upgrade 4.5.6minor-upgrade>=4.3,==4.*minor-upgrade>=4.5,==4.*
minor-downgrade 4.5.6minor-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
lockedoldnew
foo 7.1.2foo==4.3.2foo==7.1.2
foo 7.1.2foo==4.3foo==7.1.2
foo 7.1.2foo==4foo==7.1.2
foo 7.1.2foo==7foo==7.1.2
foo 7.1.2foo==7.1foo==7.1.2
foo 7.1.2foo==7.1.2foo==7.1.2
expand Poetry examples
lockedoldnew
foo 7.1.2foo = 4.3.2foo = 7.1.2
foo 7.1.2foo = 4.3foo = 7.1.2
foo 7.1.2foo = 4foo = 7.1.2
foo 7.1.2foo = 7foo = 7.1.2
foo 7.1.2foo = 7.1foo = 7.1.2
foo 7.1.2foo = 7.1.2foo = 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
lockedoldnew
foo 7.1.2foo==4.3.*foo==7.1.*
foo 7.1.2foo==4.*foo==7.*
foo 7.1.2foo==7.*foo==7.*
foo 7.1.2foo==7.0.*foo==7.1.*
foo 7.1.2foo==7.1.*foo==7.1.*
expand Poetry examples
lockedoldnew
foo 7.1.2foo = 4.3.*foo = 7.1.*
foo 7.1.2foo = 4.*foo = 7.*
foo 7.1.2foo = 7.*foo = 7.*
foo 7.1.2foo = 7.0.*foo = 7.1.*
foo 7.1.2foo = 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
lockedoldnew
major 7.1.2major~=4.5major~=7.1
minor0 4.5.6minor0~=4.0minor0~=4.5
minor 4.5.6minor~=4.3minor~=4.5
patch 4.5.6patch~=4.3.2patch~=4.5.6
minor-poetry 4.5.6minor-poetry = ~=4.3minor-poetry = ~=4.5
patch-poetry 4.5.6patch-poetry = ~=4.3.2patch-poetry = ~=4.5.6
major-downgrade 4.5.6major-downgrade~=7.9major-downgrade~=4.5
minor-downgrade 4.5.6minor-downgrade~=4.9minor-downgrade~=4.5
patch-downgrade 4.5.6patch-downgrade~=4.5.9patch-downgrade~=4.5.6
noop 1.2.3noop~=1.2.3noop~=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
lockedoldnew
major 7.1.2major = ~4major = ~7
major 7.1.2major = ~4.5major = ~7.1
minor0 4.5.6minor0 = ~4.0minor0 = ~4.5
minor 4.5.6minor = ~4.3minor = ~4.5
patch 4.5.6patch = ~4.3.2patch = ~4.5.6
major-downgrade 4.5.6major-downgrade = ~7major-downgrade = ~4
major-downgrade 4.5.6major-downgrade = ~7.9major-downgrade = ~4.5
minor-downgrade 4.5.6minor-downgrade = ~4.9minor-downgrade = ~4.5
patch-downgrade 4.5.6patch-downgrade = ~4.5.9patch-downgrade = ~4.5.6
noop 1.2.3noop = ~1.2.3noop = ~1.2.3
noop 1.2.3noop = ~1.2noop = ~1.2
noop 1.2.3noop = ~1noop = ~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
lockedoldnew
foo 7.1.2foo!=4.3.2foo!=4.3.2
foo 7.1.2foo!=4.*foo!=4.*
foo 7.1.2foo!=7.9.*foo!=7.9.*
foo 7.1.2foo!=7.0.1foo!=7.0.1
exclusions are removed if they would exclude the current version
lockedoldnew
foo 7.1.2foo!=7.*foo
foo 7.1.2foo!=7.1.*foo
foo 7.1.2foo!=7.1.2foo

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
lockedoldnew
foo 7.1.2.post1+abcfoo===7.1.2foo===7.1.2.post1+abc
foo 7.1.2foo===7.1.2.post1+abcfoo===7.1.2
foo 7.1.2foo===whateverfoo===7.1.2
unmodified 7.1.2unmodified===7.1.2unmodified===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
lockedoldnew
foo 7.1.2foo>4foo>4
foo 7.1.2foo>8foo
foo 7.1.2foo<5foo
foo 7.1.2foo<8foo<8
foo 7.1.2foo<=5foo
foo 7.1.2foo<=8foo<=8
foo 7.1.2foo<=7.1foo
foo 7.1.2foo<=7.2foo<=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 PATH Store a backup in this file.
  • --to [none|minimum] How to reset constraints.
    • none (default): remove all constraints
    • minimum: set constraints to the currently locked minimum, removing upper bounds
  • --lockfile PATH Where 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 PYPROJECT directory
  • --name FILTER Include/exclude constraints to edit by package name. [default: edit all]
  • --help Show 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
packageversion
annotated-types>=0.7.0
typing-extensions>=4
constraints in the edited project
packageversion
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:

packageversiongroup/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:

packageversiongroup/extra
annotated-types==0.6.*,>=0.6.1extra extra1
annotated-types~=0.6.1group group-b
merrily-ignored
ndrextra extra3
typing-extensions<4,>=3
typing-extensions~=3.4group group-a, group group-b

Current locked versions:

nameversion
annotated-types0.7.0
example0.2.0
typing-extensions4.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:

packageversiongroup/extra
annotated-types>=0.7.0extra extra1
annotated-types>=0.7.0group group-b
merrily-ignored
ndrextra extra3
typing-extensions>=4.14.1
typing-extensions>=4.14.1group 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:

packageversiongroup/extra
already-unconstrained*group poetry-a
typing-extensions^3.2
typing-extensions^3.4group poetry-a

Current locked versions:

nameversion
annotated-types0.7.0
example0.2.0
typing-extensions4.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:

packageversiongroup/extra
already-unconstrained*group poetry-a
typing-extensions>=4.14.1
typing-extensions>=4.14.1group 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:

packageversion
split0.1.0
typing-extensions3.10.0.2
typing-extensions4.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]
  • --help Show 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.

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:

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
nameversion
annotated-types0.7.0
click8.3.1
colorama0.4.6
coverage7.12.0
dirty-equals0.11
executing2.2.1
idna3.11
iniconfig2.3.0
inline-snapshot0.31.1
multidict6.7.0
mypy1.18.2
mypy-extensions1.1.0
packaging25.0
pluggy1.6.0
propcache0.4.1
pydantic2.12.4
pydantic-core2.41.5
pygments2.19.2
pytest9.0.1
pytest-cov7.0.0
tomlkit0.13.3
typing-extensions4.15.0
typing-inspection0.4.2
yarl1.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: packages is 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 the groups JSON field to in_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=FILTER to 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 bump will issue a warning when a package has an ambiguous version.
  • Schema change for ganzua constraints inspect: track information about extras (optional dependencies) with a new in_extras field
  • 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 bump now 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 ~1 expression 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.toml and 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 . and ganzua inspect uv.lock are 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:

Other:

  • Run tests under Python 3.14.
  • Added a CHANGELOG.md file.
  • 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 remove to ganzua constraints reset.
  • Schema change for ganzua diff JSON output: diff is nested under packages key.
  • Schema change for ganzua inspect JSON output: data is nested under packages key to match the diff schema.

New features:

  • New command ganzua constraints inspect lists all constraints in a pyproject.toml file, including extras, environment markers, and dependency groups. This is particularly helpful for debugging Ganzua.
  • New option ganzua constraints reset --to=minimum edits 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 diff Markdown output that counts the number of changes.
  • Add a stat section to ganzua diff JSON output that counts the number of changes.

Fixes:

  • Support loading lockfiles regardless of name. Previously, lockfiles had to be named poetry.lock or uv.lock.
  • (https://github.com/latk/ganzua/issues/1) Support pyproject.toml files with out-of-order tables.

Other:

  • Various internal changes. Improvements to pyproject.toml manipulation. 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.

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 ganzua Python 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 the old constraint 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:

  • format is either uv or poetry
  • path is 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 name
  • version (default: 0.1.0) is the locked version
  • source_toml is 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:

  1. word splitting is performed per the usual Posix rules
  2. variables are expanded – also within single quotes, unlike Posix rules
  3. 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):

  • $EXAMPLE contains a temporary directory in which files may be edited
  • $CORPUS points 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:

nameversion
foo1.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.