Jobs and Auto-Discovery¶
Functualize automatically discovers job functions from Python modules and registers them as CLI commands. This guide explains how the discovery mechanism works, what makes a function eligible for registration, and how to organize jobs into grouped sub-commands.
How auto-discovery works¶
When you create a FunctualizeApp, the job_sources parameter tells the framework where to look for job modules:
from functualize.app import FunctualizeApp, JobSources
app = FunctualizeApp(
name="my-app",
job_sources=JobSources(directories=["src/my_app/jobs"]), # (1)!
)
- You can pass multiple directories. Each one is scanned independently.
The discovery process uses Python's pkgutil.iter_modules to scan each directory. For every Python module found (.py files), the framework:
- Skips sub-packages — only top-level modules in the directory are scanned, not nested packages
- Imports the module — loads it so functions and module-level variables can be inspected
- Inspects all attributes — checks each public attribute against the eligibility criteria
- Registers qualifying functions — adds them as Click CLI commands
Import failures are non-fatal
If a module fails to import (syntax error, missing dependency, etc.), a warning is logged and discovery continues with the remaining modules. Your application won't crash because of one broken job file.
Job names are canonical¶
A job's name is derived from its function name and normalized to lowercase-hyphenated form — the spelling command-line tools conventionally use, and the one Click itself defaults to:
| You write | The job is | You run |
|---|---|---|
def data_sync() |
data-sync |
func data-sync |
def buildWheel() |
build-wheel |
func build-wheel |
JOB_GROUP = "data_ops" + def run_etl() |
data-ops.run-etl |
func data-ops run-etl |
Python identifiers cannot contain hyphens and command names conventionally do, so without a single canonical form the same job has two spellings and every consumer picks one.
Typing the Python spelling still works. func data_sync,
rc.invoke("data_sync") and Deps("data_sync") all reach data-sync. That is
normalization, not aliasing: there is one name, and you cannot miss it by
writing it the way Python spells it. func --help always shows the canonical
form.
Two places keep the underscored spelling for good reasons:
- Environment variables —
DATA_SYNC_BATCH_SIZE, because no shell can export a name containing a hyphen. - Config sections —
[data_sync]and[data-sync]are both read, so an existing config file keeps working.
Two functions whose names normalize to the same job (build_wheel and
buildWheel) are rejected at registration rather than one silently replacing
the other.
Function eligibility criteria¶
Not every function in a job module becomes a CLI command. A function is registered only if all of the following are true:
| Criterion | Description |
|---|---|
| Callable | The attribute must be callable |
| Is a function | Must be an actual function (inspect.isfunction), not a class or other callable object |
| No underscore prefix | The name must not start with _ — underscore-prefixed functions are treated as private |
| Defined in the module | The function must be defined in the scanned module, not imported from elsewhere |
The "defined in module" check prevents imported helper functions from accidentally becoming CLI commands. Only functions whose source module matches the scanned module are registered.
# jobs/data_tasks.py
from some_library import helper_function # (1)!
JOB_GROUP = "data"
def export(): # (2)!
"""Export data to CSV."""
print("Exporting...")
def _validate_row(row): # (3)!
"""Internal validation helper."""
return row is not None
class DataProcessor: # (4)!
"""Not registered — it's a class, not a function."""
pass
helper_functionis not registered — it's imported from another module.exportis registered — it's a public function defined in this module._validate_rowis not registered — it starts with an underscore.DataProcessoris not registered — it's a class, not a function.
When the built-in filters cannot describe your jobs¶
The require_* settings describe a module by its filename, its imports, a
marker, or a decorator. That covers most projects. It does not cover a host
whose jobs are, say, methods on a class, or are chosen by a rule that only that
host knows.
DiscoveryConfig.pre_filter takes your own predicate, asked before the module
is imported:
from pathlib import Path
from functualize.app import FunctualizeApp, JobSources
from functualize.app.config import DiscoveryConfig
class HasJobSuffix:
"""Admit a module only when its name ends in `_tasks` or `_ops`."""
def should_import(self, source_file: Path) -> bool:
return source_file.stem.endswith(("_tasks", "_ops"))
def fingerprint(self) -> str:
return "has-job-suffix:v1"
app = FunctualizeApp(
"myapp",
job_sources=JobSources(directories=["./jobs"]),
discovery_config=DiscoveryConfig(pre_filter=HasJobSuffix()),
)
Two things about it are worth knowing before you write one.
It composes; it does not replace¶
Your filter is ANDed onto the stack the require_* settings build. Setting
both means a module has to satisfy both:
DiscoveryConfig(
require_file_prefix="job_", # filename starts with job_
pre_filter=HasJobSuffix(), # ...and ends with _tasks or _ops
)
It runs last, after the cheap filename checks, so an expensive predicate sees the fewest possible files.
fingerprint() is not optional¶
The discovery cache stores which files your filter rejected and replays those decisions on the next boot, so a scan does not re-read a file it already ruled out. That is only safe while the cache can tell that your filter still behaves the same way.
fingerprint() returns a stable string that you change when the filter's logic
changes. Return the same value across processes for the same behaviour, and a
new value when you edit the predicate:
Identity cannot be used instead. An object's str() carries its memory
address, so a digest built from the object itself would differ on every boot —
invalidating the cache on every run while appearing to work. A filter with no
fingerprint() is refused with a TypeError rather than silently cached
wrong.
Forgetting to bump it gives you a stale cache: the same contract, and the same
failure mode, as editing a require_* setting without invalidating.
lazy=False applies no discovery filter at all
JobSources(lazy=False) opts out of the cached provider and, with it, out
of filtering entirely — not just pre_filter, but exclude_patterns and
every require_* setting too. This is a known defect, not a design; the
default (lazy=True) is the filtered path.
The protocol¶
ModulePreFilter is exported from functualize.plugin if you want to declare
the type. There is nothing to inherit — it is satisfied structurally:
from functualize.plugin import ModulePreFilter
def check(candidate: ModulePreFilter) -> bool:
return isinstance(candidate, ModulePreFilter) # both methods required
JOB_GROUP and sub-command grouping¶
The JOB_GROUP module-level variable controls how functions are organized in the CLI hierarchy.
With JOB_GROUP (grouped)¶
When a module defines JOB_GROUP, all qualifying functions in that module are registered under a Click sub-command group named after the JOB_GROUP value:
# jobs/reporting.py
JOB_GROUP = "report" # (1)!
def generate(format: str = "pdf"):
"""Generate a report."""
print(f"Generating {format} report...")
def send(recipient: str = "team@example.com"):
"""Send the latest report."""
print(f"Sending report to {recipient}...")
- All public functions in this module become sub-commands under
my-app report.
This produces the following CLI structure:
Without JOB_GROUP (top-level)¶
When a module does not define JOB_GROUP, its qualifying functions are registered as top-level commands on the main application:
# jobs/health.py
# No JOB_GROUP defined
def ping():
"""Check if the service is alive."""
print("pong")
This registers ping directly on the app:
Multiple modules sharing the same JOB_GROUP¶
Multiple job modules can share the same JOB_GROUP value. Their functions are all registered under the same sub-command group:
Both export and load appear under the data sub-command group:
Organizing large projects
Splitting related functions across multiple files while sharing a JOB_GROUP keeps individual modules focused and manageable, while presenting a unified command group to users.
Duplicate command detection¶
When a command name is already registered at the same level (either within the same group or at the top level):
This can happen when:
- Two modules with the same
JOB_GROUPboth define a function with the same name - Two modules without
JOB_GROUPboth define a function with the same name
Within one directory, the first module discovered wins (filesystem order
from pkgutil.iter_modules) and the duplicate is not registered. Duplicate
job names across providers raise a ValueError at registration. Do not
rely on discovery order — rename one of the functions.
Minimal job file example¶
Here's a complete, minimal job file with annotations showing which elements are required for discovery:
"""Sample job module demonstrating auto-discovery requirements."""
JOB_GROUP = "sample" # (1)!
def run(target: str, dry_run: bool = False): # (2)!
"""Execute the sample job. # (3)!
Args:
target: The target resource to process.
dry_run: If True, simulate without making changes.
"""
print(f"Starting sample job with target: {target}")
if dry_run:
print("Dry run mode — skipping actual processing")
print("Job completed successfully")
JOB_GROUP— Groups this module's functions under thesamplesub-command. Remove this line to register functions at the top level instead.- Public function — Must be a function (not a class), must not start with
_, and must be defined in this module. Parameters with type annotations become Click CLI options automatically. - Docstring — Used as the command's help text in
--helpoutput.
This produces:
$ my-app sample run --help
Usage: my-app sample run [OPTIONS]
Execute the sample job.
Options:
--target TEXT The target resource to process. [required]
--dry-run / --no-dry-run
If True, simulate without making changes. [default: no-dry-run]
--help Show this message and exit.
Job Metadata¶
@job attaches structured metadata to job functions. It is available via JobDescriptor.declaration and useful for AI orchestrators, schema exporters, and documentation generators.
from functualize.job import job
from functualize.job import RunContext
JOB_GROUP = "deploy"
@job(
extra_description="Deploy the application to the specified environment",
category="deployment",
examples=["deploy --env production", "deploy --env staging --dry-run"],
tags=["deploy", "infrastructure", "production"],
)
def run(rc: RunContext) -> None:
"""Deploy the application."""
...
Identity and description parameters¶
| Parameter | Type | Description |
|---|---|---|
group |
str \| None |
Overrides the module-level JOB_GROUP |
extra_description |
str \| None |
Description beyond the docstring summary, for AI/LLM consumption |
category |
str \| None |
Grouping category for job organization |
examples |
tuple[str, ...] \| list[str] |
Usage examples |
tags |
tuple[str, ...] \| list[str] |
Searchable tags; also drives func mcp serve --include-tags |
visibility |
"external" \| "internal" |
"internal" hides the job from MCP. Defaults to "external" |
config_section |
str \| None |
Config section this job reads |
@job also carries the operational contract — deps, cache, guards, exec — as self-validating value objects. See the @job API reference.
The declaration is stored as a frozen JobDeclaration on the function's __functualize_job__ attribute and incorporated into the JobDescriptor during registration. It round-trips through the discovery cache, so warm boot has it without importing the module.
Replaces @job_metadata
The @job_metadata decorator has been removed. Its ai_description argument is now extra_description; category, examples, tags and visibility keep their names.
Composable with other decorators
@job can be combined with any other decorators in any order. It does not wrap the function — it only attaches an attribute, so decorated is original always holds.
JobDescriptor Retention¶
After registration, every job's JobDescriptor is retained and accessible for runtime introspection:
# From application code
descriptors = app.job_registry.get_descriptors() # All registered descriptors
descriptor = app.job_registry.get_descriptor("data-sync") # Single descriptor by name
print(descriptor.name) # "data-sync"
print(descriptor.group) # "data" or None
print(descriptor.config_schema) # Pydantic model class or None
print(descriptor.metadata) # JobMetadataAnnotation or None
From within a job, use rc.discovery.get_job_schema(job_name) to introspect sibling jobs:
def orchestrator(rc: RunContext) -> None:
schema = rc.discovery.get_job_schema("validate-data")
rc.log(f"Job has {len(schema.config_schema.model_fields)} config fields")
This enables patterns like dynamic orchestration, job discovery UIs, and AI-driven job selection.
Next steps¶
- JobConfig with Pydantic — Add typed, validated configuration to your jobs with automatic CLI option generation
- RunContext Lifecycle — Inject runtime context with lifecycle hooks into your job functions
- Configuration System — Understand how config values are resolved from multiple sources