mrkeyoor.com_
Thu 06 Aug 07:39 UTC
PyPICLI & Toolingupdated 06 Aug 2026

invoke

Invoke is a task runner: you write ordinary Python functions in a file called tasks.py, decorate them with @task, and run them from the shell as inv build or inv test --coverage. It is what people reach for when a Makefile stops being pleasant because the logic wants to be Python. Two halves make up the library. The first is the task and CLI layer: the decorator turns a function's parameters into command line flags automatically, so a keyword argument with a False default becomes a boolean flag and one with a string default becomes a flag that takes a value, and it builds help text, short flags, task listings and namespaces out of the same functions. The second is a subprocess runner reached through the context object every task receives. c.run('pytest') streams output to your terminal while capturing it, returns a Result carrying stdout, stderr, the exit code and the PID, raises on non-zero exit unless you ask it not to, and can allocate a pseudo-terminal for programs that behave differently when they think they are talking to a human. Invoke has no runtime dependencies and is the library Fabric is built on.

Verdict

Invoke is a well-designed, dependency-free way to turn Python functions into a project CLI, and its subprocess runner is genuinely better than anything you would write yourself. Go in knowing it is a command runner and not a build system, and that releases arrive years apart.

API stability5/5@task, Context.run, Result and Collection have kept the same shape since 1.0 in 2018. The 3.0 release in April 2026 broke almost nothing: it dropped Python versions below 3.9, moved packaging metadata to pyproject.toml, and changed run() with disown=True to return a Result instead of None. Task files written for 1.x mostly still run.
Docs4/5pyinvoke.org has a conceptual guide covering task arguments, namespaces, the configuration hierarchy and the runner internals, and docs.pyinvoke.org carries versioned API reference generated from thorough docstrings. What is missing is a cookbook: common needs such as running a task in a subdirectory or handling a failing command gracefully mean reading two separate chapters and inferring the combination.
Maintenance2/5The last release, 3.0.3, went out on 7 April 2026 and the repo has not been pushed since, with 351 open issues (456 counting PRs). It is effectively a single-maintainer project on a multi-year release rhythm; the 2.x line covered January 2023 to early 2026. Nothing is broken, but treat open bugs as permanent features of the landscape rather than things that will be fixed.
Ecosystem4/5Around 19.5M weekly downloads, mostly as the foundation under Fabric and as a transitive dependency of deployment tooling. There is no plugin ecosystem to speak of, because the extension point is just writing more Python, but the Program class lets you ship your own branded CLI binary built on the same task machinery.

Use it if

  • Your Makefile has grown conditionals, string munging and shell quoting problems, and you would rather write the same logic as Python functions with real arguments
  • You run a lot of shell commands from Python and keep re-implementing the same subprocess wrapper: capture output but also stream it live, raise on failure, allocate a pty when the program demands one, answer an interactive prompt
  • You want project commands discoverable: inv --list prints every task with its docstring, and inv --help deploy prints the flags, so new contributors stop grepping CI config to find out how to build things
  • You need Fabric later: Fabric's remote Connection is an Invoke Context, so tasks written against c.run move to SSH execution without rewriting them
Skip it if

Setup reality

pip install invoke and you are done, because it has zero runtime dependencies and ships both the invoke and inv console scripts. Python 3.9 or newer as of 3.0. The friction is not installation, it is the conventions. Tasks must live in a file named tasks.py (or a tasks package) found by walking up from your working directory, so running inv from a subdirectory quietly picks up a different file than you expected. Every task function takes the context as its first positional parameter, and forgetting it produces a confusing arity error rather than a helpful message. Argument names get their underscores turned into dashes, so a parameter called dry_run becomes --dry-run on the command line and inv deploy --dry_run fails. Short flags are auto-generated from first letters and collide silently between two parameters starting with the same letter, at which point one of them just loses its short form. c.cd is not os.chdir: it prepends a cd command to each subsequent c.run string, so Python-level file operations inside the block still run in the original directory. Configuration is layered across invoke.yaml in the project, ~/.invoke.yaml, INVOKE_-prefixed environment variables, collection-level configure() calls and per-run keyword arguments, and working out which layer won requires reading the configuration chapter rather than guessing.

