Entwicklung robuster Python-Skripte

Python ist eine Programmiersprache, die sich hervorragend für die Entwicklung eigenständiger Skripte eignet. Um mit einem ähnlichen Skript das gewünschte Ergebnis zu erzielen, müssen Sie mehrere zehn oder hundert Codezeilen schreiben. Und nachdem die Arbeit erledigt ist, können Sie einfach den geschriebenen Code vergessen und mit der Lösung des nächsten Problems fortfahren.

Wenn beispielsweise sechs Monate nach dem Schreiben eines bestimmten „einmaligen“ Skripts jemand den Autor fragt, warum dieses Skript fehlschlägt, ist dem Autor des Skripts dies möglicherweise nicht bekannt. Dies geschieht, weil das Skript keine Dokumentation geschrieben hat, weil Parameter verwendet werden, die im Code fest codiert sind, weil das Skript während der Arbeit nichts protokolliert und weil keine Tests zulässig sind um die Ursache des Problems schnell zu verstehen.



Es sollte beachtet werden, dass es nicht so schwierig ist, ein in Eile geschriebenes Skript in etwas viel Besseres zu verwandeln. Ein solches Skript lässt sich nämlich recht einfach in einen zuverlässigen und verständlichen Code verwandeln, der bequem zu verwenden ist, in einen Code, der sowohl den Autor als auch andere Programmierer einfach unterstützt.

Der Autor des Materials, dessen Übersetzung wir heute veröffentlichen, wird eine solche „Transformation“ am Beispiel des klassischen Fizz Buzz-Tests demonstrieren. Diese Aufgabe besteht darin, eine Liste mit Zahlen von 1 bis 100 anzuzeigen und einige davon durch spezielle Zeilen zu ersetzen. Wenn die Zahl also ein Vielfaches von 3 ist - stattdessen müssen Sie die Zeichenfolge Fizz drucken, wenn die Zahl ein Vielfaches von 5 ist - die Zeichenfolge Buzz , und wenn beide Bedingungen erfüllt sind - FizzBuzz .

Quellcode


Hier ist der Quellcode für ein Python-Skript, das das Problem löst:

 import sys for n in range(int(sys.argv[1]), int(sys.argv[2])):    if n % 3 == 0 and n % 5 == 0:        print("fizzbuzz")    elif n % 3 == 0:        print("fizz")    elif n % 5 == 0:        print("buzz")    else:        print(n) 

Sprechen wir darüber, wie wir es verbessern können.

Die Dokumentation


Ich finde es hilfreich, vor dem Schreiben von Code Dokumentation zu schreiben. Dies vereinfacht die Arbeit und hilft, die Erstellung der Dokumentation nicht auf unbestimmte Zeit zu verzögern. Die Dokumentation für das Skript kann oben platziert werden. Zum Beispiel könnte es so aussehen:

 #!/usr/bin/env python3 """Simple fizzbuzz generator. This script prints out a sequence of numbers from a provided range with the following restrictions: - if the number is divisible by 3, then print out "fizz", - if the number is divisible by 5, then print out "buzz", - if the number is divisible by 3 and 5, then print out "fizzbuzz". """ 

Die erste Zeile enthält eine kurze Beschreibung des Zwecks des Skripts. Die verbleibenden Absätze enthalten zusätzliche Informationen zur Funktionsweise des Skripts.

Befehlszeilenargumente


