Your test code shouldn't change between environments. The same script that runs on your dev bench needs to run in QA validation and on the production floor. What changes is the configuration: instrument addresses, measurement limits, TofuPilot workspace, logging levels.
This guide shows three approaches to environment-specific config and how to point each environment at its own TofuPilot workspace.
The Problem
A single test script typically needs different values for:
- Instrument addresses. Your dev bench DMM is at
TCPIP::192.168.1.10but production usesGPIB0::22. - Measurement limits. Dev uses wider tolerances for debugging. Production uses datasheet limits.
- TofuPilot workspace. Dev runs shouldn't pollute production data.
- Logging verbosity. Debug logging in dev, errors only in production.
- Fixture IDs. Each station has its own fixture with unique calibration data.
Hardcoding any of these means editing code for every deployment. That's how bugs reach the production floor.
Approach 1: Environment Variables with python-dotenv
The simplest approach. Create a .env file per environment and load it at startup.
TOFUPILOT_API_KEY=tp_dev_abc123
DMM_ADDRESS=TCPIP::192.168.1.10::INSTR
PSU_ADDRESS=TCPIP::192.168.1.11::INSTR
VOLTAGE_MIN=3.0
VOLTAGE_MAX=3.6
LOG_LEVEL=DEBUG
STATION_ID=DEV-BENCH-01TOFUPILOT_API_KEY=tp_prod_xyz789
DMM_ADDRESS=GPIB0::22::INSTR
PSU_ADDRESS=GPIB0::5::INSTR
VOLTAGE_MIN=3.2
VOLTAGE_MAX=3.4
LOG_LEVEL=ERROR
STATION_ID=PROD-LINE1-STN04Load the right file based on an environment flag:
import os
from dotenv import load_dotenv
# Set TEST_ENV=dev|qa|production before running
env = os.getenv("TEST_ENV", "dev")
load_dotenv(f".env.{env}")
class Config:
TOFUPILOT_API_KEY = os.getenv("TOFUPILOT_API_KEY")
DMM_ADDRESS = os.getenv("DMM_ADDRESS")
PSU_ADDRESS = os.getenv("PSU_ADDRESS")
VOLTAGE_MIN = float(os.getenv("VOLTAGE_MIN", "3.2"))
VOLTAGE_MAX = float(os.getenv("VOLTAGE_MAX", "3.4"))
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
STATION_ID = os.getenv("STATION_ID", "UNKNOWN")Use it in your test:
import openhtf as htf
from tofupilot.openhtf import TofuPilot
from config import Config
@htf.measures(
htf.Measurement("voltage_3v3").in_range(Config.VOLTAGE_MIN, Config.VOLTAGE_MAX),
)
def test_power_rail(test):
# Use Config.DMM_ADDRESS to connect to the right instrument
test.measurements.voltage_3v3 = 3.29
def main():
test = htf.Test(test_power_rail)
with TofuPilot(test):
test.execute(test_start=lambda: "DUT-001")
if __name__ == "__main__":
main()Run with: TEST_ENV=production python test_power.py
Approach 2: YAML Config Files
For more complex configurations with nested structures, YAML is cleaner than flat env vars.
instruments:
dmm:
address: "GPIB0::22::INSTR"
timeout_ms: 5000
psu:
address: "GPIB0::5::INSTR"
timeout_ms: 3000
limits:
voltage_3v3:
min: 3.2
max: 3.4
current_idle:
max: 0.050
station:
id: "PROD-LINE1-STN04"
log_level: "ERROR"instruments:
dmm:
address: "TCPIP::192.168.1.10::INSTR"
timeout_ms: 10000
psu:
address: "TCPIP::192.168.1.11::INSTR"
timeout_ms: 10000
limits:
voltage_3v3:
min: 3.0
max: 3.6
current_idle:
max: 0.100
station:
id: "DEV-BENCH-01"
log_level: "DEBUG"Load with a simple helper:
import os
import yaml
from pathlib import Path
def load_config(env: str = None) -> dict:
"""Load YAML config for the given environment."""
env = env or os.getenv("TEST_ENV", "dev")
config_path = Path(__file__).parent / "config" / f"{env}.yaml"
if not config_path.exists():
raise FileNotFoundError(f"No config file for environment: {env}")
with open(config_path) as f:
return yaml.safe_load(f)import openhtf as htf
from tofupilot.openhtf import TofuPilot
from config_loader import load_config
cfg = load_config()
limits = cfg["limits"]["voltage_3v3"]
@htf.measures(
htf.Measurement("voltage_3v3").in_range(limits["min"], limits["max"]),
)
def test_power_rail(test):
test.measurements.voltage_3v3 = 3.29
def main():
test = htf.Test(test_power_rail)
with TofuPilot(test):
test.execute(test_start=lambda: "DUT-001")
if __name__ == "__main__":
main()Point to Different TofuPilot Workspaces
Each environment should write to its own TofuPilot workspace. This keeps dev noise out of production data.
Set the TOFUPILOT_API_KEY environment variable per environment. Each API key is scoped to a workspace. No code changes needed.
# Dev
export TOFUPILOT_API_KEY="tp_dev_abc123"
python test_power.py
# QA
export TOFUPILOT_API_KEY="tp_qa_def456"
python test_power.py
# Production
export TOFUPILOT_API_KEY="tp_prod_xyz789"
python test_power.pyThe TofuPilot client reads TOFUPILOT_API_KEY from the environment automatically. You don't need to pass it in code.
Comparison: Env Vars vs Config Files vs CLI Args
| Approach | Best for | Pros | Cons |
|---|---|---|---|
| Environment variables | Simple configs, CI/CD, secrets | No files to deploy, works with Docker/systemd, secret-friendly | Flat structure only, no nesting |
| YAML config files | Complex configs, nested structures | Readable, supports comments, version-controllable | Need to deploy files, secrets in plaintext |
| CLI arguments | One-off overrides, debugging | No files needed, easy to test | Verbose for many params, not persistent |
| Combined (env + YAML) | Production deployments | Secrets in env, structure in YAML | Two systems to maintain |
The pragmatic approach: use YAML for instrument addresses and limits (things that vary by station), and environment variables for secrets (API keys, credentials). CLI args for quick overrides during debugging.