Ausführen von Shell-Befehlen in Python

Python ist eine beliebte Wahl, wenn es darum geht, alles Mögliche zu automatisieren. Dazu gehört auch die Automatisierung von Systemverwaltungsaufgaben oder Aufgaben, die die Ausführung anderer Programme oder die Interaktion mit dem Betriebssystem erfordern. Es gibt jedoch viele Möglichkeiten, dies in Python zu erreichen, von denen die meisten wohl eher schlecht sind.

In diesem Artikel werden wir uns veschiedene Möglichkeiten ansehen, die Python anbietet, um andere Prozesse auszuführen – den schlechten, den guten und vor allem den richtigen Weg, es zu tun.

Die Möglichkeiten

Python hat viel zu viele built-in Optionen für die Zusammenarbeit mit anderen Programmen, einige von ihnen sind besser, einige schlechter, und eine einzige optimale Lösung gibt es leider nicht. Werfen wir einen kurzen Blick auf jede Option und sehen, wann (wenn überhaupt) es Sinn ergibt, das jeweilige Modul zu verwenden.

Native Werkzeuge

Als Faustregel gilt, dass man native Funktionen verwenden sollte, anstatt andere Programme oder Betriebssystembefehle direkt aufzurufen. Schauen wir uns also zunächst die nativen Python-Optionen an:

pathlib – Wenn man eine Datei bzw. ein Verzeichnis erstellen oder löschen will, prüfen will, ob eine Datei existiert, die Zugriffsrechte ändern will usw., gibt es keinen Grund, Systembefehle auszuführen, man verwendet einfach pathlib, es hat alles, was man braucht. Wenn man anfängt, pathlib zu benutzen, wird man auch feststellen, dass man andere Python-Module wie glob oder os.path vergessen kann.

tempfile – Wenn man eine temporäre Datei benötiget, verwendet man einfach das Modul tempfile, ohne sich manuell mit /tmp herumschlagen zu müssen.

shutil – pathlib sollte die meisten der dateibezogenen Bedürfnisse in Python befriedigen, aber wenn man z.B. kopieren, verschieben, chown, which oder ein Archiv erstellen muss, dann sollte man sich an shutil wenden.

signal – für den Fall, dass man Signalhandler verwenden muss.

syslog – für eine Schnittstelle zu Unix syslog.

Wenn keine der oben genannten eingebauten Optionen die Bedürfnisse befriedigt, ist es nur dann sinnvoll, direkt mit dem Betriebssystem oder anderen Programmen zu interagieren…

os-Modul

Ausgehend von den schlechtesten Optionen – dem os-Modul – bietet es Low-Level-Funktionen für die Interaktion mit dem Betriebssystem, von denen viele durch Funktionen in anderen Modulen ersetzt wurden.

Wenn man einfach ein anderes Programm aufrufen will, kann man die Funktion os.system verwenden, aber das sollte man nicht!

Obwohl os nicht die erste Wahl sein sollte, gibt es ein paar Funktionen, die in manchen Fällen eventuell nützlich sein könnten:

import os

print(os.getenv('PATH'))
# /home/jereczet/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:...

print(os.uname())
# posix.uname_result(sysname='Linux', nodename='...', release='...', version='...', machine='x86_64')

print(os.times())
# posix.times_result(user=0.02, system=0.0, children_user=0.0, children_system=0.0, elapsed=17192985.97)

print(os.cpu_count())
# 3

print(os.getloadavg())
# (0.251953125, 0.22314453125, 0.19091796875)

Neben der oben gezeigten Funktion gibt es auch Funktionen zum Erstellen von fd (Dateideskriptoren), Pipes, Öffnen von PTY, chroot, chmod, mkdir, kill, stat, aber davon ist abzuraten, da es bessere Optionen gibt. Es gibt sogar einen Abschnitt in der Dokumentation, der zeigt, wie man os durch das Modul subprocess ersetzt, also sollte man nicht einmal daran, os.popen, os.spawn oder os.system zu benutzen.

Das gleiche gilt für die Verwendung des os-Moduls für Datei-/Pfadoperationen – bitte nicht verwenden! Es gibt einen Abschnitt darüber, wie man pathlib anstelle von os.path und anderen pfadbezogenen Funktionen verwendet.

Die meisten der verbleibenden Funktionen im os-Modul sind direkte Schnittstellen zu OS (oder C-Sprache) API, z.B. os.dup, os.splice, os.mkfifo, os.execv, os.fork, etc. Wenn man alle diese Funktionen verwenden muss, sollte man überlegen, ob Python die richtige Sprache für die vorgesehene Aufgabe ist.

subprocess-Modul

