Skip to content

Task Runner — @job, Dependencies, and Fingerprints

The task runner turns your job functions into build-tool-style targets: declare dependencies, guard execution with preconditions, cache results with fingerprints, and retry on failure.

Quick Start

Basic @job decorator

from functualize.job.decorators import job
from functualize.job import Log

@job(extra_description="Deploy the application.")
def deploy(log: Log):
    log("Deploying...")

With dependencies

from functualize.job.decorators import job, Deps

@job(deps=Deps("lint", "test"))
def build(sh: Shell, log: Log):
    log("Building after lint and test pass...")
    sh(["uv", "build"])

Deps run first, in dependency order. By default, if any dep fails, downstream jobs are skipped (Deps.policy="fail-fast"). Use Deps(policy="keep-going") for Make-style -k behavior.

With fingerprint caching

from functualize.job.decorators import job, Fingerprint

@job(cache=Fingerprint(sources=["src/**/*.py", "pyproject.toml"]))
def build(sh: Shell):
    sh(["uv", "build"])

The build only re-runs when its source files change. The fingerprint includes the file hashes and the job's resolved config, plus any arguments the caller passed — the same job with different arguments gets different fingerprints. Framework-injected parameters (Log, Shell, Sources, …) are excluded: they are not part of the call's meaning, and a live object's repr is not stable between processes.

Four behaviours worth knowing before you rely on this:

  • A declared output that is missing forces a run. generates is part of the freshness question, not decoration — a job is not up to date if the artifact it promised to produce is not there.
  • sources and generates are both glob patterns, and a pattern that matches nothing counts as missing:
@job(cache=Fingerprint(sources=["src/**/*.py"], generates=["dist/*.whl"]))
def build(sh: Shell): ...

dist/*.whl is a declaration about a wheel whose version is not known in advance. With dist/ empty, the job is not fresh — same verdict as a missing literal path, because "the promised artifact is not there" is one fact however it was spelled. - Declared paths may live anywhere. Absolute patterns, ../ patterns and patterns reaching through a symlinked directory are all declarable. Each path is recorded as written, so an absolute one will not match on a teammate's machine and their first run re-runs the job once — nothing breaks, the work is just not shared. See ADR-013. - Declared inputs that resolve to nothing refuse the run (exit 3), rather than reporting "0 sources unchanged, up to date". A stage cannot certify success having verified nothing. Declaring no sources is different and is unaffected.

Where the freshness ledger lives

Fingerprints are stored in a runtime state store, and there are two modes. Which one you are in depends on a single directory:

Mode When Where the ledger lives
project a .functualize/ directory is found, walking upward from the working directory <that directory>/fresh.json — inside your project, alongside the code it describes
standalone no .functualize/ directory anywhere above you $XDG_CACHE_HOME/functualize/<project-id>/fresh.json — a hashed directory under your home cache

func is meant to run over loose scripts anywhere on the filesystem, so standalone is the fallback rather than the failure: littering a .functualize/ beside every one-off script would be worse than a keyed cache directory.

A substrate plugin changes the file, not the directory. The table above is the default layout, where each store is its own JSON file. Install a StoreSubstrate — functualize-substrate-sqlite, say — and every store moves into one database in the same directory, so the ledger is reported as .functualize/state.db#fresh rather than .functualize/fresh.json. The mode still decides which directory; the substrate decides what sits in it. Read the path the command prints rather than assuming either form.

mkdir .functualize is the switch. Do it when you want the ledger versioned with the project, shared by everyone working in it, or simply findable — after which rm -rf .functualize is a full reset.

Both commands tell you which mode you are in and where the file actually is:

func builtin data show     # State path + Mode
func builtin info           # the same two facts, beside config resolution

Worth checking before concluding a job "won't re-run": in standalone mode the file you are looking for is under a hashed directory you have never seen.

Reading the inputs you declared

The glob you declare is expanded on every run to decide freshness. Read the result rather than restating it:

from functualize.job import Fingerprint, Sources, job

@job(cache=Fingerprint(sources=["src/**/*.yaml"], generates=["out/parsed.json"]))
def parse(sources: Sources) -> None:
    for path in sources.keys():           # project-relative POSIX paths
        text = Path(path).read_text()
    entry = sources["src/app.yaml"]        # {"mtime": float, "size": int, "sha256": str}

