Packaging Python scripts

As EPNix uses Nix, you can package Python scripts as helpers for your integration tests, by using the provided infrastructure of nixpkgs. In fact, you can package any program in any language, but this document focuses on Python scripts with Poetry for their simplicity and popularity.

Getting started

We recommend using the Poetry package in your EPNix environment, through Nix, to use the same version as the one building the Python script.

You can do this by adding this bit in your flake.nix file:

epnix.devShell.packages = [
  { package = pkg.poetry; category = "development tools"; }
];

Next, you can start your development shell with nix develop, go to the directory of your test, and create a new project with the command:

poetry new <my-python-script>

This will create a Python project under the <my-python-script> directory. Under it, you will find a pyproject.toml where you can specify the dependencies of your script. For example, you can specify modbus to add the Python modbus package, if you want to test modbus communication. You can remove the dependency on pytest if you won’t add unit tests to your Python script.

To add an entry point to your Python code, you can use the tool.poetry.scripts section like so:

[tool.poetry.scripts]
my_python_script = "my_python_script:main"

This will add an executable named my_python_script that will run the main() function of the my_python_script module.

For more information on how to use poetry, please refer to the Poetry documentation.

Before packaging this script using Nix, it’s important to generate the lock file, and to remember to re-generate it each time you change the pyproject.toml file.

You can do this with the following command:

poetry lock

Then, in your integration test file (see: Adding integration tests to your IOC), you can package it like this:

{ build, pkgs, ... }:

let
  pythonScript = pkgs.poetry2nix.mkPoetryApplication {
    projectDir = ./path/to/my-python-script;
  };
in
pkgs.nixosTest {
  name = "myTest";

  # ...
}

With this, you can use the pythonScript variable as you see fit.

Example usage: As a one shot test script

Using a packaged Python script instead of the provided testScript has several advantages. It can use dependencies provided by the community (like modbus, systemd, etc.), and you can make it run on the running virtual machine.

Python script:

import subprocess

from modbus.client import *


def main():
    c = client(host='HOSTNAME')
    modbus_values = c.read(FC=3, ADR=10, LEN=8)

    for i in range(8):
        epics_value = subprocess.run(
            ["caget", "-t", "MyPV:" + i],
            capture_output=True,
        ).stdout.strip()

        assert modbus_values[i] == int(epics_value), "Wrong value provided by epics"

Nix test:

{ build, pkgs, ... }:

let
  pythonScript = pkgs.poetry2nix.mkPoetryApplication {
    projectDir = ./path/to/my-python-script;
  };
in
pkgs.nixosTest {
  name = "myTest";

  machine = {
    environment.systemPackages = [ pythonScript ];

    # ...
  };

  testScript = ''
    # ...

    my_python_script --my-flag --my-option=3

    # ...
  '';
}

Example usage: As a systemd service

Using a Python script as a systemd service is useful for mocking devices.

Python script:

import logging
from logging import info


def main():
    logging.basicConfig(level=logging.INFO)

    while True:
        info("doing things")

        # ...

Nix test:

{ build, pkgs, ... }:

let
  pythonScript = pkgs.poetry2nix.mkPoetryApplication {
    projectDir = ./path/to/my-python-script;
  };
in
pkgs.nixosTest {
  name = "myTest";

  machine = {
    systemd.services."my-python-service" = {
      wantedBy = [ "multi-user.target" ];

      serviceConfig.ExecStart = "${pythonScript}/bin/my_python_script";
    };

    # ...
  };

  testScript = ''
    # ...

    machine.wait_for_unit("my-python-service.service")

    # ...
  '';
}