Skip to content
Test Station Setup

Deploy Test Scripts to Production

Learn how to package, distribute, and deploy OpenHTF test scripts to production stations using virtual environments, PyInstaller, and version tracking.

JJulien Buteau
intermediate12 min readMarch 14, 2026

Deploying test scripts to production stations requires a repeatable process: pin dependencies, bundle the executable, configure each station's environment, and track versions in TofuPilot. This guide covers the full workflow from a clean virtual environment to a versioned deployment across multiple stations.

Prerequisites

  • Python 3.9 or later installed on all target stations
  • TofuPilot account with at least one station configured
  • tofupilot and openhtf packages available on PyPI

Step 1: Set Up a Virtual Environment

Isolate your test script dependencies from the system Python to avoid version conflicts across deployments.

setup_env.sh
#!/bin/bash
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip

On Windows stations:

setup_env.bat
@echo off
python -m venv .venv
.venv\Scripts\activate
pip install --upgrade pip

Step 2: Pin Dependencies

Pin all dependency versions to ensure every station runs the same code. Unpinned dependencies are the most common source of "works on my machine" failures in production.

requirements.txt
openhtf==2.1.0
tofupilot==1.5.0
pyserial==3.5
numpy==1.26.4
pyinstaller==6.10.0

Install and verify:

install_deps.sh
#!/bin/bash
pip install -r requirements.txt
pip freeze > requirements.lock.txt  # capture transitive deps for audit

Use requirements.lock.txt for auditing; use requirements.txt for installs. Never pin transitive dependencies manually.

Using pyproject.toml (Optional)

If you manage your test scripts as a package, use pyproject.toml instead:

pyproject.toml
[project]
name = "station-tests"
version = "1.4.2"
requires-python = ">=3.9"
dependencies = [
    "openhtf==2.1.0",
    "tofupilot==1.5.0",
    "pyserial==3.5",
    "numpy==1.26.4",
]

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.backends.legacy:build"

The version field in pyproject.toml becomes your deployment version. Increment it before each release.

Step 3: Write the Test Script

Structure your test with plug injection using the @htf.plug decorator. Don't use type hints for plug injection.

test_main.py
import openhtf as htf
from tofupilot.openhtf import TofuPilot

from plugs.serial_plug import SerialPlug
from plugs.power_plug import PowerPlug


@htf.measures(
    htf.Measurement("boot_time_ms")
    .in_range(maximum=5000)
    .doc("Time from power-on to READY response"),
)
@htf.plug(serial=SerialPlug)
@htf.plug(power=PowerPlug)
def phase_power_on(test, serial, power):
    power.enable()
    response = serial.read_until("READY", timeout_s=5)
    test.measurements.boot_time_ms = response.elapsed_ms


@htf.measures(
    htf.Measurement("voltage_mv")
    .in_range(minimum=4900, maximum=5100)
)
@htf.plug(power=PowerPlug)
def phase_voltage_check(test, power):
    voltage = power.measure_voltage()
    test.measurements.voltage_mv = voltage


def main():
    test = htf.Test(
        phase_power_on,
        phase_voltage_check,
        test_name="PCB Functional Test",
    )
    with TofuPilot(test):
        test.execute(test_start=lambda: input("Enter serial number: ").strip())


if __name__ == "__main__":
    main()

Step 4: Bundle with PyInstaller

PyInstaller packages the script and all dependencies into a single executable. This eliminates Python version mismatches on stations and simplifies deployment to one file copy.

PyInstaller can't detect OpenHTF's dynamic imports automatically, so you need explicit hook configuration. The spec file below handles the known hidden imports.

station_tests.spec
# -*- mode: python ; coding: utf-8 -*-
import sys
from PyInstaller.utils.hooks import collect_data_files, collect_submodules

block_cipher = None

# OpenHTF uses dynamic imports that PyInstaller can't detect automatically
openhtf_datas = collect_data_files("openhtf")
openhtf_hiddenimports = collect_submodules("openhtf")

a = Analysis(
    ["test_main.py"],
    pathex=["."],
    binaries=[],
    datas=openhtf_datas + [
        ("plugs/", "plugs/"),
        ("config/", "config/"),
        ("VERSION", "."),
    ],
    hiddenimports=openhtf_hiddenimports + [
        "openhtf.output.callbacks",
        "openhtf.output.callbacks.json_factory",
        "openhtf.util.logs",
        "tofupilot",
        "tofupilot.openhtf",
    ],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=block_cipher,
    noarchive=False,
)

pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name="station_tests",
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=False,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=True,
    disable_windowed_traceback=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
)

Build the executable:

build.sh
#!/bin/bash
set -e

VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
echo "$VERSION" > VERSION
echo "Building version $VERSION"

pyinstaller station_tests.spec --clean --noconfirm

