Desarrollando scripts robustos de Python

Python es un lenguaje de programación que es excelente para desarrollar scripts independientes. Para lograr el resultado deseado utilizando un script similar, debe escribir varias decenas o cientos de líneas de código. Y una vez realizado el trabajo, simplemente puede olvidarse del código escrito y proceder a la solución del siguiente problema.

Si, por ejemplo, seis meses después de que se escribió un guión "único", alguien le pregunta al autor por qué falla este guión, el autor del guión puede no ser consciente de esto. Esto sucede debido al hecho de que no se escribió documentación para tal secuencia de comandos, debido al uso de parámetros codificados en el código, al hecho de que la secuencia de comandos no registra nada durante la operación, y debido a la falta de pruebas que permitieron para comprender rápidamente la causa del problema.



Cabe señalar que convertir un guión escrito a toda prisa en algo mucho mejor no es tan difícil. Es decir, un script de este tipo es bastante fácil de convertir en un código confiable y comprensible que sea conveniente de usar, en un código que sea simple de soportar tanto para su autor como para otros programadores.

El autor del material, cuya traducción publicamos hoy, demostrará tal "transformación" utilizando la prueba clásica de Fizz Buzz como ejemplo. Esta tarea es mostrar una lista de números del 1 al 100, reemplazando algunos de ellos con líneas especiales. Entonces, si el número es un múltiplo de 3, debe imprimir la línea Fizz lugar, si el número es un múltiplo de 5, la línea Buzz , y si se cumplen ambas condiciones, FizzBuzz .

Código fuente


Aquí está el código fuente de un script de Python que resuelve el problema:

 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) 

Hablemos sobre cómo mejorarlo.

La documentación


Me resulta útil escribir documentación antes de escribir el código. Esto simplifica el trabajo y ayuda a no retrasar la creación de documentación indefinidamente. La documentación para el script se puede colocar en su parte superior. Por ejemplo, podría verse así:

 #!/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". """ 

La primera línea da una breve descripción del propósito del guión. Los párrafos restantes proporcionan información adicional sobre lo que hace el script.

Argumentos de línea de comando


La siguiente tarea para mejorar el script será reemplazar los valores codificados en el código con los valores documentados pasados ​​al script a través de los argumentos de la línea de comandos. Esto se puede hacer usando el módulo argparse . En nuestro ejemplo, sugerimos al usuario que especifique un rango de números y especifique los valores de "fizz" y "buzz" que se usan al verificar números del rango especificado.

 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):    # ... 

Estos cambios son de gran beneficio para el guión. Es decir, los parámetros ahora están debidamente documentados, puede averiguar su propósito utilizando el indicador --help . Además, de acuerdo con el comando correspondiente, también se muestra la documentación que escribimos en la sección anterior:

 $ ./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) 

El módulo argparse es una herramienta muy poderosa. Si no está familiarizado con él, le será útil ver la documentación que contiene. En particular, me gusta su capacidad para definir subcomandos y grupos de argumentos .

Registro


Si equipa el script con la capacidad de mostrar cierta información durante su ejecución, esto será una adición agradable a su funcionalidad. El módulo de registro es muy adecuado para este propósito. Primero, describimos un objeto que implementa el registro:

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

Luego, haremos posible controlar los detalles de la información que se muestra durante el registro. Entonces, el logger.debug() debería generar algo solo si el script se ejecuta con el --debug . Si el script se ejecuta con el --silent , el script no debe mostrar nada excepto mensajes de excepción. Para implementar estas características, agregue el siguiente código a parse_args() :

 #  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") 

Agregue la siguiente función al código del proyecto para configurar el registro:

 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) 

El código del script principal cambiará de la siguiente manera:

 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) 

Si planea ejecutar el script sin la participación directa del usuario, por ejemplo, usando crontab , puede hacer que su salida vaya a 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) 

En nuestro pequeño script, parece necesaria una cantidad similar de código para usar el logger.debug() . Pero en las secuencias de comandos reales, este código ya no parecerá así y el beneficio de esto se pondrá en primer plano, es decir, con su ayuda, los usuarios podrán conocer el progreso de la solución del problema.

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

Pruebas


Las pruebas unitarias son una herramienta útil para verificar si las aplicaciones se comportan como deberían. Los guiones de unidad se usan con poca frecuencia en los guiones, pero su inclusión en los guiones mejora significativamente la confiabilidad del código. Transformamos el código dentro del bucle en una función y describimos varios ejemplos interactivos de su uso en su documentación:

 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 

Puede verificar el funcionamiento correcto de la función usando 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 ========================== 

Para que todo esto funcione, necesita la extensión .py después del nombre del script. No me gusta agregar extensiones a los nombres de las secuencias de comandos: el idioma es solo un detalle técnico que no necesita mostrarse al usuario. Sin embargo, parece que equipar un nombre de script con una extensión es la forma más fácil de permitir que los sistemas para ejecutar pruebas, como pytest , encuentren las pruebas incluidas en el código.

Si se pytest un error pytest mostrará un mensaje que indica la ubicación del código correspondiente y la naturaleza del problema:

 $ 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 ========================== 

Las pruebas unitarias también se pueden escribir como código regular. Imagine que necesitamos probar la siguiente función:

 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)] 

Al final del script, agregamos las siguientes pruebas unitarias utilizando las pytest para usar funciones de prueba parametrizadas :

 #   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 

Tenga en cuenta que, dado que el código del script finaliza con una llamada a sys.exit() , las pruebas no se ejecutarán cuando se llame normalmente. Gracias a esto, pytest no pytest necesario para ejecutar el script.

La función de prueba se llamará una vez para cada grupo de parámetros. La entidad args se usa como entrada para la función parse_args() . Gracias a este mecanismo, obtenemos lo que necesitamos para pasar a la función main() . La entidad expected se compara con lo que main() . pytest es lo que pytest nos dirá si todo funciona como se esperaba:

 $ 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 ========================== 

Si se produce un error, pytest proporcionará información útil sobre lo que sucedió:

 $ 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 ===================== 

La salida del comando logger.debug() incluye en esta salida. Esta es otra buena razón para usar mecanismos de registro en scripts. Si desea saber más sobre las excelentes características de pytest , eche un vistazo a este material.

Resumen


Puede hacer que los scripts de Python sean más confiables siguiendo estos cuatro pasos:

  • Equipa el script con la documentación ubicada en la parte superior del archivo.
  • Utilice el módulo argparse para documentar los parámetros con los que se puede argparse el script.
  • Use el módulo de logging para mostrar información sobre el proceso de operación del script.
  • Escribir pruebas unitarias.

Aquí está el código completo para el ejemplo discutido aquí. Puede usarlo como plantilla para sus propios scripts.

Se iniciaron interesantes debates sobre este material; puede encontrarlos aquí y aquí . Al parecer, la audiencia recibió buenas recomendaciones sobre documentación y argumentos de la línea de comandos, pero a algunos lectores les pareció un "disparo de un arma en gorriones". Aquí está el material que se escribió en respuesta a este artículo.

Estimados lectores! ¿Planea aplicar las recomendaciones para escribir scripts de Python que figuran en esta publicación?

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


All Articles