Die nächste Aufgabe zur Verbesserung des Skripts besteht darin, die im Code fest codierten Werte durch die dokumentierten Werte zu ersetzen, die über Befehlszeilenargumente an das Skript übergeben werden. Dies kann mit dem argparse- Modul erfolgen. In unserem Beispiel empfehlen wir dem Benutzer, einen Zahlenbereich anzugeben und die Werte für "Fizz" und "Buzz" anzugeben, die beim Überprüfen von Zahlen aus dem angegebenen Bereich verwendet werden.

 import argparse import sys class CustomFormatter(argparse.RawDescriptionHelpFormatter,                      argparse.ArgumentDefaultsHelpFormatter):    pass def parse_args(args=sys.argv[1:]):    """Parse arguments."""    parser = argparse.ArgumentParser(        description=sys.modules[__name__].__doc__,        formatter_class=CustomFormatter)    g = parser.add_argument_group("fizzbuzz settings")    g.add_argument("--fizz", metavar="N",                   default=3,                   type=int,                   help="Modulo value for fizz")    g.add_argument("--buzz", metavar="N",                   default=5,                   type=int,                   help="Modulo value for buzz")    parser.add_argument("start", type=int, help="Start value")    parser.add_argument("end", type=int, help="End value")    return parser.parse_args(args) options = parse_args() for n in range(options.start, options.end + 1):    # ... 

Diese Änderungen sind für das Skript von großem Nutzen. Die Parameter sind jetzt ordnungsgemäß dokumentiert. Sie können ihren Zweck mit dem Flag --help . Darüber hinaus wird gemäß dem entsprechenden Befehl auch die Dokumentation angezeigt, die wir im vorherigen Abschnitt geschrieben haben:

 $ ./fizzbuzz.py --help usage: fizzbuzz.py [-h] [--fizz N] [--buzz N] start end Simple fizzbuzz generator. This script prints out a sequence of numbers from a provided range with the following restrictions: - if the number is divisible by 3, then print out "fizz", - if the number is divisible by 5, then print out "buzz", - if the number is divisible by 3 and 5, then print out "fizzbuzz". positional arguments:  start     Start value  end      End value optional arguments:  -h, --help  show this help message and exit fizzbuzz settings:  --fizz N   Modulo value for fizz (default: 3)  --buzz N   Modulo value for buzz (default: 5) 

Das argparse Modul ist ein sehr leistungsfähiges Werkzeug. Wenn Sie nicht damit vertraut sind, ist es hilfreich, die Dokumentation zu lesen. Insbesondere gefällt mir seine Fähigkeit, Unterbefehle und Argumentgruppen zu definieren.

Protokollierung


Wenn Sie das Skript mit der Möglichkeit ausstatten, während der Ausführung einige Informationen anzuzeigen, stellt sich heraus, dass dies eine angenehme Ergänzung seiner Funktionalität darstellt. Das Protokollierungsmodul ist für diesen Zweck gut geeignet. Zunächst beschreiben wir ein Objekt, das die Protokollierung implementiert:

 import logging import logging.handlers import os import sys logger = logging.getLogger(os.path.splitext(os.path.basename(sys.argv[0]))[0]) 

Anschließend können Sie die Details der während der Protokollierung angezeigten Informationen steuern. Der Befehl logger.debug() sollte also nur dann etwas ausgeben, wenn das Skript mit dem Schalter --debug ausgeführt wird. Wenn das Skript mit dem --silent , sollte das Skript nur Ausnahmemeldungen anzeigen. parse_args() den folgenden Code hinzu, um diese Funktionen zu implementieren:

 #  parse_args() g = parser.add_mutually_exclusive_group() g.add_argument("--debug", "-d", action="store_true",               default=False,               help="enable debugging") g.add_argument("--silent", "-s", action="store_true",               default=False,               help="don't log to console") 

Fügen Sie dem Projektcode die folgende Funktion hinzu, um die Protokollierung zu konfigurieren:

 def setup_logging(options):    """Configure logging."""    root = logging.getLogger("")    root.setLevel(logging.WARNING)    logger.setLevel(options.debug and logging.DEBUG or logging.INFO)    if not options.silent:        ch = logging.StreamHandler()        ch.setFormatter(logging.Formatter(            "%(levelname)s[%(name)s] %(message)s"))        root.addHandler(ch) 