Patterns

The minimum tasks.pydefine-task

# tasks.py
from invoke import task

@task
def test(c):
    """Run the test suite."""
    c.run("pytest -q")

@task
def build(c, clean=False):
    """Build the wheel."""
    if clean:
        c.run("rm -rf dist build")
    c.run("python -m build")

# inv --list
# inv build --clean

The file must be named tasks.py and Invoke finds it by walking up from the current directory, so running inv from a nested package can pick up a parent project's tasks. The first parameter is the context and is not exposed as a flag; the docstring becomes the description in inv --list.

Turn parameters into flags with real help texttask-arguments

@task(help={
    "env": "Target environment (staging or prod)",
    "dry-run": "Print commands without running them",
})
def deploy(c, env="staging", dry_run=False, workers=2):
    print(f"{env} {dry_run} {workers}")

# inv deploy --env prod --dry-run --workers 4
# inv --help deploy

The default value picks the flag kind: False gives a boolean switch, a string gives a value flag, an int gives an int-cast value flag. Underscores become dashes on the command line and in the help dict keys, so help={"dry_run": ...} silently documents nothing. Auto short flags come from first letters, so dry_run and deploy_key would fight over -d and one loses.

Handle a failing command instead of crashinginspect-command-result

@task
def lint(c):
    r = c.run("ruff check .", warn=True, hide="both")
    if r.failed:
        print(f"ruff exited {r.exited}")
        print(r.stdout)
    else:
        print("clean")

Without warn=True a non-zero exit raises UnexpectedExit and kills the run. hide="both" stops the live streaming but the output is still captured on the Result; hide="out" and hide="err" hide one stream each. Note that r.stdout is captured even when it is also being streamed, so you never have to choose.

Run prerequisites and pass them argumentschain-tasks

from invoke import task, call

@task
def clean(c, docs=False):
    c.run("rm -rf build")
    if docs:
        c.run("rm -rf docs/_build")

@task(pre=[call(clean, docs=True)], post=[test])
def release(c):
    c.run("python -m build && twine upload dist/*")

call() is how you give a pre-task non-default arguments; passing the bare function uses its defaults. Pre-tasks are deduplicated per invocation, so a task named by two different parents runs once unless you pass --no-dedupe. There is no up-to-date check: pre-tasks run every single time, which is the main way Invoke differs from make.

Run commands in another directory or under a prefixchange-directory

@task
def frontend(c):
    with c.cd("web"):
        c.run("npm ci")
        c.run("npm run build")

@task
def legacy(c):
    with c.prefix("source .venv/bin/activate"):
        c.run("python manage.py migrate")

Neither of these changes the Python process. c.cd prepends cd web && to each c.run string and c.prefix prepends the given shell snippet, so open("package.json") inside the block still reads from the original directory. Both emit POSIX shell syntax and do not work on cmd.exe.

Group tasks into namespacesorganize-namespaces

# tasks.py
from invoke import Collection, task
from . import docs, db

@task
def test(c):
    c.run("pytest")

ns = Collection(test)
ns.add_collection(Collection.from_module(docs), name="docs")
ns.add_collection(Collection.from_module(db), name="db")
ns.configure({"db": {"url": "postgresql://localhost/dev"}})

# inv db.migrate
# inv docs.build

As soon as you define a variable named ns (or namespace) in tasks.py, Invoke uses it as the root and ignores every other @task in the module, so a task you forgot to add to the Collection disappears from inv --list with no warning. Sub-collection configuration is scoped: the db block above is visible as c.db.url inside db tasks only.

Pull settings out of invoke.yamlread-configuration

