Article

Publishing a Geospatial CLI to PyPI with Binary Wheels

Keep your own package pure Python and let rasterio, pyogrio and pyproj supply the compiled GDAL through their own wheels. Declare permissive ranges, expose one console script, and gate the upload on an install into a genuinely empty environment — which is the step that catches the dependency you forgot to declare. It builds on the Packaging & CI/CD guide, part of the broader CLI Architecture & Design Patterns reference.

Prerequisites

What You Are and Are Not Shipping

Three positions, one of which is almost always right A pure-Python package depending on rasterio's wheels ships in minutes and inherits their platform coverage. Vendoring GDAL means building wheels for every platform yourself. Requiring a system GDAL pushes the problem to users and produces the largest share of installation issues. pure Python + wheels you ship no compiled code rasterio and pyogrio bring GDAL with them one sdist, one wheel do this vendor GDAL yourself build for every platform cibuildwheel, manylinux, macOS arm and x86 weeks of maintenance only if you must require system GDAL users install it first version skew, PROJ paths, and most support load nothing to build avoid

The first column is available to almost every CLI, because the compiled work is already done by the libraries you depend on. It is worth checking that assumption explicitly: if your package has no .c, .pyx or setup.py build step, you are in that column whether you meant to be or not.

Complete Working Implementation

# pyproject.toml
[build-system]
requires = ["hatchling>=1.24"]
build-backend = "hatchling.build"

[project]
name = "gistools"
version = "1.4.0"
description = "Command-line tools for Python geospatial batch processing"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
dependencies = [
  "typer>=0.12,<1",
  "rasterio>=1.3,<2",
  "pyogrio>=0.7,<1",
  "pyproj>=3.6,<4",
]

[project.optional-dependencies]
cloud = ["fsspec>=2024.2", "s3fs>=2024.2"]
dev = ["pytest>=8", "pytest-cov", "build", "twine"]

[project.scripts]
gis = "gistools.cli:app"

[tool.hatch.build.targets.wheel]
packages = ["src/gistools"]