Der Hauptskriptcode ändert sich wie folgt:

 if __name__ == "__main__":    options = parse_args()    setup_logging(options)    try:        logger.debug("compute fizzbuzz from {} to {}".format(options.start,                                                             options.end))        for n in range(options.start, options.end + 1):            # ..    except Exception as e:        logger.exception("%s", e)        sys.exit(1)    sys.exit(0) 

Wenn Sie das Skript ohne direkte Beteiligung des Benutzers ausführen crontab , z. B. mithilfe von crontab , können Sie die Ausgabe an syslog :

 def setup_logging(options):    """Configure logging."""    root = logging.getLogger("")    root.setLevel(logging.WARNING)    logger.setLevel(options.debug and logging.DEBUG or logging.INFO)    if not options.silent:        if not sys.stderr.isatty():            facility = logging.handlers.SysLogHandler.LOG_DAEMON            sh = logging.handlers.SysLogHandler(address='/dev/log',                                                facility=facility)            sh.setFormatter(logging.Formatter(                "{0}[{1}]: %(message)s".format(                    logger.name,                    os.getpid())))            root.addHandler(sh)        else:            ch = logging.StreamHandler()            ch.setFormatter(logging.Formatter(                "%(levelname)s[%(name)s] %(message)s"))            root.addHandler(ch) 

In unserem kleinen Skript scheint eine ähnliche Menge an Code erforderlich zu sein, um nur den Befehl logger.debug() . In echten Skripten wird dieser Code jedoch nicht mehr so ​​aussehen und der Nutzen davon wird in den Vordergrund rücken, nämlich dass Benutzer mit seiner Hilfe in der Lage sein werden, den Fortschritt der Problemlösung herauszufinden.

 $ ./fizzbuzz.py --debug 1 3 DEBUG[fizzbuzz] compute fizzbuzz from 1 to 3 1 2 fizz 

Tests


Unit-Tests sind ein nützliches Tool, um zu überprüfen, ob sich Anwendungen so verhalten, wie sie sollten. Einheitenskripte werden in Skripten selten verwendet, aber ihre Aufnahme in Skripte verbessert die Codezuverlässigkeit erheblich. Wir wandeln den Code innerhalb der Schleife in eine Funktion um und beschreiben in seiner Dokumentation mehrere interaktive Beispiele für seine Verwendung:

 def fizzbuzz(n, fizz, buzz):    """Compute fizzbuzz nth item given modulo values for fizz and buzz.    >>> fizzbuzz(5, fizz=3, buzz=5)    'buzz'    >>> fizzbuzz(3, fizz=3, buzz=5)    'fizz'    >>> fizzbuzz(15, fizz=3, buzz=5)    'fizzbuzz'    >>> fizzbuzz(4, fizz=3, buzz=5)    4    >>> fizzbuzz(4, fizz=4, buzz=6)    'fizz'    """    if n % fizz == 0 and n % buzz == 0:        return "fizzbuzz"    if n % fizz == 0:        return "fizz"    if n % buzz == 0:        return "buzz"    return n 

Sie können den korrekten Betrieb der Funktion mit pytest :

 $ python3 -m pytest -v --doctest-modules ./fizzbuzz.py ============================ test session starts ============================= platform linux -- Python 3.7.4, pytest-3.10.1, py-1.8.0, pluggy-0.8.0 -- /usr/bin/python3 cachedir: .pytest_cache rootdir: /home/bernat/code/perso/python-script, inifile: plugins: xdist-1.26.1, timeout-1.3.3, forked-1.0.2, cov-2.6.0 collected 1 item fizzbuzz.py::fizzbuzz.fizzbuzz PASSED                 [100%] ========================== 1 passed in 0.05 seconds ========================== 