Eine zweite – etwas bessere – Möglichkeit, die wir in Python haben, ist das Modul subprocess:

import subprocess

p = subprocess.run('ls -l', shell=True, check=True, capture_output=True, encoding='utf-8')
print(f'Command {p.args} exited with {p.returncode} code, output: \n{p.stdout}')

Beispielausgabe:

Command ls -l exited with 0 code, output: 
insgesamt 48
drwxrwxr-x 6 jereczet jereczet 4096 Jun 12 11:31 dmbackup
drwxrwxr-x 11 jereczet jereczet 4096 Jun 16 17:52 dmgit
drwxr-xr-x 2 jereczet jereczet 4096 Mai 17 12:16 Dokumente
drwxr-xr-x 3 jereczet jereczet 4096 Jun 23 12:32 Downloads
...

Wie in der Dokumentation angegeben:

„The recommended approach to invoking subprocesses is to use the run() function for all use cases it can handle.“ – Der empfohlene Ansatz für den Aufruf von Unterprozessen ist die Verwendung der Funktion run() für alle Anwendungsfälle, die sie verarbeiten kann.

In den meisten Fällen sollte es ausreichen, subprocess.run zu verwenden und kwargs zu übergeben, um das Verhalten zu ändern, z.B. shell=True erlaubt es, den Befehl als einzelne Zeichenkette zu übergeben, check=True bewirkt, dass eine Exception geworfen wird, wenn der Exit-Code nicht 0 ist, und capture_output=True füllt das stdout-Attribut.

Während subprocess.run() der empfohlene Weg ist, Prozesse aufzurufen, gibt es andere (unnötige, veraltete) Optionen in diesem Modul: call, check_call, check_output, getstatusoutput, getoutput. Im Allgemeinen sollte man nur run und Popen verwenden:

with subprocess.Popen(['ls', '-la'], stdout=subprocess.PIPE, encoding='utf-8') as process:
    # process.wait(timeout=5) # Returns only code: 0
    outs, errs = process.communicate(timeout=5)
    print(f'Command {process.args} exited with {process.returncode} code, output: \n{outs}')

# Pipes
import shlex

ls = shlex.split('ls -la')
awk = shlex.split("awk '{print $9}'")

ls_process = subprocess.Popen(ls, stdout=subprocess.PIPE)
awk_process = subprocess.Popen(awk, stdin=ls_process.stdout, stdout=subprocess.PIPE, encoding='utf-8')

for line in awk_process.stdout:
    print(line.strip())

Das erste Beispiel oben zeigt das Popen-Äquivalent des zuvor gezeigten subprocess.run. Man sollte Popen jedoch nur verwenden, wenn man mehr Flexibilität benötigt, als run bietet. Im zweiten Beispiel sieht man, wie man die Ausgabe eines Befehls in einen anderen leiten kann, indem man ls -la | awk ‚{print $9}‘ ausführt. Man kann auch sehen, dass hier shlex.split verwendet wird, eine Komfortfunktion, die die Zeichenkette in ein Array von Token aufteilt, die an Popen oder run übergeben werden können, ohne shell=True zu verwenden.

Wenn man Popen verwendet, kann man zusätzlich terminate(), kill() und send_signal() für weitere Interaktionen mit dem Prozess verwenden.

In den vorangegangenen Beispielen haben wir uns nicht wirklich um die Fehlerbehandlung gekümmert, aber beim Ausführen anderer Prozesse kann eine Menge schief gehen. Für einfaches Skripting ist check=True wahrscheinlich ausreichend, da es CalledProcessError auslöst, sobald der Subprozess auf einen Rückgabewert ungleich Null trifft, so dass das Programm schnell und laut scheitern wird, was gut ist. Wenn man zusätzlich das Argument timeout setzt, kann man auch die Ausnahme TimeoutExpired erhalten, aber im Allgemeinen erben alle Ausnahmen im Subprocess-Modul von SubprocessError, wenn man also Ausnahmen abfangen will, kann man einfach auf SubprocessError achten.

Der richtige Weg?

Zen von Python besagt, dass:

„There should be one– and preferably only one –obvious way to do it.“ – Es sollte einen – und vorzugsweise nur einen – offensichtlichen Weg geben, dies zu tun.

Aber bis jetzt haben wir schon einige Wege gesehen, alle in Pythons eingebauten Modulen. Welcher ist denn nun der richtige? 

Das ist nicht einfach zu beantworten.

Obwohl die Standardbibliothek von Python bereits einiges anbieten, sieht es doch so aus, dass ein besseres Subprozessmodul benötigt wird.
Wenn man viele andere Prozesse in Python verwalten muss, dann sollte man zumindest einen Blick auf die sh-Bibliothek werfen, die über pip installiert werden kann (pip install sh).