Restating the glob in the body is how the freshness check and the work drift apart. sources.declared tells "declared no sources" apart from "declared sources that matched nothing"; sources.generates carries the declared outputs. See ADR-012.

Deciding your own freshness

A fresh job is skipped: the engine decides that before your body is called, so the body never runs. That is the right default, and it is the wrong answer for a job that produces an artifact — for such a job, "I am already current" means "hand back what I built", not "do nothing".

Fingerprint(decides=True) moves that decision into the job. The body is entered when the verdict says SKIP_FRESH, and reads the verdict through a Freshness parameter:

from pathlib import Path

from functualize.job import Fingerprint, Freshness, Log, Sources, job

ARTIFACT = Path("build/report.json")   # your format, your location


@job(
    cache=Fingerprint(
        sources=["inputs/*.md"],
        generates=["build/report.json"],
        decides=True,
    ),
)
def report(fresh: Freshness, sources: Sources, log: Log) -> None:
    verdict = fresh.verdict()
    if verdict is not None and verdict.is_fresh:
        log(f"up to date under {verdict.key} — returning the artifact")
        print(ARTIFACT.read_text())
        return
    # ...the real work, then write ARTIFACT...

Two runs tell the whole story:

$ func lab report
rebuilt build/report.json from 2 declared inputs
BUILT built=836e97a7 state=run

$ func lab report
up to date under lab.report::fa723457…::checksum — returning the artifact
CACHED built=836e97a7 state=skip_fresh

The second run entered the body (state=skip_fresh), read the verdict and returned the artifact it had already built — the same built token, no rebuild. Without decides=True that second run would have printed nothing at all.

The verdict is the decision itself, not a summary of it: state (GuardState), key, recorded_value, declared_sources, declared_generates, source_map, and is_fresh — true for SKIP_FRESH alone. verdict() returns None only when the job declares no Fingerprint: there was no decision, and a fabricated one would be a lie.

func builtin why <job> renders that same GuardState, so a person and their job cannot disagree about a run:

$ func builtin why lab.report
lab.report → SKIP (up to date)
  fingerprint  2 sources unchanged

Worth knowing before you rely on it:

  • The framework never reads your artifact. It knows the path only because you declared it under generates, and all it asks is whether that path exists. Rewrite build/report.json by hand and the job is still fresh. The format, the location and the retention are yours; the decision about your declared inputs is the framework's.
  • The body runs on every invocation while your inputs are unchanged. A decided run does not rewrite its fingerprint record — the decision was discarded, exactly as --force-fresh discards it. Reading the verdict is cheap; that is the trade, and the body is what makes it pay.
  • It is not a cache. No artifact is stored, content-addressed or evicted on your behalf; the verdict is the contract and the storage is yours (contributor/architecture/run-model/11-boundaries.md §B, N1).
  • Reading a fresh verdict does not oblige you to skip. A job that is fresh and cheap can do the work anyway.
  • It is not a way to report a skip. An opted-in run that runs is recorded as having run — "the framework skipped me" and "I ran and decided" stay distinguishable in history.
  • Its scope is SKIP_FRESH only. A satisfied status guard, a failing Precondition (still exit 3) and a blocking gate (still exit 5) all stand, exactly as they do for force_fresh.

The worked example — including the control job that does not opt in, and a test that hand-edits the artifact to show the framework never reads it — is examples/standalone/freshness_lab/.

With guards

from functualize.job.decorators import job, Guards, Exec

@job(guards=Guards(preconditions=["docker_running"]), exec=Exec(platforms=["linux", "darwin"]))
def deploy_docker(sh: Shell):
    sh(["docker", "compose", "up", "-d"])