Damit dies alles funktioniert, benötigen Sie die Erweiterung .py , die nach dem Skriptnamen steht. Ich mag es nicht, Erweiterungen zu Skriptnamen hinzuzufügen: Sprache ist nur ein technisches Detail, das dem Benutzer nicht angezeigt werden muss. Es scheint jedoch, dass das Ausstatten eines pytest mit einer Erweiterung der einfachste Weg ist, Systeme zum Ausführen von Tests wie pytest die im Code enthaltenen Tests finden zu lassen.

Wenn ein Fehler pytest eine Meldung an, die den Speicherort des entsprechenden Codes und die Art des Problems angibt:

 $ python3 -m pytest -v --doctest-modules ./fizzbuzz.py -k fizzbuzz.fizzbuzz ============================ test session starts ============================= platform linux -- Python 3.7.4, pytest-3.10.1, py-1.8.0, pluggy-0.8.0 -- /usr/bin/python3 cachedir: .pytest_cache rootdir: /home/bernat/code/perso/python-script, inifile: plugins: xdist-1.26.1, timeout-1.3.3, forked-1.0.2, cov-2.6.0 collected 1 item fizzbuzz.py::fizzbuzz.fizzbuzz FAILED                 [100%] ================================== FAILURES ================================== ________________________ [doctest] fizzbuzz.fizzbuzz _________________________ 100 101   >>> fizzbuzz(5, fizz=3, buzz=5) 102   'buzz' 103   >>> fizzbuzz(3, fizz=3, buzz=5) 104   'fizz' 105   >>> fizzbuzz(15, fizz=3, buzz=5) 106   'fizzbuzz' 107   >>> fizzbuzz(4, fizz=3, buzz=5) 108   4 109   >>> fizzbuzz(4, fizz=4, buzz=6) Expected:    fizz Got:    4 /home/bernat/code/perso/python-script/fizzbuzz.py:109: DocTestFailure ========================== 1 failed in 0.02 seconds ========================== 

Unit-Tests können auch als regulärer Code geschrieben werden. Stellen Sie sich vor, wir müssen die folgende Funktion testen:

 def main(options):    """Compute a fizzbuzz set of strings and return them as an array."""    logger.debug("compute fizzbuzz from {} to {}".format(options.start,                                                         options.end))    return [str(fizzbuzz(i, options.fizz, options.buzz))            for i in range(options.start, options.end+1)] 

Am Ende des Skripts fügen wir die folgenden pytest hinzu, die die pytest für die Verwendung parametrisierter Testfunktionen verwenden :

 #   import pytest          # noqa: E402 import shlex          # noqa: E402 @pytest.mark.parametrize("args, expected", [    ("0 0", ["fizzbuzz"]),    ("3 5", ["fizz", "4", "buzz"]),    ("9 12", ["fizz", "buzz", "11", "fizz"]),    ("14 17", ["14", "fizzbuzz", "16", "17"]),    ("14 17 --fizz=2", ["fizz", "buzz", "fizz", "17"]),    ("17 20 --buzz=10", ["17", "fizz", "19", "buzz"]), ]) def test_main(args, expected):    options = parse_args(shlex.split(args))    options.debug = True    options.silent = True    setup_logging(options)    assert main(options) == expected 

Bitte beachten Sie, dass Tests nicht ausgeführt werden, wenn der sys.exit() mit einem Aufruf von sys.exit() endet, wenn er normal aufgerufen wird. Aus diesem pytest nicht benötigt, um das Skript auszuführen.