# invoke.yaml
run:
  echo: true
deploy:
  host: deploy@example.com

# tasks.py
@task
def push(c):
    c.run(f"rsync -a dist/ {c.deploy.host}:/srv/app/")

The lookup order runs from built-in defaults through the system file, the user file, the project invoke.yaml, INVOKE_-prefixed environment variables, runtime files passed with -f, and finally per-call keyword arguments. Environment variables only override keys that already exist somewhere in the config, and setting one that does not raises AmbiguousEnvVar or UncastableEnvVar rather than being ignored.

Answer a prompt or run a program that demands a terminalinteractive-programs

from invoke import Responder

@task
def unlock(c):
    responder = Responder(pattern=r"Passphrase:", response="hunter2\n")
    c.run("ssh-add ~/.ssh/id_ed25519", pty=True, watchers=[responder])

@task
def colors(c):
    c.run("pytest --color=yes", pty=True)

pty=True is what makes tools emit color and progress bars, but it merges stderr into stdout, so r.stderr comes back empty and any code branching on it breaks. Watchers see the raw stream including your own echoed input, so a naive pattern can match twice; use a FailingResponder when a wrong answer should abort rather than loop.

Set environment variables and run privileged commandsenvironment-and-sudo

@task
def migrate(c):
    c.run("alembic upgrade head", env={"DATABASE_URL": c.db.url}, echo=True)

@task
def restart(c):
    c.sudo("systemctl restart myapp")
# inv -p restart      # prompt for the sudo password
# or set sudo.password in ~/.invoke.yaml (chmod 600 it)

env merges into the inherited environment by default; pass replace_env=True for a clean one, which usually breaks PATH unless you set it yourself. c.sudo watches for the password prompt and feeds it in, so it needs the password in config or via -p; storing it in a project invoke.yaml puts it in version control, which is the wrong place.

Accept a flag more than onceiterable-flags

@task(iterable=["tag"], incrementable=["verbose"])
def publish(c, tag=None, verbose=0):
    tags = tag or []
    args = " ".join(f"-t {t}" for t in tags)
    c.run(f"docker build {args} .", echo=verbose > 0)

# inv publish -t latest -t v2.1 -vv

Names in iterable arrive as a list and default to an empty list when the flag is absent, so declaring tag=None and normalising with tag or [] avoids a mutable default. incrementable counts occurrences, giving you the -v -vv -vvv convention for free.

Unit test a task without running any commandstest-tasks

from invoke import MockContext, Result
from tasks import build

def test_build_cleans_first():
    c = MockContext(run={
        "rm -rf dist build": Result(""),
        "python -m build": Result("built"),
    })
    build(c, clean=True)
    assert c.run.call_count == 2

MockContext wraps run and sudo in unittest.mock objects so you get call assertions for free. Any command you did not supply a Result for raises NotImplementedError rather than shelling out, which is a deliberate guardrail: a typo in the expected command string fails loudly instead of running the real thing.

Ship your tasks as their own commandcustom-cli-binary

# myapp/cli.py
from invoke import Collection, Program
from myapp import tasks

program = Program(
    namespace=Collection.from_module(tasks),
    version="1.4.0",
)

# pyproject.toml
# [project.scripts]
# myapp = "myapp.cli:program.run"

Passing namespace explicitly turns off tasks.py discovery, so your binary always runs its own bundled tasks regardless of the working directory. It also removes the core --collection and --search-root flags, which is usually what you want for a distributed tool.

Alternatives

PackageRegistryPick it when
noxPyPIThe tasks you actually need are test runs across several Python versions in isolated virtualenvs, which Invoke does not manage
doitPyPIYou want make-style up-to-date checking so a task is skipped when its inputs have not changed
poethepoetPyPIYour project already has a pyproject.toml and you want the task list to live there instead of in a separate Python file
fabricPyPIThe same tasks need to run over SSH against remote hosts, which is Invoke plus a Connection object