Guards are checked before dependencies. The pipeline is: platforms → preconditions → status → fingerprint (platforms are read from Exec, not Guards). If Docker isn't running the job refuses immediately — RunStatus.REFUSED, exit 3 — rather than failing after an opaque error from the shell. Exit 3 is distinct from exit 1 on purpose: nothing ran and nothing raised, so a caller can tell "I declined to start" from "the body threw".

Value Objects Reference

All operational concerns are grouped into typed value objects:

Deps

Deps("lint", "test", policy="fail-fast")
  • policy="fail-fast" (default): stop on first failure
  • policy="keep-going": run everything not downstream of a failure (like make -k)

Fingerprint

Fingerprint(sources=["src/**/*.py", "pyproject.toml"], method="checksum")
  • sources: glob patterns. Hashed with resolved config/args for the composite key.
  • method: "checksum" (default), "timestamp", or "none".
  • decides: False (default). True means "when I am fresh, enter my body and let me decide" — the body reads its verdict through a Freshness parameter and may return its cached artifact instead of rebuilding. See Deciding your own freshness.

Guards

Guards(preconditions=["docker_running", "k8s_connected"])
  • preconditions: registered checks (session-cached).
  • status: named status checks.

Exec

Exec(platforms=["linux", "darwin"], run="when_changed", retry=Retry(attempts=3))
  • run: "always" (default), "once" (deduplicates within a session), "when_changed" (deduplicates only with identical resolved args).
  • platforms: sys.platform prefix match — a mismatch refuses (exit 3).
  • retry: Retry(attempts, backoff=..., on=(...), on_exit_codes=(...)).

Inspecting Why a Job Will Run

$ func builtin why build

build
  platforms  ✓ linux
  preconditions  docker: ✓
  fingerprint  src/**/*.py: 3 files changed (a.py, b.py, c.py)
  deps  lint ✓ fresh · test ✗ stale → will run first

Use func builtin why to see guard results, fingerprint freshness, and which dependencies will re-run. On any job run, --explain prints the same verdict without executing.

State Management

The state store holds fingerprints, guard results, and execution history. Its location depends on the mode described in Where the freshness ledger lives — func builtin data show prints the resolved path:

func builtin data clear    # Clear derived state (fingerprints, history, preconditions)
func builtin data clear --scopes   # ...and discard in-flight workflow runs too
func builtin cache clear            # Clear discovery cache (job metadata)

These are independent — state clear doesn't touch the cache; cache clear doesn't touch state.

Workflow scopes live in their own file, .functualize/scopes.json, and state clear keeps them — it tells you how many it kept. A scope is a run somebody is waiting on, including any gate input they already approved, so discarding one takes --scopes, and even then the file is moved aside rather than deleted.

Parallel Execution

func builtin parallel lint test typecheck --output grouped

Runs jobs concurrently with a bounded thread pool. Output modes:

Mode Behavior
interleaved Streams mixed output in real time
grouped Buffers per-job output, emits on completion with CI group markers
prefixed Each line prefixed with [job_name]

Pipeline Mode

Jobs with a Stdout capability act as Unix pipeline stages:

func build --emit-format ndjson | jq '.targets'

func build emits NDJSON; jq processes it. The exit code table propagates through the pipeline:

Code Meaning
0 success — and skipped: a guard saying "nothing to do" did what was asked
1 the job body raised
2 usage or config error
3 refused — a declared precondition for running was not met: a Precondition failed, or Fingerprint(sources=…) resolved to no files
4 stale-check failure (--check)
5 blocked awaiting gate input — ran successfully and is resumable

3 and 5 are deliberately different: a workflow paused at a gate ran and can be resumed; a refusal never started. The same code for both would force every caller to parse stderr.

See Also

  • Composing Capabilities — how this fits with the other capabilities: a combination matrix of what happens at each intersection, and the traps between them
  • Freshness Lab — the worked example for a job that caches its own artifact, with the control job that does not
  • Shell Capability Guide — running external commands with lifecycle management
  • Workflows Guide — multi-step DAGs with gates and conditional branching