How to package a multi file python script and run as systemd?

Hi I’m trying to build a python application and run it as a systemd service.

I have the following files:

  • app
    • controller.py
    • monitor.py
    • main.py
  • default.nix
  • pyproject.toml
  • service.nix

Now to be honest I’m not that good with python or nix so I have no idea if it’s my nix code that is wrong or my python code.

I have used the following as reference to get me this far:

I can get my service to build with nixos-rebuild switch, but the problem is that main.py doesn’t seem to find the other files as I get the following error:

nov 18 21:20:27 brage systemd[1]: monitor-controllers.service: Failed with result 'exit-code'.
nov 18 21:20:27 brage systemd[1]: monitor-controllers.service: Main process exited, code=exited, status=1/FAILURE
nov 18 21:20:27 brage app[84062]: ModuleNotFoundError: No module named 'monitor'
nov 18 21:20:27 brage app[84062]:     from monitor import Monitor
nov 18 21:20:27 brage app[84062]:   File "/nix/store/f9jsrvkjrr1lj3ldv5vlhkjvk7kvjn4l-python3.12-monitor-controllers-0.0.1/lib/python3.12/site-packages/app/main.py", line 7, in <module>
nov 18 21:20:27 brage app[84062]:     from app.main import start
nov 18 21:20:27 brage app[84062]:   File "/nix/store/f9jsrvkjrr1lj3ldv5vlhkjvk7kvjn4l-python3.12-monitor-controllers-0.0.1/bin/.app-wrapped", line 6, in <module>
nov 18 21:20:27 brage app[84062]: Traceback (most recent call last):counter is at 8.

So I went into the /nix/store/f9jsrvkjrr1lj3ldv5vlhkjvk7kvjn4l-python3.12-monitor-controllers-0.0.1 folder and it has the following structure:

  • bin
    • app (bash script that starts .app-wrapped)
    • .app-wrapped (python script that starts main.py I think)
  • lib
    • python3.12
      • site-packages
        • app
          • controller.py
          • monitor.py
          • main.py

pyproject.toml

[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project]
name = "monitor-controllers"
version = "0.0.1"
dependencies = [
    "evdev"
]

[project.scripts]
app = "app.main:start"

[tool.setuptools]
packages = ["app"]

main.py

#!/usr/bin/env python3

import argparse
import asyncio
import logging
import signal
from monitor import Monitor

def setup_logging(level: str):
    numeric = getattr(logging, level.upper(), None)
    if not isinstance(numeric, int):
        numeric = logging.INFO
    logging.basicConfig(level=numeric, format="%(asctime)s %(levelname)s: %(message)s")

async def main():
    parser = argparse.ArgumentParser(description="Disconnect idle PS3/PS4 Bluetooth controllers")
    parser.add_argument("--timeout-minutes", type=int, default=10, help="Idle timeout in minutes")
    parser.add_argument("--log-level", default="INFO", help="Logging level (DEBUG, INFO, WARNING, ERROR)")
    args = parser.parse_args()

    setup_logging(args.log_level)
    monitor = Monitor(args.timeout_minutes)
    loop = asyncio.get_event_loop()

    def stop(signum, frame):
        logging.info("Received signal %s — shutting down", signum)
        loop.call_soon_threadsafe(monitor.stop)

    signal.signal(signal.SIGTERM, stop)
    signal.signal(signal.SIGINT, stop)


    logging.info("Starting monitor (timeout=%d minutes)", args.timeout_minutes)
    try:
        await monitor.start()
    finally:
        monitor.stop()

def start():
    asyncio.run(main())

monitor.py

from controller import Controller

class Monitor:
    def __init__(self, timeout_minutes):
.....

default.nix

{ nixpkgs ? import <nixpkgs> {}, pythonPkgs ? nixpkgs.pkgs.python3Packages }:

let
  # This takes all Nix packages into this scope
  inherit (nixpkgs) pkgs;
  # This takes all Python packages from the selected version into this scope.
  inherit pythonPkgs;

  # Inject dependencies into the build function
  f = { buildPythonPackage, evdev, buildtools }:
    buildPythonPackage rec {
      pname = "monitor-controllers";
      version = "0.0.1";

      src = pkgs.nix-gitignore.gitignoreSource [ ] ./.;
      format = "pyproject";
      propagatedBuildInputs = [ evdev setuptools ];

      meta = {
        description = ''
          Disconnect idle PS3/PS4 Bluetooth controllers
        '';
      };
    };

  drv = pythonPkgs.callPackage f {};