mv dist/station_tests "dist/station_tests_v${VERSION}"
rm VERSION
echo "Built: dist/station_tests_v${VERSION}"

PyInstaller Troubleshooting

SymptomCauseFix
ModuleNotFoundError: openhtf.util.logsMissing hidden importAdd to hiddenimports in spec
Blank frontend at localhost:4444openhtf web assets not bundledCheck collect_data_files("openhtf") in spec
OSError: [Errno 2] on config fileData file not includedAdd config dir to datas in spec
Crash on second runLeftover _MEIPASS temp dirSet runtime_tmpdir to a fixed path

Step 5: Configure Station Environment

Each station needs its own identity and credentials. Never hardcode station IDs or API keys in the script.

Environment Variables (Recommended)

station_env.sh
#!/bin/bash
# Source this file on each station before running tests
# Place in /etc/profile.d/tofupilot.sh for persistent configuration

export TOFUPILOT_API_KEY="tp_live_xxxxxxxxxxxxxxxxxxxx"
export TOFUPILOT_STATION_ID="station-floor-1-cell-3"
export STATION_SERIAL_PORT="/dev/ttyUSB0"
export STATION_BAUD_RATE="115200"

Config File Fallback

For stations where environment variables are impractical, use a local config file:

/etc/tofupilot/station.json
{
  "api_key": "tp_live_xxxxxxxxxxxxxxxxxxxx",
  "station_id": "station-floor-1-cell-3",
  "serial_port": "/dev/ttyUSB0",
  "baud_rate": 115200
}

Load it in the script:

config/loader.py
import json
import os
from pathlib import Path


def load_station_config() -> dict:
    if os.environ.get("TOFUPILOT_API_KEY"):
        return {
            "api_key": os.environ["TOFUPILOT_API_KEY"],
            "station_id": os.environ.get("TOFUPILOT_STATION_ID", "unknown"),
            "serial_port": os.environ.get("STATION_SERIAL_PORT", "/dev/ttyUSB0"),
            "baud_rate": int(os.environ.get("STATION_BAUD_RATE", "115200")),
        }

    config_path = Path("/etc/tofupilot/station.json")
    if not config_path.exists():
        raise FileNotFoundError(
            f"Station config not found at {config_path}. "
            "Set TOFUPILOT_API_KEY environment variable or create the config file."
        )

    with config_path.open() as f:
        return json.load(f)

Step 6: Deploy to Multiple Stations

Use a deploy script to push the executable and config in one step. This example targets Linux stations over SSH.

deploy.sh
#!/bin/bash
set -e

VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
EXECUTABLE="dist/station_tests_v${VERSION}"
DEPLOY_PATH="/opt/tofupilot/station_tests"

STATIONS=(
    "operator@station-01.local"
    "operator@station-02.local"
    "operator@station-03.local"
)

for STATION in "${STATIONS[@]}"; do
    echo "Deploying v${VERSION} to ${STATION}..."
    ssh "$STATION" "[ -f ${DEPLOY_PATH} ] && cp ${DEPLOY_PATH} ${DEPLOY_PATH}.bak || true"
    scp "$EXECUTABLE" "${STATION}:${DEPLOY_PATH}"
    ssh "$STATION" "chmod +x ${DEPLOY_PATH}"
    ssh "$STATION" "${DEPLOY_PATH} --version 2>&1 | head -1"
    echo "  Done: ${STATION}"
done

echo "Deployed v${VERSION} to ${#STATIONS[@]} stations."

Step 7: Track Versions in TofuPilot

TofuPilot records the software version on every test run. Set it explicitly so you can filter runs by deployment version and correlate yield changes with code releases.

test_main.py
import importlib.metadata
import openhtf as htf
from tofupilot.openhtf import TofuPilot


def get_version() -> str:
    try:
        return importlib.metadata.version("station-tests")
    except importlib.metadata.PackageNotFoundError:
        import os, sys
        base = getattr(sys, "_MEIPASS", os.path.dirname(__file__))
        version_file = os.path.join(base, "VERSION")
        with open(version_file) as f:
            return f.read().strip()


def main():
    test = htf.Test(
        phase_power_on,
        phase_voltage_check,
        test_name="PCB Functional Test",
    )
    with TofuPilot(test, software_version=get_version()):
        test.execute(test_start=lambda: input("Enter serial number: ").strip())

Deployment Comparison

MethodSetupStation Python requiredRollback
Virtual environmentLowYesReplace venv
PyInstaller single executableMediumNoReplace file
Docker containerHighNodocker pull previous tag
Network share (live)LowYesRevert file on share

PyInstaller is the right choice when stations have locked-down OS images or inconsistent Python versions. Virtual environments work well when you control the station OS and want faster iteration.

More Guides

Put this guide into practice