Python ist eine der populärsten Programmiersprachen – kein Wunder, denn wegen der klaren, eindeutigen Syntax erlernt es sich leicht. Obendrein gibt es Bibliotheken für verschiedenste Anwendungsbereiche, von Machine Learning über Bildbearbeitung bis hin zur Kommandozeile.
Wer 2024 ein Python-Projekt beginnt, sollte definitiv mit Virtual Environments (Venv) arbeiten. Das gilt besonders dann, wenn im Projekt Third-Party-Libraries zum Einsatz kommen sollen. Venvs garantieren, dass sich verschiedene Projekte mit ihren unterschiedlichen Versionen diverser Bibliotheken nicht in die Quere kommen, sondern stattdessen voneinander isoliert koexistieren.
Aus diesem Grund beginnt das Python-Beispielprojekt für diesen Artikel damit, einen neuen Ordner und daraufhin ein virtuelles Environment zu erzeugen (Abbildung 1). Das erledigen die ersten beiden Kommandos aus Listing 1. Im nächsten Schritt wechseln Sie in den neuen Ordner und aktivieren das Venv (Zeile 3 und 4). Wie Abbildung 2 verdeutlicht, verändert das den Bash-Prompt.
Listing 1
Venv
$ mkdir clipy $ python3 -m venv clipy $ cd clippy $ source bin/activate
In der neuen virtuellen Umgebung legen Sie nun ein Python-Skript namens »clipy.py« an und befüllen es mit einem Grundstock an Informationen. Dazu gehört beispielsweise der Header. Er teilt der Shell über die Shebang-Zeichenfolge »#!« mit, dass das vorliegende Skript durch Python ausgeführt werden soll (Listing 2, erste Zeile), dass es sich um UTF-8-kodierten Text handelt (zweite Zeile) und dass Sie die Bibliotheken Os und Sys verwenden möchten (Zeile 4 und 5).
Listing 2
Python-Header
#!/usr/bin/env python$ # -*- coding: utf-8 -*-$ $ import os$ import sys$ def main() -> int: return 0 if __name__ == '__main__': sys.exit(main())
Den Einstiegspunkt in das Skript markiert Zeile 10. Damit weisen Sie den Python-Interpreter an, die Funktion »main()« auszuführen, wenn das Skript im Top-Level-Kontext läuft. Da das Skript als CLI-Utility laufen soll, ist der an die Shell zurückgelieferte Rückgabewert jener, den »main()« liefert (Zeile 11). Um dieses Grundgerüst lauffähigen Python-Codes dazu zu bringen, wie unter Linux und anderen unixoiden Betriebssystemen üblich Daten in einer Pipe entgegenzunehmen, benötigen Sie lediglich Funktionen aus dem Namespace »sys«.
Python macht es Anwendern an dieser Stelle extrem einfach. Die folgende Funktion »main()« aus Listing 3 liest sämtliche Zeilen ein, die über die Standardeingabe kommen (also ebenso über eine Pipe), und gibt sie ohne nachfolgenden Zeilenumbruch »\n« aus. Dafür sorgen Sie, indem Sie die String-Funktion »rstrip()« aufrufen, die jeglichen Whitespace am Ende eines Strings entfernt (Zeile 3).
Listing 3
Standardeingabe
def main() -> int:
for line in sys.stdin:
print(f"{line.rstrip()}")
return 0
Quasi-Sed
Aber damit nicht genug: Das Skript soll nicht nur Daten aus Pipes entgegennehmen können, sondern sich darüber hinaus auch auf Dateien anwenden lassen. Außerdem möchten wir im Datenstrom Veränderungen vornehmen. Alte Linux-Hasen denken jetzt unweigerlich an den Stream-Editor Sed und liegen damit vollkommen richtig. Sed ist ein überaus nützliches Tool und leistet hervorragende Arbeit. Allerdings unterliegt es einer Einschränkung: Es kann keine mehrzeiligen Ersetzungen vornehmen. Das zählt zugegebenermaßen kaum zu den alltäglichen Anwendungsfällen eines Stream-Editors, trotzdem vermisse ich dieses Feature gelegentlich schmerzlich.
Für unser Beispielskript schwebte mir vor, dass es ähnlich wie Sed einen String entgegennimmt, in dem Sie ein Kommando, einen Suchtext (“Needle”) und einen Ersatztext angeben und das Ganze dann auf einen Such-String (“Haystack”) anwenden. Da sich schon abzeichnet, dass hier zusätzliche Optionen und Parameter vorkommen können, muss eine weitere Python-Standardbibliothek mit an Bord: Argparse. Damit gleicht die Verarbeitung von Kommandozeilenparametern einem Kinderspiel. Die Schalter und Optionen lassen sich nicht nur zügig definieren, obendrein schreibt die Bibliothek auch noch die Kurzhilfe wie von selbst.
Dazu müssen Sie Argparse genauso wie schon Sys und Os importieren (Listing 4, erste Zeile). Anschließend erzeugen Sie in Zeile 4 einen »ArgumentParser« namens »parser«, dem Sie einen Programmnamen und eine Beschreibung mitgeben. Ab Zeile 8 definieren Sie den beliebten Schalter »-v«, mit dem die meisten Linux-Tools gesprächiger auf die Konsole loggen. Der Schalter gilt als optional (»required=False«) und wird, falls angegeben, als boolescher Wert abgespeichert. Nach der finalen Auswertung durch den Aufruf der Methode »parse_args()« der Klasse »ArgumentParser« in Zeile 25 können Sie somit problemlos per »if args.verbose« darauf zugreifen. Wenn Sie schon einmal per Getopt in der Bash Kommandozeilenparameter ausgewertet haben, erkennen Sie sofort, wie unkompliziert sich das gestaltet.
Listing 4
Argparse
import argparse
[...]
def main() -> int:
parser = argparse.ArgumentParser(
prog = "clipy",
description = "Eine rudimentäre, exemplarische Sed-Implementierung in Python"
)
parser.add_argument("-v", "--verbose",
help="Erweiterte Ausgabe",
action="store_true",
required=False,
default=False)
parser.add_argument("-V", "--version", action="version",
version=f"%(prog)s {__version__} ({__lastupdate__})")
parser.add_argument("-i", "--in-place",
help="Sollen Änderungen direkt an der Quelldatei stattfinden?",
required=False,
default=False)
parser.add_argument("scriptlet", type=str,
nargs="?",
help="sed-artiges Scriptlet")
parser.add_argument("infiles", nargs="*",
type=argparse.FileType("r"),
default=sys.stdin)
args = parser.parse_args()
[...]
if not os.isatty(0):
for line in sys.stdin:
print(f"{line.rstrip()}")
return 0
[...]
Wer und wo?
Das Python Enhancement Proposal Nummer 8 (PEP8 [1]) definiert sogenannte Dunder Names. Damit kann man Variablen wie »__version__«, »__author__« und so weiter nutzen, die sich anschließend zum Beispiel in Setups weiter auswerten lassen. Ich verwende für diesen Artikel »__version__« und »__lastupdate__« zur Ausgabe der Version mit dem Schalter »-V«.
Dank der mit Python 3.6 eingeführten f-Strings »f”…{Variable}…”« fällt das Einbetten von Variablenwerten in Strings sehr viel leichter. Die Lesbarkeit hat sich dadurch erhöht. Wie Sie in den Zeilen 8, 13 und 15 von Listing 4 deutlich erkennen können, akzeptiert »add_argument« sowohl kurze als auch langformatige Parameter, etwa »-v« und »–verbose«.
Bislang bin ich lediglich auf Named Parameters eingegangen, also Parameter mit Namen. Der Großteil der Werkzeuge aus den Core-Utils erwartet jedoch zusätzlich Positionsparameter. So sieht Cp beispielsweise vor, dass nach den Named Parameters auch Positionsparameter vorkommen. Die leiten Sie nicht etwa durch vorgestellte Namen ein, sondern hängen sie einfach an. Im Fall von Cp entspricht der letzte Positionsparameter dem Kopierziel, die Positionsparameter davor sind die Quelldateien oder -Verzeichnisse.
In meinem Skript definiere ich zwei Positionsparameter. Einen Sed-artigen Ausdruck wie »s/dass/dass/« können Sie über den Parameter »scriptlet« eingeben (Listing 4, Zeile 19). Um mehrere Eingabedateien verarbeiten zu können, legen Sie in den Zeilen 22 bis 24 den Parameter »infiles« fest. Hier setzen Sie zudem »nargs=”*”« ein. Das bedeutet in Anlehnung an die Regex-Syntax, dass der Parameter null Mal oder mehrfach vorkommen darf. Außerdem kommt hier der spezielle Variablentyp »FileType(“r”)« zum Einsatz. Dementsprechend werden alle angegebenen Namen als Dateien direkt im Lese-Modus geöffnet und lassen sich direkt im Skript weiterverarbeiten. Argparse hat abgesehen davon noch einige weitere nützliche Features im Köcher. Diesbezüglich lohnt sich die Lektüre der zugehörigen Dokumentation [2].
Nach der Verarbeitung aller Parameter geht es in Listing 4 ab Zeile 27 mit der Verarbeitung von Daten weiter, die das Skript über Pipes erreichen. Der Befehl »os.isatty(0)« prüft, ob es sich beim File Descriptor 0, der die Standard-Eingabe bezeichnet, um ein TTY handelt, also ein Teletype [3]. Trifft das zu, wurde das Skript selbst ausgeführt und ist kein Teil einer Pipeline. Negiert durch »not«, führt das Programm die For-Schleife ab Zeile 28 ausschließlich dann aus, wenn das Skript zu einer Pipeline gehört. In diesem Fall gibt es ähnlich wie Cat sämtliche Zeilen direkt wieder aus, die per Pipe eintreffen. Statt des einfachen Kommandos »print« wäre in Zeile 29 auch eine Weiterverarbeitung möglich.
Ersetzen!
Da Sed neben dem bekannten Kommando »substitute« (»s«) auch »delete« (»d«) beherrscht, setze ich für das Auswerten des Scriptlets auf eine Klasse, die diese Verarbeitung kapselt. Zur Unterscheidung des Kommandos greifen Sie auf einen Enum-Typ namens »Command« zurück. Enums sind im Modul »enum« definiert, das Sie im ersten Schritt importieren müssen (Listing 5, erste Zeile).
Zur weiteren Verarbeitung zerlege ich das Sed-Scriptlet mithilfe eines Regex [4]. Solche Scriptlets beginnen mit einem Buchstaben, gefolgt von einem Trennzeichen. Im Falle von »substitute« folgen eine Eingangszeichenkette, ein weiteres Trennzeichen, eine Ausgangszeichenkette und gegebenenfalls ein Modifier wie »m« für Multiline-Strings. In der Praxis sprechen wir von Ausdrücken wie »s/alt/neu/g« oder »s#/anderes/trennzeichen#/klappt/ebenfalls#«. Diese Zerlegung klappt mit dem Pattern ab Zeile 6. Mithilfe von benannten Gruppen [5] nach dem Schema »(?P<Name>…)« ermitteln Sie:
- das Kommando »com«, also »S« oder »D«,
- das Trennzeichen »sep«, derzeit begrenzt auf die Zeichen »\/#~_«,
- den zu ersetzenden String »needle«,
- ein weiteres Trennzeichen, das Sie per Back-Reference »\2« festlegen,
- den Ersatz-String »rep« sowie
- das abschließende Trennzeichen und einen optionalen Modifier »mod«.
Nach der Auswertung des Scriptlets, die bereits im Konstruktor »__init__« ab Zeile 10 geschieht, können Sie über Getter-Methoden (Zeile 19ff) auf die entsprechenden Attribute zugreifen, um sie als Teil eines Aufrufs von »re.sub()« ersetzen zu lassen (Zeile 38ff).
Listing 5
CommandParser
from enum import Enum
class CommandParser:
class Command(Enum):
SUBSTITUTE = 1
DELETE = 2
script_pattern = (
r'^(?P<com>[sd])(?P<sep>[\/#~_])'
r'(?P<needle>.*)\2(?P<rep>.*)\2(?P<mod>[mgi]*)$'
)
def __init__(self, scriptlet: str):
reg = re.search(self.script_pattern, scriptlet, flags=0)
if None != reg:
self.set_command(reg["com"])
self.set_needle(reg["needle"])
self.set_replacement(reg["rep"])
self.set_modifiers(reg["mod"])
else:
raise ValueError(f"Fehler in Scriptlet: {scriptlet}")
def get_command(self):
if 'S' == self._command:
return self.Command.SUBSTITUTE
elif 'D' == self._command:
return self.Command.DELETE
else:
raise NotImplementedError
def set_command(self, value):
self._command = value.upper()
def get_needle(self):
return self._needle
def set_needle(self, value):
self._needle = value
[...]
cmd_par = CommandParser(args.scriptlet)
if CommandParser.Command.SUBSTITUTE == cmd_par.get_command():
if type(args.infiles) is list:
for f in args.infiles:
haystack = f.read()
result = re.sub(cmd_par.get_needle(),
cmd_par.get_replacement(),
haystack)
print(result)
[...]
Fazit
Python eignet sich hervorragend dazu, gute Skripte zu schreiben, und das nicht nur für Sysadmins. Module wie Argparse erleichtern leidige Pflichtübungen wie das Verarbeiten von Kommandozeilenparametern erheblich, und dank Features aus dem Os-Modul geht auch das Lesen aus Pipes schnell von der Hand. Zwar ist bei mir nach wie vor die Bash die erste Wahl für Skripting-Jobs, aber spätestens, wenn komplexe Datentypen ins Spiel kommen, lohnt sich der Wechsel zu Python. Das komplette Skript aus dieser Folge von Admin-as-a-Service finden Sie auf Github [6]. (csi)
Infos
- PEP8: https://peps.python.org/pep-0008/#module-level-dunder-names
- Argparse: https://docs.python.org/3/library/argparse.html
- Snapshot: Mike Schilli, “Dressur mit Tiefgang”, LM 06/2021, S. 80, https://www.lm-online.de/44716
- Admin-as-a-Service: Thomas Reuß, “Schreckgespenster”, LM 12/2023, S. 48, https://www.lm-online.de/49801
- Admin-as-a-Service: Thomas Reuß, “Grüppchenbildung”, LM 01/2024, S. 44, https://www.lm-online.de/49937
- Python-Skript zum Artikel: https://www.github.com/treuss/clipy






