Best Practices
Guidelines for writing maintainable, reliable function blocks.
Naming and Packaging
Use descriptive names that communicate what the block does, not how it does it.
| Aspect | Convention | Example |
|---|---|---|
| Block name | Lowercase, underscore-separated verb-noun | config_backup, interface_compliance_check |
| Package | fb.{team}.neops.io |
fb.netops.neops.io |
| Version | Semantic versioning tuple | (1, 0, 0) for initial release |
Increment the major version when you change the parameter or result schema in a breaking way. Increment minor for new optional parameters. Increment patch for bug fixes.
File Structure
Keep one function block per file (or a closely related pair, like a check and its remediation). A typical file contains:
- Parameter model (
FunctionBlockParamssubclass) - Result model (
FunctionBlockResultDatasubclass) - Registration decorator
- Function block class
Keep the run() method focused. If it grows beyond ~40 lines, extract helper methods. Use @run_in_thread for any extracted method that performs blocking I/O.
Logging
The framework provides self.logger on every function block instance. It is a ContextualLogger backed by loguru.
Structured context
Use with_context() to attach key-value pairs that appear in every subsequent log line:
log = self.logger.with_context(device=device.hostname, ip=device.ip)
log.info("Starting configuration backup")
log.warning("Timeout on first attempt, retrying")
Log levels
| Level | When to use |
|---|---|
debug |
Detailed diagnostic data (parameter values, intermediate results) |
info |
Normal operation milestones (connected, collected N lines, completed) |
warning |
Recoverable issues (retry succeeded, fallback used, deprecated parameter) |
error |
Failures that cause success=False (connection refused, validation failed) |
Avoid logging at error level and then returning success=True — this creates confusing signals for operators.
Parameter Design
Defaults and optionality
Make parameters optional with sensible defaults whenever the common case does not require user input:
class ComplianceCheckParams(FunctionBlockParams):
severity_threshold: str = "warning"
include_interfaces: bool = True
custom_rules: list[str] | None = None
Validation
Use Pydantic validators to catch bad input early:
from pydantic import field_validator
class DeployParams(FunctionBlockParams):
target_version: str
@field_validator("target_version")
@classmethod
def validate_version(cls, v: str) -> str:
if not v.strip():
raise ValueError("target_version must not be empty")
return v.strip()
Extra fields and forward compatibility
FunctionBlockParams uses extra="ignore" in its Pydantic model config. If the workflow engine sends fields that your parameter model does not define, they are silently dropped. The SDK's internal parse_parameters_from_dto method (which is @final) logs a warning listing the unexpected fields before Pydantic processes them. If you call MyParams.model_validate(dto) directly in tests, this SDK-level warning is bypassed — Pydantic silently drops the extras without logging. This enables forward compatibility — older function block versions continue to work when the engine adds new fields.
Common Pitfalls
Blocking I/O without @run_in_thread
Calling netmiko, paramiko, or requests directly inside run() blocks the job's event loop. The blocking detector will warn you, but the fix is straightforward:
# Wrong — blocks the event loop
async def run(self, params, context):
conn = ConnectHandler(host=device.ip, ...)
output = conn.send_command("show version")
# Correct — offloaded to a thread
@run_in_thread
def _get_version(self, ip: str) -> str:
conn = ConnectHandler(host=ip, ...)
return conn.send_command("show version")
async def run(self, params, context):
output = await self._get_version(device.ip)
Unchecked None on context entities
The WorkflowContext convenience properties (context.device, context.device_group, context.interface) return None when the entity is not available. Always guard access:
device = context.device
if device is None:
return FunctionBlockResult(
message="No device in context.", success=False, data=None
)
Hardcoded credentials
Device credentials are available on the context entity. Never hardcode them:
# Wrong
device_params = {"username": "admin", "password": "admin123"}
# Correct
device_params = {
"username": context.device.username,
"password": context.device.password,
}
Credentials in the context come from the CMS and can be rotated centrally without modifying function block code.
Overly broad exception handling
Catching Exception silently hides bugs. If you need a catch-all, log the full exception: