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_abc123DMM_ADDRESS=TCPIP::192.168.1.10::INSTRPSU_ADDRESS=TCPIP::192.168.1.11::INSTRVOLTAGE_MIN=3.0VOLTAGE_MAX=3.6LOG_LEVEL=DEBUGSTATION_ID=DEV-BENCH-01TOFUPILOT_API_KEY=tp_prod_xyz789DMM_ADDRESS=GPIB0::22::INSTRPSU_ADDRESS=GPIB0::5::INSTRVOLTAGE_MIN=3.2VOLTAGE_MAX=3.4LOG_LEVEL=ERRORSTATION_ID=PROD-LINE1-STN04Load the right file based on an environment flag:
import osfrom dotenv import load_dotenv# Set TEST_ENV=dev|qa|production before runningenv = 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 htffrom tofupilot.openhtf import TofuPilotfrom 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.29def 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: 3000limits: voltage_3v3: min: 3.2 max: 3.4 current_idle: max: 0.050station: 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: 10000limits: voltage_3v3: min: 3.0 max: 3.6 current_idle: max: 0.100station: id: "DEV-BENCH-01" log_level: "DEBUG"Load with a simple helper:
import osimport yamlfrom pathlib import Pathdef 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 htffrom tofupilot.openhtf import TofuPilotfrom config_loader import load_configcfg = 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.29def 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.
# Devexport TOFUPILOT_API_KEY="tp_dev_abc123"python test_power.py# QAexport TOFUPILOT_API_KEY="tp_qa_def456"python test_power.py# Productionexport 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.