Project Setup

Optional Dependencies and Extras for Python CLIs

Keep a Python CLI’s base install small with extras: declaring them, installing with pipx and uv, friendly errors when an extra is missing, lazy imports and tests.

Updated

Every dependency a CLI declares is downloaded by every user, imported (or at least resolved) on every install, and audited by every security team. Yet many features are used by a minority: YAML output for the Kubernetes crowd, Excel export for the finance team, an S3 backend for the people on AWS, a full-screen Textual interface for those who want one. Extras — PEP 508 optional dependency groups — let you ship those features in the same package while users opt in to the dependencies they need: uv tool install "mytool[yaml,s3]". Done well, the base install stays small and fast, and a user who tries a feature without its extra gets a one-line explanation instead of a ModuleNotFoundError. This guide covers declaring extras, installing them, the runtime pattern that makes them pleasant, and testing both sides. It belongs to the packaging topic.

Prerequisites

What should be an extra?

Make a dependency optional when it is large or slow to import, has a heavy transitive tree, requires system libraries, or serves a feature most users never touch. Keep it required when nearly every command needs it, or when making it optional would complicate the code more than it saves.

Required or optional? A decision guide for whether a command line tool dependency should be required or moved into an optional extra. Required or optional? Who needs this dependency? Nearly every command Required keep it in dependencies One heavy feature Extra mytool[yaml], mytool[s3] Only developers Group dependency-groups, never published If most users install [all], the split is in the wrong place.

A useful test: look at your import-time profile and your dependency tree (uv tree --depth 1). Packages that dominate either, and serve one or two commands, are the candidates. Reducing CLI dependency weight covers the startup side of the same decision.

The recipe

Declare the extras

[project]
name = "mytool"
dependencies = [
    "typer>=0.12",
    "httpx>=0.27,<1",
]

[project.optional-dependencies]
yaml = ["pyyaml>=6"]
excel = ["openpyxl>=3.1"]
s3 = ["boto3>=1.34"]
tui = ["textual>=0.80"]
all = ["mytool[yaml,excel,s3,tui]"]

Extra names are lower-case words users will type, so keep them short and descriptive. The all extra refers back to the package itself, which avoids repeating version constraints. With uv, uv add --optional yaml pyyaml adds an entry and re-locks; the lock file covers every extra, and uv sync --extra yaml or --all-extras installs them for development.

Install with extras

uv tool install "mytool[yaml,s3]"
pipx install "mytool[yaml,s3]"
uvx --from "mytool[excel]" mytool export --to report.xlsx

Quote the requirement — square brackets are glob characters in zsh and some other shells, and an unquoted mytool[yaml] fails with "no matches found". Adding an extra to an existing pipx installation needs pipx install --force "mytool[yaml]" or pipx inject mytool pyyaml; with uv, re-running uv tool install with the new extras replaces the environment.

Fail helpfully when an extra is missing

The runtime half matters most. Import optional modules inside the code path that needs them, and convert a missing import into a message that names the extra:

# src/mytool/optional.py
from __future__ import annotations

import importlib
from types import ModuleType


class MissingExtra(RuntimeError):
    def __init__(self, feature: str, extra: str, module: str) -> None:
        super().__init__(
            f"{feature} needs the optional '{extra}' dependencies (module '{module}' is missing).\n"
            f"Install them with:  uv tool install --force 'mytool[{extra}]'\n"
            f"               or:  pipx install --force 'mytool[{extra}]'"
        )
        self.extra = extra


def require(module: str, *, extra: str, feature: str) -> ModuleType:
    """Import an optional module or raise MissingExtra with install instructions."""
    try:
        return importlib.import_module(module)
    except ModuleNotFoundError as exc:
        if exc.name and exc.name.split(".")[0] == module.split(".")[0]:
            raise MissingExtra(feature, extra, module) from None
        raise                                   # a different module is missing: a real bug
# src/mytool/cli.py
import json
import sys

import typer

from mytool.optional import MissingExtra, require

app = typer.Typer()
ROWS = [{"name": "web-1", "cpu": 0.42}]


@app.callback()
def main() -> None:
    """My tool."""


@app.command()
def show(fmt: str = typer.Option("json", "--format", help="json or yaml (needs [yaml])")) -> None:
    """Show servers."""
    if fmt == "yaml":
        yaml = require("yaml", extra="yaml", feature="YAML output")
        sys.stdout.write(yaml.safe_dump(ROWS, sort_keys=False))
    else:
        typer.echo(json.dumps(ROWS))


def run() -> None:
    try:
        app()
    except MissingExtra as exc:
        typer.echo(f"error: {exc}", err=True)
        raise SystemExit(3)

Two details make this robust. The import happens inside the branch, so users without the extra never pay for it and never see an error unless they ask for the feature. And require() checks exc.name: if PyYAML is installed but one of its own imports fails, that is a genuine bug and should not be disguised as "install the extra". The dedicated exit code (3 here) lets scripts tell "missing optional feature" apart from usage errors and runtime failures; choosing exit codes for CLI tools discusses reserving codes like this.

