product: assert audience: test-developer authority: normative
Python Runner SDK
⚠️ Before you write a single line of Python — pip packages
If your runner imports any third-party package (e.g.
numpy,pyserial,requests), you must do both of the following or the runner will fail withModuleNotFoundErroron a production station:
- Declare the dependency in
package.json:"requirements": { "pipPackages": ["requests>=2.32.0"] }- Vendor the wheels for offline install:
Commit the resulting.\scripts\vendor-wheels.ps1 -PackagePath "path\to\my-package"wheels/folder. The runner installs from it automatically.During development only you may skip vendoring by setting
ALLOW_ONLINE_PIP=truein your environment — this is already configured in the devdocker-compose.yml.Full details:
sdk/python-dependencies.md
Module setup
Place .py files in the package python_modules/ directory. The runner adds
python_modules/ to sys.path automatically. Each module IS reloaded once per test
execution — file edits take effect between executions without restarting the runner.
Module-level state (instrument handles set in a connect() step) IS preserved across
all steps within the same execution.
Parameter passing
All YAML parameters: values arrive as str keyword arguments. Convert explicitly
inside the function.
# YAML: parameters: { sensor_id: "1", gain: "2.5" }
def read_temperature(sensor_id: str, gain: str) -> dict:
temp = hardware_read(int(sensor_id), float(gain))
return {"temperature": temp}
cfg.*, exec.*, and mes.* template values ARE resolved by the engine before
the function is called. The function receives already-resolved strings as normal
keyword arguments — there IS NO special cfg or exec object in Python code.
parameters:
address: "{{cfg.DMM_VISA}}" # resolved before Python sees it
serial_number: "{{exec.serial_number}}"
def connect(address: str, serial_number: str) -> None:
print(f"Connecting to {address} for DUT {serial_number}")
Return types
| Return value | Effect |
|---|---|
dict |
Keys become output variables |
None / no return |
Success, no outputs |
Any scalar (int, float, str, …) |
Stored as output variable result |
Measurements — YAML-defined limits only
The Python runner DOES NOT support returning MeasurementPointBase objects.
Measurements and limits MUST always be defined in the YAML measurement: /
measurements: block.
def measure_voltage(channel: str) -> dict:
return {"voltage": read_channel(int(channel))}
- name: "Measure 3V3 Rail"
runner: python
runner_type: python3.11
module: "instruments"
function: "measure_voltage"
parameters:
channel: "1"
outputs:
rail_3v3: "{{voltage}}"
measurement:
name: "RAIL_3V3"
value: "{{voltage}}"
low_limit: 3.135
high_limit: 3.465
unit: "V"
⚠️ Anti-pattern — never perform limit checking inside Python:
# WRONG — do not do this def measure_voltage(channel: str) -> dict: v = read_channel(int(channel)) if v < 3.135 or v > 3.465: raise ValueError(f"Voltage {v} V out of range") # limit logic belongs in YAML return {"voltage": v}This approach bypasses the Assert measurement engine entirely. Limits defined this way are invisible to the test report, yield statistics, Cp/Cpk analysis, and the production dashboard. Always return the raw measured value and declare the limits in the YAML
measurement:block.
Step-splitting guidance
When a single function returns more than ~10 measurements, split along logical boundaries. Each sub-step has its own named verdict in the results tree.
.NET vs Python comparison
| Capability | .NET | Python |
|---|---|---|
| Parameter types | Auto-converted to declared C# type | Always str — convert manually |
| Return measurement points | ✅ MeasurementPointBase |
❌ Use YAML measurement: |
| Return output variables | Dictionary<string, object> |
dict |
| Scalar return | result |
result |
| Async support | ✅ async Task<T> |
❌ synchronous only |
| Limits defined in | Code or YAML | YAML only |
| Module hot-reload | ❌ Requires runner restart | ✅ Once per execution |
Accordion Python API (accordionq2)
The accordionq2 package mirrors the .NET AccordionQ2.WebApiClient API with
Pythonic naming and synchronous (blocking) calls — no .Wait() or .Result needed.
For the full Python API reference see ../Accordion/05-api-python/.
Package name in pipPackages: "accordionq2" (not "accordionq2-client").
Import: from accordionq2 import AccordionQ2Client.
Minimal connect/disconnect pattern:
from accordionq2 import AccordionQ2Client
_client = None
def connect(host: str) -> None:
global _client
_client = AccordionQ2Client(host)
_client.application.reset()
def disconnect() -> None:
global _client
if _client:
_client.close()
_client = None
Emitting Artifacts
A step attaches files (images, Plotly charts, CSV, logs) to the execution report by putting them
in the artifact directory, ARTIFACT_DIR. When the step returns - pass, fail or raise - the
runner reports what is in that directory and the executor stores each file against the step that
produced it. There are two ways to get a file there, and they end in the same place:
Call emit_artifact() / emit_plotly() |
Write the file into ARTIFACT_DIR yourself |
|
|---|---|---|
| Import needed | None - the runner provides both as builtins | import os |
| Description shown in the report | Yes | No (none can be attached to a bare file) |
| Content type | Declared, or inferred from the extension | Inferred from the extension |
| Runs outside a station | No - NameError without the runner |
Yes |
emit_artifact(path, description, content_type=None)
| Parameter | Type | Description |
|---|---|---|
path |
str or PathLike |
The file to attach. Copied into ARTIFACT_DIR; left in place if it is already there. |
description |
str |
Label shown next to the artifact in the report |
content_type |
str \| None |
MIME type. Inferred from the extension when omitted. |
Returns the file name the artifact is stored under: basename(path), or name-2.ext,
name-3.ext... if a different file of that name is already staged in this step. A missing
path raises FileNotFoundError in the step (verdict ABORTED).
def plot_bode(frequencies: str, gains: str) -> dict:
import matplotlib
matplotlib.use("Agg") # non-interactive backend - no display in a container
import matplotlib.pyplot as plt
freq = [float(f) for f in frequencies.split(",")]
gain = [float(g) for g in gains.split(",")]
fig, ax = plt.subplots()
ax.semilogx(freq, gain)
ax.set(xlabel="Frequency (Hz)", ylabel="Gain (dB)", title="Bode Plot")
fig.savefig("/tmp/bode-plot.png")
plt.close(fig)
name = emit_artifact("/tmp/bode-plot.png", "Bode plot") # no import
return {"bode_png": name}
emit_plotly(fig, description, file_name=None)
| Parameter | Type | Description |
|---|---|---|
fig |
plotly.graph_objects.Figure, dict or JSON str |
The figure. Anything with to_json() is serialised with it. |
description |
str |
Label shown next to the chart in the report |
file_name |
str \| None |
Stored name. Derived from description when omitted ("Bode plot" -> bode-plot.plotly.json). |
Writes the figure into ARTIFACT_DIR as *.plotly.json - always, whatever file_name ends in -
and declares it application/vnd.plotly.v1+json. The report renders it as an interactive chart
(zoom, hover, export). Returns the stored file name.
def bode_interactive(frequencies: str, gains: str) -> dict:
from plotly.graph_objects import Figure, Scatter
freq = [float(f) for f in frequencies.split(",")]
gain = [float(g) for g in gains.split(",")]
fig = Figure()
fig.add_trace(Scatter(x=freq, y=gain, mode="lines", name="Gain (dB)"))
# Limit band - a filled region between two boundary traces
fig.add_trace(Scatter(x=freq, y=[-3] * len(freq), mode="lines",
line=dict(width=0), showlegend=False))
fig.add_trace(Scatter(x=freq, y=[3] * len(freq), mode="lines",
fill="tonexty", fillcolor="rgba(255,0,0,0.15)",
line=dict(width=0), name="Limit band"))
fig.update_layout(xaxis_type="log", xaxis_title="Hz", yaxis_title="dB")
return {"bode_plotly": emit_plotly(fig, "Bode plot (interactive)")}
Limit band convention - encode limits as filled
scattertraces withfill="tozeroy"orfill="tonexty"so every Bode plot in the fleet reads the same way.
Pip dependencies -
plotlyandmatplotlibmust be declared inpackage.jsonpipPackagesand vendored. Seesdk/python-dependencies.md.
Linters and off-station runs. Both names are injected into builtins by the runner, so a
linter reports them as undefined and a plain python or pytest run raises
NameError: name 'emit_artifact' is not defined. To make them visible, import them explicitly -
from assert_artifacts import emit_artifact, emit_plotly - which also only resolves inside the
runner. Code that must run off-station too should write into ARTIFACT_DIR directly (below).
Runner version.
emit_artifact()andemit_plotly()exist from Python runner 1.2.0 (Assert 2.3.0). Earlier revisions of this page documented them for runners that did not provide them, and a step calling one failed withNameError: name 'emit_artifact' is not definedatreturn_value = func(**kwargs)(Assert bug #13). On an older station, write intoARTIFACT_DIRdirectly - that has always worked. The .NET runner has no equivalent; seesdk/dotnet-sdk.md.
Writing into ARTIFACT_DIR directly
What the helpers do underneath, and what works on every runner version and outside a station:
import os
ARTIFACT_DIR = os.environ.get("ARTIFACT_DIR", "/data/artifacts")
def log_voltage(samples: str) -> dict:
os.makedirs(ARTIFACT_DIR, exist_ok=True)
with open(os.path.join(ARTIFACT_DIR, "voltage-log.csv"), "w") as f:
f.write("sample,voltage\n")
for i, v in enumerate(samples.split(",")):
f.write(f"{i},{v}\n")
return {"csv": "voltage-log.csv"}
Read the variable with that fallback rather than hard-coding the path: the env var is what
makes the module correct inside a station, the fallback is what keeps it runnable outside
one. The executor empties the directory before every step, so a file is always attributed to
the step that wrote it. Only files directly in the directory are collected, not subdirectories.
See reference/environment.md.
A Plotly figure written this way must be named *.plotly.json:
json.dump(json.loads(fig.to_json()), f) into os.path.join(ARTIFACT_DIR, "bode.plotly.json").
The file name decides how the artifact is displayed
Unless the step declared a content type (emit_artifact(..., content_type=...), or
emit_plotly), the executor infers it from the extension alone, so the file name controls
how the artifact appears in the report:
| Name it | Stored as | Report shows |
|---|---|---|
*.png, *.jpg, *.gif, *.svg |
image/* |
inline thumbnail, click for full size |
*.plotly.json |
application/json |
interactive Plotly chart |
*.csv, *.tsv |
text/csv, text/tab-separated-values |
rendered HTML table |
*.html |
text/html |
opened as a page |
*.txt, *.log, *.json, *.xml, *.pdf, *.zip |
the matching type | download link |
| anything else | application/octet-stream |
download link |
*.plotly.json is a double extension and it is load-bearing. A figure written as
chart.json is stored as application/json too, but fails the name test and renders as raw
JSON rather than a chart - unless it was declared application/vnd.plotly.v1+json, which
emit_plotly always does.
Working examples in the shipped samples: sample-advanced-features
(python_modules/bode_artifacts.py) writes a PNG, a Plotly chart and a CSV into ARTIFACT_DIR
directly; sample-artifact-demo (python_modules/generate_csv_artifact.py, 1.1.0 and later)
produces one CSV each way - written straight into ARTIFACT_DIR, and through emit_artifact()
with a description.
Error signalling
Raise any exception → ABORTED verdict. The full traceback IS stored in the step log.
Returning None DOES NOT fail the step — it means "success with no outputs".
Logging
import sys
print("Connecting to", address) # Information
print("[DEBUG] Raw bytes:", raw) # Debug
print("[WARNING] Retrying...", file=sys.stderr) # Warning
Supported prefixes: [INFO], [DEBUG], [WARNING] / [WARN], [ERROR] / [ERR], [TRACE].