-
Notifications
You must be signed in to change notification settings - Fork 6
Defining Workflows
So far your configuration objects have only held data. This tutorial makes them run. You will meet the three ingredients of a workflow step — a result, a context, and a callable model — build one, run it, and see how the @Flow.call decorator adds type checking, logging, and other behavior behind the scenes.
Follow along in a Python session. For the full catalog of built-in results, contexts, and evaluators, see the Reference; this tutorial teaches the pattern with a few representative examples.
A workflow step in ccflow is a CallableModel: something you call with a context that returns a result. Three abstractions make this work:
- a result type to hold what a step returns,
- a context type to parameterize the step at runtime,
- the
@Flow.calldecorator, through which the framework injects type checking, logging, caching, and alternative evaluation.
Every step returns a ResultBase. The simplest is GenericResult, which holds anything in its value:
from ccflow import GenericResult
print(GenericResult(value="Anything goes here"))
#> GenericResult(value='Anything goes here')You can ask for type safety on the value using Python generics:
result = GenericResult[str](value="Any string")
try:
GenericResult[str](value={"x": "foo", "y": 5.0})
except ValueError as e:
print(e)
#> 1 validation error for GenericResult[str] ...Pydantic validation also cuts boilerplate — a bare value is validated into the wrapper:
print(GenericResult.model_validate("Any string"))
#> GenericResult(value='Any string')When you know the shape of your output, define a proper result schema by subclassing ResultBase:
from ccflow import ResultBase
class MyResult(ResultBase):
x: str
y: float
print(MyResult(x="foo", y=5.0))
#> MyResult(x='foo', y=5.0)ccflow ships typed results for common data structures (pandas, numpy, Arrow, xarray, Narwhals). The Contexts and Results reference lists them all.
A context carries the parameters that vary between runs. Some steps need none — use NullContext:
from ccflow import NullContext
print(NullContext())
#> NullContext()GenericContext mirrors GenericResult for ad-hoc parameters:
from ccflow import GenericContext
print(GenericContext[str].model_validate(100))
#> GenericContext[str](value='100')And you define your own when a workflow has specific parameters:
from ccflow import ContextBase
from datetime import datetime
class LocationTimestampContext(ContextBase):
latitude: float
longitude: float
timestamp: datetimeContexts are frozen (immutable) and hashable by default, so the framework can use them as cache keys. ccflow also provides date-oriented contexts (DateContext, DateRangeContext, and more) with convenient validation — see the Contexts and Results reference.
Put the pieces together with a CallableModel. You implement __call__ as a function of the context and decorate it with @Flow.call. Here is the classic FizzBuzz problem as a model:
from ccflow import CallableModel, Flow, GenericResult, GenericContext
class FizzBuzzModel(CallableModel):
fizz: str = "Fizz"
buzz: str = "Buzz"
@Flow.call
def __call__(self, context: GenericContext[int]) -> GenericResult[list[int | str]]:
n = context.value
result = []
for i in range(1, n + 1):
if i % 3 == 0 and i % 5 == 0:
result.append(f"{self.fizz}{self.buzz}")
elif i % 3 == 0:
result.append(self.fizz)
elif i % 5 == 0:
result.append(self.buzz)
else:
result.append(i)
return result
model = FizzBuzzModel()
print(model(15))
#> GenericResult[list[Union[int, str]]](value=[1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz', 11, 'Fizz', 13, 14, 'FizzBuzz'])Two things worth noticing. You called model(15) with a bare integer, not a GenericContext[int] — and you returned a bare list, not a GenericResult. The @Flow.call decorator did the conversions for you.
The model's fields (fizz, buzz) are configuration; the context (15) is the runtime parameter. That split is the whole idea: configure once, run across many contexts.
@Flow.call runs pydantic validation on the way in and out, so invalid inputs fail clearly:
try:
model("not an integer")
except ValueError as e:
print(e)
#> 1 validation error for GenericContext[int] ...By default the decorator infers the context and result types from your __call__ signature:
print(model.context_type)
#> <class 'ccflow.context.GenericContext[int]'>
print(model.result_type)
#> <class 'ccflow.result.generic.GenericResult[list[Union[int, str]]]'>When the types depend on configuration, override the context_type / result_type properties instead of annotating:
from typing import Type
class DynamicTypedModel(CallableModel):
input_type: Type
output_type: Type
@property
def context_type(self):
return GenericContext[self.input_type]
@property
def result_type(self):
return GenericResult[self.output_type]
@Flow.call
def __call__(self, context):
return context.value
print(DynamicTypedModel(input_type=int, output_type=str)(5))
#> GenericResult[str](value='5')@Flow.call is the seam where framework behavior is layered on, controlled by FlowOptions. You can set options four ways: as arguments to the decorator, via the FlowOptionsOverride context manager, on the model's meta.options, or per call with _options.
Turn off result validation for one model:
class NoValidationModel(CallableModel):
@Flow.call(validate_result=False)
def __call__(self, context: GenericContext[str]) -> GenericResult[float]:
return "foo"
print(NoValidationModel()("foo"))
#> fooRaise the log level for a call and every sub-call, scoped by a context manager:
import logging
from ccflow import FlowOptionsOverride
model = FizzBuzzModel()
with FlowOptionsOverride(options={"log_level": logging.WARN}):
_ = model(15)
#[FizzBuzzModel]: Start evaluation of __call__ on GenericContext[int](value=15).
#[FizzBuzzModel]: End evaluation of __call__ on GenericContext[int](value=15) (time elapsed: ...).Or pass options to a single call:
_ = model(15, _options={"log_level": logging.WARN})The full set of options lives on the FlowOptions schema — see Flow Options in the reference. The meta attribute on every CallableModel also carries a name and description (set automatically when models load from Hydra configs) and can hold options so they travel with the model in a config file.
You have been running models the "standard" way — Python calls __call__ directly. An evaluator changes how a model runs. The default logs each evaluation; others cache, evaluate an explicit dependency graph, retry on failure, or distribute work. Because an evaluator is set through FlowOptions, adding these behaviors does not change your step at all.
You set an evaluator the same way you set any option:
from ccflow.evaluators import MemoryCacheEvaluator
with FlowOptionsOverride(options={"cacheable": True, "evaluator": MemoryCacheEvaluator()}):
_ = model(15)That is the whole idea; the practical guides cover the two you will reach for most:
-
Cache Results — avoid redundant work with the
MemoryCacheEvaluator, and evaluate dependency graphs. - Retry on Failure — make flaky steps resilient.
The full list of evaluators is in the Built-in Models reference.
- A workflow step is a
CallableModelyou call with a context to get a result. -
@Flow.callhandles type conversion and validation, and is where framework behavior is injected. -
FlowOptions(via the decorator, an override,meta, or_options) tunes that behavior. - Evaluators decide how steps run, without touching the steps themselves.
- Building an ETL Pipeline — chain callable models into an end-to-end pipeline.
- Bind Logic to Configs — patterns for attaching logic, including publishers.
- Core Concepts — how contexts, results, and evaluators fit the bigger picture.
This wiki is autogenerated. To made updates, open a PR against the original source file in docs/wiki.
Tutorials
- Overview
- First Steps
- Configuring Models
- Defining Workflows
- Building an ETL Pipeline
- Composing an ETL Application
- Building a Configurable Calculator
How-to Guides
- Overview
- Install ccflow
- Configure Complex Values
- Bind Logic to Configs
- Run Workflows from the CLI
- Cache Results
- Retry on Failure
Reference
Explanation
Developer Guide