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:
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:
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:
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:
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¶
- Jobs and Auto-Discovery — how
JOB_GROUPforms a group - JobConfig with Pydantic — the job's own config model
- Configuration System — the resolution chain in full