Die Testfunktion wird für jede Parametergruppe einmal aufgerufen. Die args Entität wird als Eingabe für die Funktion parse_args() verwendet. Dank dieses Mechanismus erhalten wir das, was wir zur Übergabe an die main() Funktion benötigen. Die expected Entität wird mit der main() verglichen. pytest sagt uns pytest , wenn alles wie erwartet funktioniert:

 $ python3 -m pytest -v --doctest-modules ./fizzbuzz.py ============================ test session starts ============================= platform linux -- Python 3.7.4, pytest-3.10.1, py-1.8.0, pluggy-0.8.0 -- /usr/bin/python3 cachedir: .pytest_cache rootdir: /home/bernat/code/perso/python-script, inifile: plugins: xdist-1.26.1, timeout-1.3.3, forked-1.0.2, cov-2.6.0 collected 7 items fizzbuzz.py::fizzbuzz.fizzbuzz PASSED                 [ 14%] fizzbuzz.py::test_main[0 0-expected0] PASSED              [ 28%] fizzbuzz.py::test_main[3 5-expected1] PASSED              [ 42%] fizzbuzz.py::test_main[9 12-expected2] PASSED             [ 57%] fizzbuzz.py::test_main[14 17-expected3] PASSED             [ 71%] fizzbuzz.py::test_main[14 17 --fizz=2-expected4] PASSED        [ 85%] fizzbuzz.py::test_main[17 20 --buzz=10-expected5] PASSED        [100%] ========================== 7 passed in 0.03 seconds ========================== 

Wenn ein Fehler auftritt, pytest nützliche Informationen darüber, was passiert ist:

 $ python3 -m pytest -v --doctest-modules ./fizzbuzz.py [...] ================================== FAILURES ================================== __________________________ test_main[0 0-expected0] __________________________ args = '0 0', expected = ['0']    @pytest.mark.parametrize("args, expected", [        ("0 0", ["0"]),        ("3 5", ["fizz", "4", "buzz"]),        ("9 12", ["fizz", "buzz", "11", "fizz"]),        ("14 17", ["14", "fizzbuzz", "16", "17"]),        ("14 17 --fizz=2", ["fizz", "buzz", "fizz", "17"]),        ("17 20 --buzz=10", ["17", "fizz", "19", "buzz"]),    ])    def test_main(args, expected):        options = parse_args(shlex.split(args))        options.debug = True        options.silent = True        setup_logging(options)       assert main(options) == expected E    AssertionError: assert ['fizzbuzz'] == ['0'] E     At index 0 diff: 'fizzbuzz' != '0' E     Full diff: E     - ['fizzbuzz'] E     + ['0'] fizzbuzz.py:160: AssertionError ----------------------------- Captured log call ------------------------------ fizzbuzz.py        125 DEBUG compute fizzbuzz from 0 to 0 ===================== 1 failed, 6 passed in 0.05 seconds ===================== 

Die Ausgabe des logger.debug() ist logger.debug() in dieser Ausgabe enthalten. Dies ist ein weiterer guter Grund, Protokollierungsmechanismen in Skripten zu verwenden. Wenn Sie mehr über die großartigen Eigenschaften von pytest erfahren pytest , schauen Sie sich dieses Material an.

Zusammenfassung


Sie können Python-Skripte zuverlässiger machen, indem Sie die folgenden vier Schritte ausführen:

  • Rüsten Sie das Skript mit der Dokumentation aus, die sich oben in der Datei befindet.
  • Verwenden Sie das argparse Modul, um die Parameter zu dokumentieren, mit denen das Skript aufgerufen werden kann.
  • Verwenden Sie das logging , um Informationen zum logging anzuzeigen.
  • Schreiben Sie Unit-Tests.

Hier ist der vollständige Code für das hier beschriebene Beispiel. Sie können es als Vorlage für Ihre eigenen Skripte verwenden.

Interessante Diskussionen rund um dieses Material haben begonnen - Sie finden sie hier und hier . Das Publikum hat anscheinend Empfehlungen zur Dokumentation und zu Befehlszeilenargumenten gut aufgenommen, aber was ist mit Protokollierung und Tests, die einigen Lesern als "Schuss aus einer Kanone auf Spatzen" erschienen. Hier ist das Material, das als Antwort auf diesen Artikel geschrieben wurde.

Liebe Leser! Planen Sie, die in dieser Veröffentlichung enthaltenen Empfehlungen zum Schreiben von Python-Skripten anzuwenden?

Source: https://habr.com/ru/post/de462007/


All Articles