Live demo — data resets daily at 03:00 UTC. Nothing you enter is saved. Server UI →

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 with ModuleNotFoundError on a production station:

  1. Declare the dependency in package.json:
    "requirements": {
      "pipPackages": ["requests>=2.32.0"]
    }
    
  2. Vendor the wheels for offline install:
    .\scripts\vendor-wheels.ps1 -PackagePath "path\to\my-package"
    
    Commit the resulting wheels/ folder. The runner installs from it automatically.

During development only you may skip vendoring by setting ALLOW_ONLINE_PIP=true in your environment — this is already configured in the dev docker-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 scatter traces with fill="tozeroy" or fill="tonexty" so every Bode plot in the fleet reads the same way.

Pip dependencies - plotly and matplotlib must be declared in package.json pipPackages and vendored. See sdk/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() and emit_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 with NameError: name 'emit_artifact' is not defined at return_value = func(**kwargs) (Assert bug #13). On an older station, write into ARTIFACT_DIR directly - that has always worked. The .NET runner has no equivalent; see sdk/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].

An unhandled error has occurred. Reload

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.