Using a simple python script in NixOS as systemd service without packaging it

l33tname asked me what the "correct" way is to start a small python script as a systemd service under NixOS. The script comes with a small set of standard dependencies which of course are not available with the standard python interpreter

Well it turns out there are a couple of ways to do this and there is currently no consens in the community what should be used.

Lets start wit the following script:

# !/usr/bin/env python3
""" usage: download-ticker.py [--daemon] [OUTDIR]
"""
import requests
import yaml
from time import sleep
from pathlib import Path
from datetime import datetime
from docopt import docopt


def fetch_data(outdir):
  print("Getting data")
  try:
    response = requests.get('https://blockchain.info/ticker')
    data = response.json()
    outfile = outdir / f'{datetime.now()}-ticker.yaml'
    with open(outfile, 'w') as f:
      print(f"Writing data to {outfile}")
      yaml.dump(data, f)
  except Exception as e:
    print(f"fetching failed: {e}, continuing")
  finally:
    print("finished, sleeping")

def main():
  args = docopt(__doc__)
  outdir = Path(args["OUTDIR"] or ".")
  fetch_data(outdir)

  if args["--daemon"]:
    while sleep(60):
      fetch_data(outdir)
  else:
    print("not daemonized")


if __name__ == '__main__':
  main()

The script has two modes, being started as daemon or as a one-shot script and it requires not one, but three python libraries to work: requests, docopt and pyyaml.

Testing the script in the shell

In order to test the script under nixos it is enough to run:

nix-shell -p 'python3.withPackages(ps: with ps; [ requests docopt pyyaml ])' --run 'python3 download-ticker.py'
# or even:
nix-shell -p python3.pkgs.docopt python3.pkgs.requests python3.pkgs.pyyaml
python3 download-ticker.py

Even when just calling the script multiple options are possible, I omit the option to run this script with the nix-shell shebang

Now, how do we get out wonderful script to run as a systemd daemon? For very simple scripts it is often overkill to create a new repo, add a uv config, add a flake config, import and pin the flake in your system flake and then use the package. We could either prepare the python interpreter with the withPackages function similar to the nix-shell command or we can use the nix writers

Lets assume you system is already configured and you just want to include this new service into the configuration:

Service configuration

The ticker-service.nix will have the following content to configure a systemd service:

{ pkgs, ... }:
let
  pkg-bin = "";# ... the magic script path goes here
in
{
  systemd.services.ticker-service = {
    wantedBy = [ "multi-user.target"]; # automatically start the service at boot-time
    serviceConfig = {
      DynamicUser = true; # automatically create a user with the same name as the service
      StateDirectory = "ticker-service"; # ensure /var/lib/ticker-service exists
      ExecStart = "${pkg-bin} --daemon /var/lib/ticker-service"; # start the service
    };
  };
}

With your configuration.nix looking like:

{ pkgs, ... }:
{
 imports = [
   ...
   ./ticker-service.nix
 ];
 ...
}

The download-ticker.py script is residing in the same folder as ticker-service.nix

The only thing missing now is how to get the pkg-bin.

nixpkgs writers

Writers in nixpkgs are relatively new to the ecosystem and provide a simple interface to using scripts directly in your nixos configuration.

With pkgs.writers.writePython3 it looks like this:

{ pkgs, ... }:

{
  pkg-bin = pkgs.writers.writePython3 "download-ticker" {
    libraries = with pkgs.python3.pkgs; [ pyyaml requests docopt ];
    flakeIgnore = [ "E111"  "E302" ]; # writePython3 will refuse to build the script if it does not conform to flake8
  } builtins.readFile ./download-ticker.py;
} in
  ...

python3.withPackages

python3.withPackages allows to create a python interpreter with certain extra packages included. It is a low-level function described in the nixpkgs manual.

{ pkgs, ... }:

{
  py = pkgs.python3.withPackages(ps: with ps; [ requests docopt pyyaml ]);
  pkg-bin = "${py.interpreter} ${./download-ticker.py}";
} in
  ...

Both definitions are functionally identical, they use the python libraries in the version currently defined in your selected nixpkgs channel. However for writers your script must conform to linting demands! This is also true for writeBash

Bonus: start as timer

In order to start the service as a systemd timer the following change is enough:

{
  systemd.services.ticker-service = {
    startAt = "hourly";
    serviceConfig.ExecStart = "${pkg-bin} /var/lib/ticker-service"; # make sure to start as one-shot script
  };

}

With only a single configuration nixos will make sure that a timer is created which triggers the systemd service. Almost as great as enableACME with nginx!

Comments