Skip to content

Group Options

A group option is a flag declared once by a job group and available to every job beneath it. func deploy --env prod web run sets --env for deploy.web.run without that job — or any of its siblings — declaring the flag itself.

This is the shape every multi-level CLI converges on (docker --context, kubectl --namespace, gh --repo): the flags that describe where or how a whole family of commands runs belong to the family, not repeated on each member.

Declaring a group's options

Subclass GroupOptions and bind it to a group path:

jobs/_group.py
from typing import Annotated

from functualize.job import GroupOptions, Option


class DeployOptions(GroupOptions, group="deploy"):
    """Deploy-level flags."""

    env: Annotated[str, Option("-e", help="Target environment")] = "staging"
    dry_run: Annotated[bool, Option(help="Preview only")] = False

It is an ordinary Pydantic model: types, defaults, Field() constraints and Option() markers all work exactly as they do on a job's own config model.

The declaration is discovered by the same scan that finds your jobs. A module that contains only a declaration is fine, and so is an underscore-prefixed filename — jobs/_group.py is the conventional home, next to the jobs it covers.

One declaration per group path

Two classes bound to group="deploy" is an error at discovery time, not a last-one-wins. Importing a declaration into another module is not a second declaration — only the module that defines the class counts.

Receiving the values

Declare the class as a parameter on any job under that group:

jobs/web.py
from _group import DeployOptions

JOB_GROUP = "deploy.web"


def run(image: str = "nginx", opts: DeployOptions = None) -> str:
    """Deploy the web tier."""
    print(f"Deploying {image} to {opts.env} (dry run: {opts.dry_run})")
    return image

The engine constructs and injects opts on every run, so it is never None in practice — the default exists only so the function stays callable as plain Python. A job that does not care about the group's options simply does not declare the parameter.

A GroupOptions parameter is not the job's own config model. Its fields stay group-level flags and do not become options on the job itself, so func deploy web run --help lists only --image.

Where the flags go on the command line

Before the group segment they belong to, and before the job name:

$ func deploy --env prod web run          # ✅ --env belongs to `deploy`
$ func deploy web run --image custom      # ✅ --image belongs to the job
$ func deploy --env prod web run --image custom   # ✅ both

Position is what tells the two apart. A flag typed after the job name binds to the job, even when a group declares the same name — the same rule docker, kubectl and gh use. That is why the group's flag must come first:

$ func deploy web run --env dev           # ❌ `run` has no --env

Options are inherited down the path. A flag declared on deploy may be given at any point before its command is reached, and a nested group may declare a field of the same name to override its parent's for jobs beneath it.

Anything else mid-path is still an error, with the same message as before:

$ func deploy --nope x web run
Error: unknown option '--nope' before a command.

Discovering them

A group's listing documents its options, including the inherited ones:

$ func deploy
Usage: func deploy <command> [options]

Options:
  --env, -e TEXT           Target environment
  --dry-run, --no-dry-run  Preview only

Sub-groups:
  web

$ func deploy web          # inherited from `deploy`, and listed as such
Usage: func deploy web <command> [options]

Options:
  --env, -e TEXT           Target environment
  --dry-run, --no-dry-run  Preview only

Commands:
  run  Deploy the web tier.

func deploy --help prints the same listing.

Where the values come from

Each field resolves through the usual ladder, with the group path standing in for the job name:

Precedence Source Example
1 (highest) Runtime override rc.config.set("env", "prod")
2 The flag on the command line func deploy --env prod …
3 Environment variable DEPLOY__ENV=prod
4 Config file section [deploy] → env = "prod"
5 (lowest) The field's declared default env: str = "staging"

config.set() deposits an override: a value written during the run, which is where that run will then find it — above everything a source supplied, the command line included.

A dotted group path flattens for the environment variable and stays dotted for the config section: group="deploy.web" reads DEPLOY_WEB__ENV and [deploy.web].

One variable covers the whole group. DEPLOY__ENV is read by every job that declares DeployOptions, whatever that job is called and whatever group it sits in — there is no per-job variable to export. That is the difference from a job's own config field, which is scoped to the job that declares it:

Setting Variable Prefix
A group option DEPLOY__ENV the class's group=, double underscore
A field on a job's own config model DEPLOY_WEB_RUN_IMAGE the full job name, single underscore

The double underscore is what keeps a nested path unambiguous: DEPLOY_WEB_ENV cannot be told apart from group deploy carrying a field named web_env.

The variable is shared but the opt-in is per signature — a job sees the value only if it declares the parameter. Any job may declare the class, including one outside the group, which is how a job under check can read DeployOptions.

A flag beats the environment, matching how a job's own flag does — an exported default you cannot override from the command line would defeat the point of typing it.

None of this depends on the CLI. A job run from Python resolves the same file, environment and default layers:

app.execute("deploy.web.run")           # opts.env comes from file/env/default
rc.invoke("deploy.web.run")             # same

The mid-path flag layer belongs to the command line that typed it — and a caller who wants to pass one on now says so.

# Every executing door accepts one explicitly. This is how the CLI, HTTP,
# Lambda and MCP pass on the flags they parsed.
app.execute("deploy.web.run", group_option_values={"env": "prod"})

# `rc.invoke` starts no command line, so by default it passes none: a job
# invoked from inside another job resolves its group options from its own file,
# environment and default layers.
rc.invoke("deploy.web.run")

# ...and it can pass one deliberately, for the case where a parent really is
# steering a child at a path it knows about.
rc.invoke("deploy.web.run", group_option_values={"env": "prod"})

A @workflow step behaves like a bare rc.invoke: the walk runs each step as an ordinary job with no flag layer, so a step's group options come from file, environment and defaults.

The default did not change; the boundary did

Until surface-request-parity, rc.invoke could not pass the flag layer at all, and this guide recorded that as deliberate: a flag typed for deploy.web should not silently steer a job under deploy.worker.

That reasoning is still right about the default, and the default is unchanged — a bare rc.invoke inherits nothing, and there is a test that was written before the override existed to keep it that way.

What changed is the cost of the prohibition. Every door now builds one RunRequest, and group_option_values is a field on it. Withholding the field from one caller stopped being free and became a deliberate erasure — a line of code whose only job is to drop something the caller had. A boundary that costs code needs a better reason than that it used to cost nothing, and "the parent might mean a different path" is a reason to make the caller say it, not a reason to make it impossible.

So the answer is the same as everywhere else in the framework: explicit beats implicit. Nothing is inherited; anything passed is passed on purpose.

Steering a whole run from code

The file, environment and default layers are re-read on every job execution — nothing is snapshotted at boot. A value written before the run reaches a job is therefore picked up by that job, including jobs reached indirectly as @workflow steps, Deps upstreams or rc.invoke children:

import os

os.environ["DEPLOY__ENV"] = "prod"
app.execute("deploy.release")        # every step of the walk resolves env="prod"

The same works from inside a job that drives others. os.environ is process-global, so restore it when the value should not outlive the call:

@job(group="deploy")
def release(invoke: Invoke) -> None:
    previous = os.environ.get("DEPLOY__ENV")
    os.environ["DEPLOY__ENV"] = "prod"
    try:
        invoke("deploy.web.run")
    finally:
        if previous is None:
            os.environ.pop("DEPLOY__ENV", None)
        else:
            os.environ["DEPLOY__ENV"] = previous

The flag layer is the one that is not inherited, for the reason above:

# The workflow job is given env="prod". Its steps are not.
app.execute("deploy.release", group_option_values={"env": "prod"})

A step that should receive one is given it, explicitly, by the job that drives it — rc.invoke(..., group_option_values=...). The environment approach above remains the way to steer a whole run, including steps nobody names.

For a value that should always apply, prefer the config file section or an explicit ConfigSources(config_resolution_chain=...) over mutating the environment.

Two groups, one field

A job may declare more than one GroupOptions parameter — its group's and an ancestor's. When both declare a field of the same name and the user sets it once on the command line, both instances see that value: the merge is flat, so there is no way for two objects to disagree about a flag typed once.

Their non-CLI layers stay independent, since each class reads its own config section and environment prefix.

Over MCP

Group options appear in a job's MCP tool schema alongside its own parameters, with their descriptions and defaults, so an agent can set them exactly as a shell user can. They are never marked required — they always resolve to something. If a job declares a parameter with the same name as one of its group's fields, the job's own wins, the same way position decides on the command line.

See also