Architecture and Design¶
This document describes the internal architecture and design principles of invoke-tasklib.
Overview¶
invoke-tasklib is designed around a simple idea: task modules are plain
Invoke collections that share one config-resolution function. The
core design follows these principles:
- One config entry point: every task reads project-specific values through
invoke_tasklib.config.get_config, never directly fromc.config. - Convention over configuration: sensible defaults derived from
package.namemean most projects only set one value (tasklib.package.name). - Composability: the pre-built
nsnamespace is a convenience; any subset of task modules can be imported and combined with project-specific tasks. - No hidden state: tasks only run external commands (
ruff,pytest,uv,mike, ...); they don't maintain state between invocations.
Core Components¶
1. Config Resolution¶
invoke_tasklib.config.get_config(c) is the single source of truth for project-specific values.
It:
- Reads the
tasklibkey from the Invoke config (c.config), which Invoke itself populates frominvoke.yaml. - Merges it on top of
DEFAULT_PACKAGE/DEFAULT_PATHS. - Derives unset
paths.*entries frompackage.name(forpaths.src) orpaths.tests(for theunit_tests/integration_tests/functional_tests/benchmarksentries). - Raises
ValueErrorifpackage.nameis missing, since nearly every task needs it.
class TasklibConfig(TypedDict):
package: PackageConfig
paths: PathsConfig
Returning a TypedDict rather than a raw dict gives task authors static typing on
cfg["package"]["name"] and similar accesses.
2. Task Modules¶
Each task module (format.py, lint.py, types.py, test.py, env.py, release.py, doc.py)
is a flat collection of @task-decorated functions. A module groups tasks that operate on the
same underlying tool or concern (e.g. most of test.py wraps pytest, except
test.doctest-markdown, which wraps the standard library's doctest module directly).
Tasks that need project-specific values call get_config(c) at the top of the function body;
tasks that don't (e.g. lint.check_lint) skip it entirely.
3. The ns Namespace¶
invoke_tasklib/__init__.py builds a single Collection (ns) by adding each task module as a
sub-collection, named after the module:
ns = Collection()
ns.add_collection(Collection.from_module(format), name="format")
...
This is what gives consuming projects format.check-python, test.unit, etc. via a single
from invoke_tasklib import ns in their tasks.py.
Data Flow¶
Here's how a task invocation flows through the system:
User runs `invoke test.unit --cov`
↓
Invoke loads invoke.yaml into c.config
↓
test.unit(c, cov=True) is called
↓
get_config(c) resolves package.name and paths.unit_tests
↓
Task builds the pytest command line
↓
c.run(cmd, pty=True) executes it as a subprocess
Design Decisions¶
Why a Shared get_config Instead of Per-Task Config?¶
Rationale: A single resolution function keeps defaults and validation in one place, and
guarantees every task sees the same resolved paths (e.g. test.unit and types.check agree on
package.name).
Trade-off: Every task pays the (cheap) cost of re-resolving config on each call, rather than
resolving it once. This is negligible since get_config does no I/O beyond reading c.config.
Why check_<target>/fix_<target> Naming?¶
Rationale: Makes the read-only/mutating counterpart of a task discoverable by name alone, and
lets CI safely run every check_* task without risk of it modifying the working tree.
Trade-off: Slightly more verbose task names than a single format task with a --fix flag,
but the split keeps invoke --list self-documenting.
Why TypedDict for Config?¶
Rationale: Static type checking (via pyright) catches typos like cfg["path"]["src"] at
development time, without requiring a runtime schema validation dependency.
Trade-off: TypedDict doesn't validate values at runtime beyond what get_config does
explicitly (e.g. the package.name check).
Testing Strategy¶
Tests use invoke.context.MockContext, which records every c.run(...) call without executing
it. This lets tests assert on the exact shell command a task would run, without depending on
ruff, pytest, uv, etc. being installed or configured in the test environment.
Future Directions¶
Potential areas for enhancement:
- Additional task modules for other common project needs (e.g. changelog generation).
- A
--config-schematask to validate a project'sinvoke.yamlagainst the config schema ahead of time.