A missing extra, explained Terminal session where YAML output fails without the yaml extra with an install hint, then works after installing the extra. A missing extra, explained bash $ mytool show --format yaml error: YAML output needs the optional 'yaml' dependencies (module 'yaml' is missing). Install them with: uv tool install --force 'mytool[yaml]' $ uv tool install --force 'mytool[yaml]' $ mytool show --format yaml - name: web-1 cpu: 0.42 Exit code 3 lets scripts tell a missing feature from a usage error.

Users cannot opt into what they do not know exists. Mention the extra in the help text of every option that needs it (--format yaml above), list extras in the README's installation section, and consider a mytool doctor or mytool version --verbose command that reports which optional features are available:

from importlib.util import find_spec

FEATURES = {"yaml": "yaml", "excel": "openpyxl", "s3": "boto3", "tui": "textual"}


def available_extras() -> dict[str, bool]:
    return {extra: find_spec(module) is not None for extra, module in FEATURES.items()}

importlib.util.find_spec checks availability without importing, so the report stays fast even when the heavy packages are installed.

UX considerations

  • Name the install command in the error. "pip install pyyaml" is wrong for pipx and uv tool users, who cannot pip-install into the tool's environment; give the extras-based command for the installers you support.
  • Never import optional modules at module top level in code every command loads. One stray import boto3 in a shared module turns an optional dependency into a required one, at runtime if not on paper.
  • Keep the base install useful on its own. If most users end up installing [all], the split is wrong.
  • Version extras like everything else. Moving a dependency from required to optional is a breaking change for users who relied on it being present; announce it, and consider a transition release where the feature warns before it fails.
Adding an extra with each installer How to install or add an optional extra for a command line tool with uv, pipx and pip. Adding an extra with each installer Installer Fresh install Add to an existing install uv tool uv tool install 'mytool[yaml]' same command with --force pipx pipx install 'mytool[yaml]' install --force, or inject uvx (no install) uvx --from 'mytool[yaml]' mytool — pip in a venv pip install 'mytool[yaml]' same command Quote the brackets: zsh treats an unquoted [yaml] as a glob.

Testing the behaviour

Test both worlds: with the extra installed, the feature works; without it, the message is right. Setting sys.modules[name] = None makes Python raise ModuleNotFoundError for that import, which simulates a missing extra without uninstalling anything:

# tests/test_extras.py
import sys

import pytest
from typer.testing import CliRunner

from mytool import cli
from mytool.optional import MissingExtra, require


def test_yaml_output_with_extra_installed():
    pytest.importorskip("yaml")
    result = CliRunner().invoke(cli.app, ["show", "--format", "yaml"])
    assert result.exit_code == 0
    assert "name: web-1" in result.stdout


def test_missing_extra_gives_install_hint(monkeypatch):
    monkeypatch.setitem(sys.modules, "yaml", None)
    with pytest.raises(MissingExtra) as exc:
        require("yaml", extra="yaml", feature="YAML output")
    assert "mytool[yaml]" in str(exc.value)


def test_entry_point_exits_3_without_extra(monkeypatch, capsys):
    monkeypatch.setitem(sys.modules, "yaml", None)
    monkeypatch.setattr(sys, "argv", ["mytool", "show", "--format", "yaml"])
    with pytest.raises(SystemExit) as exc:
        cli.run()
    assert exc.value.code == 3
    assert "uv tool install --force 'mytool[yaml]'" in capsys.readouterr().err

In CI, add one job that installs the package without any extras — uv sync --no-dev and then the test suite's "no extras" subset, or the built wheel with no extras — to prove that importing the CLI and running its base commands never touches an optional module. The Python version matrix is a natural place for that extra job.

Conclusion

Extras let one package serve both the minimal and the full-featured user. Declare them in [project.optional-dependencies] with short names and an all convenience, import optional modules only inside the features that need them, convert a missing import into a message that names the right install command, and test the "extra missing" path as deliberately as the feature itself. The base install stays small and fast, and nobody meets a bare ModuleNotFoundError.

Frequently asked questions

Should development tools be extras?

No. Test, lint and docs tools belong in dependency groups ([dependency-groups]), which are not published with the package. Extras are for features end users opt into; see locking and syncing CLI dependencies with uv.

Can an extra add a whole command?

Yes, and it is a clean pattern: register the command always, import its implementation lazily, and let require() explain what is missing. Alternatively, ship the feature as a separate plugin package discovered through entry points, as in discovering plugins with entry points, when it has its own release cadence.

How do I make an extra the default?

You cannot — PEP 508 has no "default extras". If most users need a dependency, make it required. Some projects publish a second, thin package (mytool-full) that depends on mytool[all] for users who prefer one name.

Do extras work with standalone binaries?

A binary bundles whatever was installed when it was built, so build it with the extras you want to ship — or publish two binaries. The runtime require() check still gives a clear message if a feature was left out.

What if two extras need conflicting versions of a package?

Resolvers treat extras as additions to one environment, so mytool[a,b] must have a single compatible version of every shared dependency. Avoid the problem by keeping extras' version ranges broad, and if two features genuinely need incompatible versions, ship one of them as a separate plugin package with its own environment. With uv you can declare conflicts between extras under [tool.uv] so the lock file handles the case explicitly, but users installing both at once will still get a resolution error — so document which combinations are supported.