And the release job, with the gate that matters:

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.11" }
      - run: pip install build twine
      - run: python -m build
      - run: twine check dist/*
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: dist/ }

  smoke:
    needs: build
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python: ["3.10", "3.11", "3.12"]
    runs-on: $
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist }
      - uses: actions/setup-python@v5
        with: { python-version: "$" }
      # A clean environment: no repository checkout, no dev dependencies.
      - run: pip install --only-binary=:all: dist/*.whl
      - run: gis --version
      - run: gis --help
      - run: python -c "import gistools; print(gistools.__version__)"

  publish:
    needs: smoke
    runs-on: ubuntu-latest
    permissions: { id-token: write }
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist }
      - uses: pypa/gh-action-pypi-publish@release/v1

Step Annotations

  1. Ranges, not pins, in dependencies. A pinned rasterio makes your package unusable alongside anything else that pins a different one — the distinction the parent guide draws between package metadata and a lock file.
  2. --only-binary=:all: in the smoke job. It fails loudly if any dependency has no wheel for that platform, rather than silently compiling from source in CI and succeeding where a user would not.
  3. The smoke job runs without a checkout. Installing into a directory containing the source tree can import the local package rather than the installed one, which hides a packaging mistake completely.
  4. twine check before anything else. It catches a malformed README, which PyPI rejects at upload time — after the tag has been pushed and everyone has been told the release is out.
  5. Trusted publishing rather than a stored token. No long-lived secret to rotate, and the identity is scoped to the repository and workflow.

The Version Number Has to Come From One Place

One source of truth for the version The version in pyproject metadata is what PyPI records. A module attribute is what --version prints. The git tag is what triggers the release. When they disagree, a published artefact reports a version that does not exist. pyproject metadata what PyPI records and pip resolves against the canonical one a module attribute what --version prints to a user derive it, do not repeat it the git tag what triggers the release workflow check it matches

Deriving the module attribute with importlib.metadata.version("gistools") removes the second source. Asserting that the tag matches the metadata in the build job removes the third:

from importlib.metadata import version

__version__ = version("gistools")

One Named Gotcha: pip install . in CI Hides a Missing Dependency

Installing from the source directory picks up anything importable from the working tree, so a module your package imports but never declares still resolves. The wheel published from the same commit then fails on a user’s machine with an import error you could not reproduce.

Installing the built wheel, in a directory that does not contain the source, is the only reliable check — which is what the smoke job above does. Running it across three operating systems catches the second variant of the same problem: a dependency that has a wheel on Linux and not on Windows.

Verification

# Locally, before tagging: build, then install into a throwaway environment.
python -m build
python -m venv /tmp/verify && /tmp/verify/bin/pip install --only-binary=:all: dist/*.whl
cd /tmp && /tmp/verify/bin/gis --version && /tmp/verify/bin/gis --help

# Confirm the console script is on PATH and points where you expect.
/tmp/verify/bin/python -c "import shutil; print(shutil.which('gis'))"

The cd /tmp matters. Running the check from the repository root imports the source tree and proves nothing about the wheel.

A release that has to be withdrawn is expensive, so it is worth knowing which mistakes can be fixed by a new version and which cannot.

Which mistakes a patch release can fix A missing dependency is fixed by a patch release. A bad README is fixed by a patch release, because PyPI renders the version's own metadata. A wrong version number cannot be reused — that number is burned. A breaking change shipped as a patch needs a yank as well as a fix. a missing dependency declare it and publish a patch patch fixes it a malformed README fix and publish a patch; the old one stays patch fixes it the wrong version number that number can never be reused on PyPI burned a breaking change in a patch yank it, then publish a correct minor yank required

The third row is why the build job asserting that the tag matches the metadata is worth the four lines: it is the only mistake on this list with no clean remedy.

Choosing Version Ranges That Age Well

The upper bound is the part people get wrong in both directions. Omit it and your package breaks the day a dependency ships a major version with a changed API. Set it too tightly and your package becomes uninstallable alongside anything else in the same environment.

For the geospatial stack specifically, three conventions work well. Pin the major version and nothing below it — rasterio>=1.3,<2 — because that library follows semantic versioning and a minor release has not broken a CLI in practice. Set the lower bound to the oldest version you actually test in the matrix, not the oldest that might work, because an untested lower bound is a claim you cannot back. And review the bounds at each release rather than letting them drift, since a lower bound that has aged past everyone’s installed version is as much a problem as a missing upper one.

There is one geospatial-specific wrinkle. rasterio, pyogrio and fiona each vendor their own GDAL build, and installing two of them can produce a process with two GDAL libraries loaded. In practice it works, because the wheels are built to coexist, but it doubles the install size and occasionally produces confusing behaviour when a driver exists in one build and not the other. Depending on one vector library rather than two is worth the small amount of code it costs.

Extras, and What Belongs in Them

An extra is the right home for anything a meaningful share of users will never need. For a geospatial CLI that usually means the cloud stack — fsspec, s3fs, gcsfs — which is a substantial install and irrelevant to anyone working on local files.

The mechanics are simple; the discipline is in the error message. A command that needs an extra should fail with a message naming the extra, not with an ImportError naming a module:

def _require_cloud() -> None:
    try:
        import fsspec  # noqa: F401
    except ImportError as exc:
        raise typer.BadParameter(
            "cloud paths need the optional extra: pip install 'gistools[cloud]'"
        ) from exc

That message is the difference between a user installing the extra in ten seconds and a user opening an issue. It is the same principle the section applies to missing dependencies generally: name the package and the fix, never the traceback.

Keep the extras small and few. Three extras is a menu; eight is a maze, and users end up installing [all] to be safe, which defeats the purpose of having them.

Publishing a Release Candidate First

For anything with users, a release candidate published before the final version costs one extra tag and catches the class of problem that only appears on a real machine. pip install gistools==2.0.0rc1 requires the explicit version, so a candidate does not reach anyone who did not ask for it, and a week between the candidate and the release is enough for the people who care most to try it.

The argument against — that nobody installs candidates — is usually true and does not matter. The candidate’s job is to prove the artefact installs and runs, and the smoke matrix already did most of that. What the candidate adds is a real machine with a real environment, which is where the last class of packaging surprise lives.

After the Upload

Two checks are worth running against the published artefact rather than the local one, because they exercise the path a user actually takes. Installing from PyPI into a fresh environment confirms the index has what the build produced. And running the console script from that environment confirms the entry point survived the round trip — a metadata mistake in [project.scripts] produces a package that imports fine and has no command.

Both take under a minute and belong in a short post-release checklist rather than in CI, since by definition they can only run once the release exists.