Task Pydantic Models Guide¤
This guide defines the model style for NORFAB worker tasks. It is written for agentic AI contributors: follow the checklist, keep changes scoped, and ask when a type or validation rule is ambiguous.
Goal¤
Every @Task(...) worker function should have explicit Pydantic models for:
- Input: all task kwargs accepted by the public task API.
- Output: the returned
Resultshape, including a typedresultpayload when practical.
These models are used by worker runtime validation, FastAPI/OpenAPI schema generation, MCP/agent tool schemas, docs generation, and later PICLE shell models. PICLE builds interactive command trees from Pydantic models, so field names, aliases, descriptions, defaults, and validators must be CLI-friendly.
Placement¤
Keep all service task models in a dedicated <service_name>_models.py module inside the service worker package, and import them into the respective task modules.
This is a mandatory style requirement: do not define task-specific input, output, or payload models inside task implementation modules. The per-service model module is the canonical home for task models, common worker arguments, shared enums, response payload models, and any other Pydantic models consumed by that service.
Preferred module layout:
# norfab/workers/netbox_worker/netbox_models.py
class GetThingsInput(NetboxCommonArgs, use_enum_values=True, populate_by_name=True):
...
class ThingPayload(BaseModel):
...
class GetThingsResult(Result):
result: dict[StrictStr, ThingPayload] = Field(
{},
description="Thing data keyed by device name",
)
# norfab/workers/netbox_worker/<feature>_tasks.py
from norfab.workers.netbox_worker.netbox_models import (
GetThingsInput,
GetThingsResult,
)
class NetboxThingTasks:
@Task(
input=GetThingsInput,
output=GetThingsResult,
fastapi={"methods": ["GET"], "schema": NetboxFastApiArgs.model_json_schema()},
)
def get_things(...):
...
Use existing examples in service model modules such as netbox_models.py for naming and validator style. Keep service model modules organized with short section headers when a service has many task groups, for example common arguments, device models, IPAM models, and auth models.
Input Models¤
Model names should be <TaskNamePascalCase>Input, for example CreatePrefixInput for create_prefix.
Rules:
- Include every public task kwarg except
self,job,progress, internal-only kwargs,*args, and**kwargs. - Inherit common worker arguments where appropriate, for example
NetboxCommonArgs. - Define task input models with
use_enum_values=Trueandpopulate_by_name=True. - Do not use
Optional. For nullable fields useUnion[None, T]. - Prefer strict scalar types:
StrictStr,StrictInt,StrictBool,StrictFloat. - Prefer concrete collections:
list[StrictStr],dict[StrictStr, Any],list[dict[StrictStr, Any]]. - Use
Field(...)for required values andField(default, ...)for optional values. - Put useful one-line
descriptiontext on every field. This becomes API, MCP, agent, and PICLE help text. - Mention units in field descriptions when known, for example
Timeout in seconds. - Put additional usage samples in the
Field(..., examples=[...])attribute instead of expanding the description. - Use aliases for CLI/API names that should contain hyphens, and enable
populate_by_name=True. - For boolean CLI presence flags, add
json_schema_extra={"presence": True}when the shell should treat the field as a flag. - If unsure about a type or acceptable values, stop and ask. Do not guess for NetBox object payloads, polymorphic input, or state-changing tasks.
Example:
class SyncDeviceIpInput(NetboxCommonArgs, use_enum_values=True, populate_by_name=True):
devices: Union[None, list[StrictStr]] = Field(
None,
description="List of NetBox device names to sync IP addresses for",
)
create_prefixes: StrictBool = Field(
True,
description="Create missing NetBox prefixes for discovered IP addresses",
alias="create-prefixes",
json_schema_extra={"presence": True},
)
filter_by_name: Union[None, StrictStr] = Field(
None,
description="Glob pattern to restrict interfaces by name",
alias="filter-by-name",
examples=["Loopback*", "Ethernet*", "Port-Channel*"],
)
Output Models¤
Model names should be <TaskNamePascalCase>Result.
Rules:
- Inherit from
norfab.models.Result. - Override
resultwith a typed payload whenever the task result shape is stable. - Create small payload models for repeated objects or nested structures.
- Keep dynamic NetBox API dictionaries as
dict[StrictStr, Any]when the schema is intentionally pass-through. - Keep output models simple unless the result shape is fully known; prefer
dict[StrictStr, Any]over complex unions that guess every possible branch. - Do not use
default_factory; use the local model style with direct defaults such as{}or[]. - If the task returns different dry-run and commit payload shapes, either model both explicitly with
Union[...]or use a named payload with broad fields and document the variant. - If an exact output shape depends on NetBox plugins, OpenAPI schemas, or arbitrary GraphQL fields, use
dict[StrictStr, Any]and explain why in the field description.
Example:
class SyncActionsPayload(BaseModel):
created: list[StrictStr] = Field([])
updated: list[StrictStr] = Field([])
in_sync: list[StrictStr] = Field([])
class SyncDeviceIpResult(Result):
result: dict[StrictStr, SyncActionsPayload] = Field(
{},
description="IP sync actions keyed by device name",
)
Validators¤
Use validators when fields depend on each other or when a field accepts more than one ergonomic input form.
Good uses:
- Require at least one selector, for example
devices,filters, orquery. - Reject invalid combinations, for example host prefix plus peer IP creation.
- Normalize CLI-friendly strings into lists where the task already supports that behavior.
- Validate mutually exclusive fields, such as
object_idversusfilters.
Keep validation close to the model and prefer model_validator(mode="after") for cross-field checks.
@model_validator(mode="after")
def validate_selectors(self) -> "GetInterfacesInput":
if not self.devices and not self.filters:
raise ValueError("Provide devices or filters")
return self
PICLE Readiness¤
PICLE uses Pydantic models to build interactive shell commands, completions, validation, and inline help. Design task models so they can be reused or mirrored in norfab/clients/nfcli_shell/.
Checklist:
- Reuse task input models in PICLE shell models whenever possible by inheriting from the task input model.
- Do not redeclare fields already provided by the task input model unless the shell needs a different CLI shape, enum, source method, or output behavior.
- Put CLI-only overrides in the shell model and keep them minimal.
- Field names should be readable as command words.
- Use aliases for user-facing hyphenated command arguments, for example
interface-regex. - Descriptions should be one-line, short, and actionable.
- Prefer enums for small fixed value sets.
- Add source methods only when completion data can be fetched cheaply and safely.
- Avoid deeply nested required objects for common CLI workflows; provide bulk/list fields only where the task naturally needs them.
- Keep model defaults aligned with task function defaults.
Practical PICLE reuse pattern:
class GetThingsShell(
NetboxClientRunJobArgs,
GetThingsInput,
use_enum_values=True,
populate_by_name=True,
):
devices: Union[StrictStr, list[StrictStr]] = Field(
None,
description="Device names to query",
)
Only override devices here because the shell accepts a single command value as well as a list. Leave inherited fields such as dry_run, branch, timeout, and filters alone unless the CLI must parse comma-separated strings, JSON strings, use a PICLE enum, or add a completion source.
NetBox Session Tips¤
NetboxCommonArgsandNetboxFastApiArgsshould also carryuse_enum_values=Trueandpopulate_by_name=Truebecause many task and shell models inherit them.- CRUD shells should inherit CRUD task args and only override CLI string forms such as JSON
data, JSONfilters, comma-separatedfields, or comma-separatedobject-id. - For GraphQL, REST, OpenAPI, plugin, cache, and broad NetBox API passthrough results, use
dict[StrictStr, Any]unless the shape is fixed in code. - Dynamic generated model builders should follow the same
Union[None, T]and no-default_factorystyle when practical. - If a shell model inherits any
*Inputor*Argsmodel, adduse_enum_values=Trueandpopulate_by_name=Trueto the shell class too. - After
compileall, remove generated__pycache__files before finishing unless they were already intentionally tracked.
Agent Checklist¤
When adding or auditing task models:
- Read
CLAUDE.md. - List all task functions with
@Task(...). - For each task, compare the function signature with the
input=model. - Add or update the input model only after confirming each field type, default, alias, and validation rule.
- Add or update the output model; use typed payloads for stable results and documented dictionaries for dynamic results.
- Update the decorator with
input=<InputModel>andoutput=<ResultModel>. - Keep task-specific models in the service's dedicated
<service_name>_models.pyfile and import them into task modules. - Update related PICLE shell models to inherit the task input model and remove overlapping field definitions.
- Run focused validation: import/generate schemas when dependencies are available, otherwise use AST audits and
compileall. - Do not refactor unrelated code while adding models.
Useful audit command:
rg -n "@Task\\(" norfab/workers
Useful AST audits:
python - <<'PY'
import ast
from pathlib import Path
missing = []
for path in Path("norfab/workers/netbox_worker").glob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
for dec in node.decorator_list:
if isinstance(dec, ast.Call) and getattr(dec.func, "id", "") == "Task":
keys = {kw.arg for kw in dec.keywords}
if "input" not in keys or "output" not in keys:
missing.append((str(path), node.name))
print(missing)
PY
python -m compileall -q norfab/workers/netbox_worker norfab/clients/nfcli_shell/netbox
rg -n "Optional\\[|default_factory" norfab/workers/netbox_worker norfab/clients/nfcli_shell/netbox
Common Pitfalls¤
- Do not rely on the dynamic model generated by
Task.make_input_modelfor production tasks. - Do not define Pydantic task models in task implementation modules; place them in the service's dedicated
<service_name>_models.pyfile. - Do not use
Optional; useUnion[None, T]. - Do not leave untyped
listordictunless the payload is intentionally arbitrary. - Do not invent NetBox payload schemas when task behavior passes data through to NetBox; ask or model as
dict[StrictStr, Any]with a clear description. - Do not make PICLE-only choices that break Python API compatibility.
- Do not change task behavior while adding validation unless the change is explicitly approved.
- Do not leave PICLE shell fields duplicated after inheriting the task input model; duplicates drift quickly.