Introduction
PyDesignFlow is built around three core concepts: Blocks encapsulate design components, Tasks define processing steps, and Results pass data between tasks. A Flow object manages all blocks and provides the command-line interface.
Blocks
Blocks encapsulate design components such as hardware modules, testbenches, or software builds. Define a block by subclassing Block:
from pydesignflow import Block, Flow, task, Result
class MyBlock(Block):
# Define tasks here (see next section)
To use a block, instantiate it and assign it to a Flow with a unique block ID:
flow = Flow()
flow['top'] = MyBlock()
Blocks can have parameters by passing arguments to __init__(). Configure blocks during instantiation; once created, blocks should be considered immutable:
class MyBlock(Block):
def __init__(self, voltage=1.8):
super().__init__()
self.voltage = voltage
flow['block_1v8'] = MyBlock(voltage=1.8)
flow['block_1v2'] = MyBlock(voltage=1.2)
Tasks
Tasks are methods decorated with @task() that perform design flow steps. Each task receives a working directory (cwd) where it should write outputs:
class MyBlock(Block):
@task()
def synthesize(self, cwd):
result = Result()
result.netlist = cwd / "netlist.v"
result.timing_met = True
return result
Tasks are not parameterized. For similar variants (e.g., behavioral vs. netlist simulation), define separate tasks. Share common functionality through regular methods or functions. Use block-level parameters for configuration.
Targets
A target is the combination of a block ID and task ID, uniquely identifying a buildable item. For example, top.synthesize refers to the synthesize task of the top block.
Each target has its own working directory: build/[block ID]/[task ID]/. This directory is passed as the cwd parameter and should contain all task outputs.
Note
The terms “target” and “task” are often used interchangeably. Technically, a task is a method in a Block class, while a target refers to that task in a specific Block instance.
Result Objects
Tasks can return a Result object to pass structured data to dependent tasks. If no data needs to be passed, return None:
@task()
def synthesize(self, cwd):
result = Result()
result.area = 1234
result.timing_met = True
result.netlist = cwd / "netlist.v"
return result
Supported data types for Result attributes:
Scalars:
str,bool,int,float,pathlib.Path,datetime.datetimeContainers:
dict,list,tuple(can be nested)
Path objects are serialized relative to the build directory. Upon task completion, the Result is serialized to result.json:
{
"block_id": "top",
"task_id": "synthesize",
"data": {
"area": 1234,
"timing_met": true,
"netlist": {
"_type": "Path",
"value": "top/synthesize/netlist.v"
},
"time_started": {
"_type": "Time",
"value": 1003.2
},
"time_finished": {
"_type": "Time",
"value": 1010.5
}
}
}
Task Dependencies
Declare dependencies using the requires argument to @task. Keys are parameter names; values are requirement specifications:
@task(requires={'syn': '.synthesize'})
def place_route(self, cwd, syn):
print(f"Area: {syn.area}")
if not syn.timing_met:
raise RuntimeError("Timing not met")
# Continue with place & route...
return Result()
Missing dependencies are built automatically before the task runs.
Requirement Specification Formats
1. Within-block reference (recommended for same block):
.task_id- References a task in the same block:@task(requires={'syn': '.synthesize'}) def place_route(self, cwd, syn): # ...
2. Symbolic block reference (recommended for cross-block):
block_ref.task_id- References a task in another block using a symbolic name:class MyBlock(Block): @task(requires={'other_syn': 'dependency.synthesize'}) def my_task(self, cwd, other_syn): # ... flow['top'] = MyBlock(dependency_map={'dependency': 'other_block'}) flow['other_block'] = OtherBlock()The
dependency_mapmaps symbolic names (dependency) to actual block IDs (other_block), enabling block reuse without code changes.
3. Direct block ID reference:
=block_id.task_id- Direct reference to a specific block:@task(requires={'syn': '=fpga_top.synthesize'}) def my_task(self, cwd, syn): # ...This creates tight coupling and is generally discouraged.
Multi-Block Example
class SynthesisBlock(Block):
@task()
def synthesize(self, cwd):
result = Result()
result.netlist = cwd / "output.v"
return result
class PlaceRouteBlock(Block):
@task(requires={'syn': 'synth.synthesize'})
def place_route(self, cwd, syn):
print(f"Using netlist: {syn.netlist}")
return Result()
flow = Flow()
flow['syn_block'] = SynthesisBlock()
flow['pnr_block'] = PlaceRouteBlock(dependency_map={'synth': 'syn_block'})
Using symbolic references (variant 2) keeps block IDs out of Block class code, making blocks more reusable.