Wenn man sh.some_command aufruft, versucht die sh-Bibliothek, nach einem eingebauten Shell-Befehl oder einer Binärdatei mit diesem Namen in $PATH zu suchen. Wenn sie einen solchen Befehl findet, führt sie ihn einfach aus. Wenn das Kommando nicht im $PATH ist, kann man eine Instanz von Command erstellen und es auf diese Weise aufrufen. Falls man sudo verwenden muss, kann man den sudo-Kontextmanager aus dem contrib-Modul verwenden. So einfach und geradlinig, richtig?

import sh

# startet Befehl aus $PATH
print(sh.ls('-la'))

# Befehl wird direkt aufgerufen
ls_cmd = sh.Command('ls')
print(ls_cmd('-la'))

Ausgabe:

total 36
drwxrwxr-x 2 martin martin 4096 apr 8 14:18 .
drwxrwxr-x 41 martin martin 20480 apr 7 15:23 ..
-rw-rw-r-- 1 martin martin 30 apr 8 14:18 examples.py
# falls eigener Befehl nicht in $PATH:
custom_cmd = sh.Command('/path/to/my/cmd')
custom_cmd('some', 'args')

with sh.contrib.sudo:
    # hier Befehle ausführen, die 'sudo' benötigen ...
    ...

Um die Ausgabe eines Befehls in eine Datei zu schreiben, muss man der Funktion nur das Argument _out übergeben:

sh.ip.address(_out='/tmp/ipaddr')
# identisch mit 'ip address > /tmp/ipaddr'

Das obige Beispiel zeigt auch, wie man Unterbefehle aufruft – man verwendet einfach Punkte.

Und schließlich kann man auch Pipes (|) verwenden, indem man das Argument _in verwendet:

print(sh.awk('{print $9}', _in=sh.ls('-la')))
# identisch mit "ls -la | awk '{print $9}'"

print(sh.wc('-l', _in=sh.ls('.', '-1')))
# identisch mit "ls -1 | wc -l"

Was die Fehlerbehandlung betrifft, so kann man einfach die Ausnahmen ErrorReturnCode oder TimeoutException abfangen:

try:
    sh.cat('/tmp/doesnt/exist')
except sh.ErrorReturnCode as e:
    print(f'Command {e.full_cmd} exited with {e.exit_code}')
    # Command /usr/bin/cat /tmp/doesnt/exist exited with 1

curl = sh.curl('https://httpbin.org/delay/5', _bg=True)

try:
    curl.wait(timeout=3)
except sh.TimeoutException:
    print("Command timed out...")
    curl.kill()

Wenn der Prozess durch ein Signal beendet wird, erhält man optional eine SignalException. Man kann auf ein bestimmtes Signal z.B. mit SignalException_SIGKILL (oder _SIGTERM, _SIGSTOP, …) prüfen.

Diese Bibliothek hat auch eine eingebaute Logging-Unterstützung, die man nur noch einschalten muss:

import logging

# default logging:
logging.basicConfig(level=logging.INFO)
sh.ls('-la')

# INFO:sh.command:<Command '/usr/bin/ls -la', pid 1631463>: process started


# log level ändern:
logging.getLogger('sh').setLevel(logging.DEBUG)
sh.ls('-la')

# INFO:sh.command:<Command '/usr/bin/ls -la', pid 1631661>: process started
# DEBUG:sh.command:<Command '/usr/bin/ls -la'>: starting process
# DEBUG:sh.command.process:<Command '/usr/bin/ls -la'>.<Process 1631666 ['/usr/bin/ls', '-la']>: started process
# ...

Die obigen Beispiele sollten die meisten Anwendungsfälle abdecken, aber wenn man mehr fortgeschrittene / obskure Möglichkeiten braucht, dann schaut man sich Tutorials oder die FAQ in der Bibliothek docs an, die zusätzliche Beispiele hat.

Abschliessende Bemerkung

Abschliessend ist noch einmal zu betonen, dass man immer native Python-Funktionen bevorzugen sollte, anstatt auf Systembefehle zurückzugreifen. Außerdem sollte man immer Client-Bibliotheken von Drittanbietern wie den Kubernetes-Client oder das SDK des Cloud-Anbieters verwenden, anstatt CLI-Befehle direkt auszuführen. Das gilt auch dann, wenn man aus dem SysAdmin-Umfeld kommt und sich mit der Shell besser auskennt als mit Python. Und schließlich ist Python zwar eine großartige und viel robustere Sprache als Shell, aber wenn man zu viele andere Programme/Befehle aneinanderreihen muss, sollte man vielleicht, stattdessen einfach ein Shell-Skript schreiben.