in
  if pkgs.lib.inNixShell then drv.env else drv

service.nix

{ config, lib, pkgs, ... }:

let
    monitor-controllers = pkgs.callPackage ./default.nix {};
    cfg = config.services.monitor-controllers;
in {

    options.services.monitor-controllers.log-level = lib.mkOption {
        type = lib.types.str;
        default = "warning";
        example = "debug";
    };
    options.services.monitor-controllers.timeout-minutes = lib.mkOption {
        type = lib.types.int;
        default = 5;
    };
    # Everything that should be done when/if the service is enabled
    config = lib.mkIf cfg.enable {
        systemd.services.monitor-controllers = {
            description = "Disconnect idle PS3/PS4 Bluetooth controllers";
            environment = {
                PYTHONUNBUFFERED = "1";
            };
            after = [ "multi-user.target.target" ];
            wantedBy = [ "multi-user.target.target" ];

            serviceConfig = {
                ExecStart = "${monitor-controllers}/bin/app --timeout-minutes ${toString cfg.log-level} --log_level ${toString cfg.timeout-minutes}";
                Restart = "on-failure";
                RestartSec = "5";
                User = "root";
            };
        };
    };
}

app (Generated when i build?)

#! /nix/store/cfqbabpc7xwg8akbcchqbq3cai6qq2vs-bash-5.2p37/bin/bash -e
PATH=${PATH:+':'$PATH':'}
PATH=${PATH/':''/nix/store/f9jsrvkjrr1lj3ldv5vlhkjvk7kvjn4l-python3.12-monitor-controllers-0.0.1/bin'':'/':'}
PATH='/nix/store/f9jsrvkjrr1lj3ldv5vlhkjvk7kvjn4l-python3.12-monitor-controllers-0.0.1/bin'$PATH
PATH=${PATH#':'}
PATH=${PATH%':'}
export PATH
PATH=${PATH:+':'$PATH':'}
PATH=${PATH/':''/nix/store/vxl8pzgkkw8vdb4agzwm58imrfclmfrx-python3-3.12.11/bin'':'/':'}
PATH='/nix/store/vxl8pzgkkw8vdb4agzwm58imrfclmfrx-python3-3.12.11/bin'$PATH
PATH=${PATH#':'}
PATH=${PATH%':'}
export PATH
export PYTHONNOUSERSITE='true'
exec -a "$0" "/nix/store/f9jsrvkjrr1lj3ldv5vlhkjvk7kvjn4l-python3.12-monitor-controllers-0.0.1/bin/.app-wrapped"  "$@"

.app-wrapped (Generated when i build?)

#!/nix/store/vxl8pzgkkw8vdb4agzwm58imrfclmfrx-python3-3.12.11/bin/python3.12
# -*- coding: utf-8 -*-
import sys;import site;import functools;sys.argv[0] = '/nix/store/f9jsrvkjrr1lj3ldv5vlhkjvk7kvjn4l-python3.12-monitor-controllers-0.0.1/bin/app';functools.reduce(lambda k, p: site.addsitedir(p, k), ['/nix/store/f9jsrvkjrr1lj3ldv5vlhkjvk7kvjn4l-python3.12-monitor-controllers-0.0.1/lib/python3.12/site-packages','/nix/store/wrasigx2g79vfzsy2ygkcdn453lh3ccz-python3.12-evdev-1.9.2/lib/python3.12/site-packages','/nix/store/dklqz633x8big8lgqifpj97f8hd7v6b8-python3.12-setuptools-78.1.1/lib/python3.12/site-packages'], site._init_pathinfo());
import re
import sys
from app.main import start
if __name__ == "__main__":
    sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
    sys.exit(start())

after importing service.nix I can add the following to my config to enable the service

  services.monitor-controllers = {
    enable = true;
    log-level = "debug";
    timeout-minutes = 1;
  };

Sorry for the wall of text, the idea was to upload the code to a git repo but github seems to have some issues currently.

Does it work if you change that to from .monitor import Monitor or from app.monitor import Monitor?

That did the trick, Is that how imports are supposed to work in python or did i mess something up in pyproject.toml?

Yes that’s how the import is supposed to look like.

from monitor import Monitor works by accident. You probably have the app folder on your python path in your development environment.

On a second look I think

[tool.setuptools]
packages = ["app"]

might be what let you import monitor directly. I’m not all that familiar with setuptools.

Thanks for the help, I was pulling my hair out yesterday!