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
tofupilotandopenhtfpackages 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.
#!/bin/bash
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pipOn Windows stations:
@echo off
python -m venv .venv
.venv\Scripts\activate
pip install --upgrade pipStep 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.
openhtf==2.1.0
tofupilot==1.5.0
pyserial==3.5
numpy==1.26.4
pyinstaller==6.10.0Install and verify:
#!/bin/bash
pip install -r requirements.txt
pip freeze > requirements.lock.txt # capture transitive deps for auditUse 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:
[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.
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.
# -*- 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:
#!/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
| Symptom | Cause | Fix |
|---|---|---|
ModuleNotFoundError: openhtf.util.logs | Missing hidden import | Add to hiddenimports in spec |
Blank frontend at localhost:4444 | openhtf web assets not bundled | Check collect_data_files("openhtf") in spec |
OSError: [Errno 2] on config file | Data file not included | Add config dir to datas in spec |
| Crash on second run | Leftover _MEIPASS temp dir | Set 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)
#!/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:
{
"api_key": "tp_live_xxxxxxxxxxxxxxxxxxxx",
"station_id": "station-floor-1-cell-3",
"serial_port": "/dev/ttyUSB0",
"baud_rate": 115200
}Load it in the script:
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.
#!/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.
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
| Method | Setup | Station Python required | Rollback |
|---|---|---|---|
| Virtual environment | Low | Yes | Replace venv |
| PyInstaller single executable | Medium | No | Replace file |
| Docker container | High | No | docker pull previous tag |
| Network share (live) | Low | Yes | Revert 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.