Hace poco cuando revisaba en algunos foros me encontré con un topic en donde se hablaba de hacer debug en python, aquí se mencionaban algunas herramientas pero más que las herramientas me causo curiosidad una opinión de un usuario en donde escribía que él llevaba muchos tiempo pensando que en python no existía el concepto de debug y que solamente esto se hacía utilizando print() , mi pensamiento automáticamente fue y en dónde dejó el método de "cuéntaselo al pato de goma"? (Ver Figura 1)

Figura 1: Método de debug cuéntaselo al pato de goma. Tomado de https://twitter.com/dark_gnosis/status/941599785437540353

Bueno efectivamente en python tenemos la función print() una función que nos permite generar una salida por consola, durante mucho tiempo muchos programadores en python sobre todo comenzando la usan/usamos para hacer debug, es una ayuda rápida pero a largo plazo es poco eficiente y a medida que el bug que buscamos es más complejo se necesita de más ayuda, por ello en este artículo trataremos uno de los temas con los que como desarrollador me he encontrado todos los días y ese es hacer debug, antes de empezar el post quiero decir el que metodo ganador es el del hablar con el pato

Iniciemos con un ejemplo de una porción código de un proyecto pequeño para generar un contexto

async def send_message(message: str, chat_id: str):
    to_message = {"chat_id": chat_id, "text": message, "parse_mode": "Markdown"}
    token_telegram: str = os.getenv("API_KEY_TELEGRAM")
    url: str = os.getenv("URL") + token_telegram + "/sendMessage"

    async with httpx.AsyncClient() as client:
        await client.post(url=url, json=to_message)


@app.post("/webhook")
async def receive_webhook(req: Request):
    body = await req.json()
    event: str = req.headers.get("X-Github-Event")

    if event == "star":
        try:
            url_repo = body["repository"]["htmlurl"]
            started_user = body["sender"]["login"]
            repo_name = body["repository"]["name"]
        except KeyError as error:
            logging.exception("error getting keys from json github")
            return Response(status_code=200)

        action_star = "add" if body["action"] == "created" else "delete"
        message = f"The user {started_user} { action_star} start the repo {repo_name}"
        await send_message(chat_id=os.getenv("CHAT_ID"), message=message)
    elif event == "pull_request":

        action_pull_request = body["action"]
        merged = body["pull_request"]["merged"]
        user = body["pull_request"]["user"]["login"]
        title = body["pull_request"]["title"]
        commits = body["pull_request"]["commits"]
        if action_pull_request == "closed" and merged:

            message = f"The user {user} merged pull request with title{title} total commits {commits}"
            await send_message(chat_id=os.getenv("CHAT_ID"), message=message)
        else:
            message = f"The user {user} has not merged pull request with title {title} total commits {commits}"
            await send_message(chat_id=os.getenv("CHAT_ID"), message=message)

    return Response(status_code=200)

Arriba tenemos dos funciones una que se llama receive_webhook que recibe información de un repositorio de github y la otra send_message que envía mensajes de texto a una cuenta de telegram, actualmente este codigo no esta funcionando y nos esta generando el siguiente error

ERROR:root:error getting keys from json github
Traceback (most recent call last):
  File "./main.py", line 42, in receive_webhook
    url_repo = body["repository"]["htmlurl"]
KeyError: 'htmlurl'

ya sabemos la línea, pero debemos empezar a realizar debug para poder ver la información que llega de github

Herramienta para debuguear print()

como mencione print() es una función que se utiliza algunas veces para hacer debug , podemos incluir en nuestro código esta función e imprimir la información que envía github,  y para no hacerle mala fama al print vamos a imprimir utilizando el formato json como se aprecia en el siguiente código

@app.post("/webhook")
async def receive_webhook(req: Request):
    body = await req.json()
   
    print(json.dumps(body, indent=4))
    
    if event == "star":
        try:
           ...
        except KeyError as error:
            logging.exception("error getting keys from json github")
            return Response(status_code=200)
	...
    return Response(status_code=200)

nos quedaría mas o menos algo así en la consola (el json ha sido recortado al igual que el código)

{
    "action": "created",
    "starred_at": "2021-04-24T00:51:23Z",
    "repository": {
        "id": 361039123,
        "node_id": "MDEwOlJlcG9zaXRvcnkzNjEwMzkxMjM=",
        "name": "testdebug",
        "full_name": "jairoufps/testdebug",
        "private": true,
        "owner": {
            "login": "jairoufps",
            ....
        },
        "html_url": "https://github.com/jairoufps/testdebug",
        ...
        }

aquí podemos seguir debugueando con print agregando mas codigo print() e incluso validaciones adicionales

@app.post("/webhook")
async def receive_webhook(req: Request):
    body = await req.json()
   
    print(json.dumps(body, indent=4))
    print(body.keys())
    print(type(body["repository"]))
    print(body["repository"].keys())
    if "htmlurl" in body["repository"].keys():
        print("Found")
    else:
        print("not found")
    
    if event == "star":
        try:
           ...
        except KeyError as error:
            logging.exception("error getting keys from json github")
            return Response(status_code=200)
	...
    return Response(status_code=200)
dict_keys(['action', 'starred_at', 'repository', 'sender'])
<class 'dict'>
dict_keys(['id', 'node_id', 'name', 'full_name', 'private', 'owner', 'html_url', 'description', 'fork', 'url', 'forks_url', 'keys_url', 'collaborators_url', 'teams_url', 'hooks_url', 'issue_events_url', 'events_url', 'assignees_url', 'branches_url', 'tags_url', 'blobs_url', 'git_tags_url', 'git_refs_url', 'trees_url', 'statuses_url', 'languages_url', 'stargazers_url', 'contributors_url', 'subscribers_url', 'subscription_url', 'commits_url', 'git_commits_url', 'comments_url', 'issue_comment_url', 'contents_url', 'compare_url', 'merges_url', 'archive_url', 'downloads_url', 'issues_url', 'pulls_url', 'milestones_url', 'notifications_url', 'labels_url', 'releases_url', 'deployments_url', 'created_at', 'updated_at', 'pushed_at', 'git_url', 'ssh_url', 'clone_url', 'svn_url', 'homepage', 'size', 'stargazers_count', 'watchers_count', 'language', 'has_issues', 'has_projects', 'has_downloads', 'has_wiki', 'has_pages', 'forks_count', 'mirror_url', 'archived', 'disabled', 'open_issues_count', 'license', 'forks', 'open_issues', 'watchers', 'default_branch'])
not found

así podríamos seguir agregando print para seguir viendo que información falta , esta mal escrita o incompleta en este caso ya encontramos que el nombre del atributo del json que se recibe de github es html_url, corregimos el codigo y funciona a la perfeccion

Figura 2: Ejecución exitosa después de encontrar el bug

Vemos que la función print() nos ayudo a cumplir éxito el debug, sin embargo existen algunas desventajas :

  • En caso de tener un servicio como este cada print() que se agrega implica volver a levantar el servidor y volver a realizar el llamado del endpoint
  • El código se llena de  funciones print(), en este ejemplo era una función pero imagine que tenga clases objetos más funciones para hacer trazabilidad de todo lo que pasa debe ir agregando print y gestionar esto muy manualmente (el código generalmente se ensucia y algunas veces no se borra y esto llega así a los pull request)
  • No se tiene mayor información, esto refiere a que la unica informacion que se puede tener acceso es la que se imprime pero no necesariamente es la que se necesita ver , pues si se esta debuggeando se supone que no se sabe dónde está el error por lo tanto se estarían colocando print al azar esperando que alguno de con el error
  • No se realiza un debug realmente, no se sigue una trazabilidad de la ejecución no se revisan estados de variables si no como mencione en el punto anterior va ligado al "ojo" o a donde se cree que podría estar el error se pone un print() pero  al debuggear generalmente se revisan estados diferentes variables, funciones, pilas todo al tiempo

Herramienta para debuguear pdb

pdb es un módulo exclusivo de las librerías propias de python para realizar debugging de forma interactiva deteniendo la ejecución del código en un punto específico y facilitando una shell interactiva, teniendo el código anterior y agregando una modificación de que ahora existe una lista de usuarios vamos a simular que tenemos otro error suponiendo que el código se movió de máquina y se ejecutó pero ahora genera el siguiente error

 url: str = os.getenv("URL") + token_telegram + "/sendMessage"
TypeError: unsupported operand type(s) for +: 'NoneType' and 'NoneType'

Aparentemente el error está cuando se envía el mensaje a telegram, para ello vamos a agregar al código la instrucción breakpoint() esto solo por que estoy utilizando una versión  superior a python 3.7 de lo contrario deberían agregar la instrucción import pdb; pdb.set_trace()

async def send_message(message: str):
    for user_telegram in users_telegram:
        to_message = {"chat_id": user_telegram.chat_id, "text": message, "parse_mode": "Markdown"}
        token_telegram: str = os.getenv("API_KEY_TELEGRAM")
        breakpoint()
        url: str = os.getenv("URL") + token_telegram + "/sendMessage"

        async with httpx.AsyncClient() as client:
            await client.post(url=url, json=to_message)

al ejecutar el código cuando llegue al breakpoint se detiene la ejecución del código y devuelve una consola interactiva en ella podemos hacer una gran cantidad de operaciones y se tiene acceso al estado total de la ejecución de la aplicación, por ejemplo podríamos importar un módulo especial llamado pprint y ver la lista de usuarios que tenemos

(Pdb) from pprint import pprint
(Pdb) pprint(users_telegram)
[UserTelegram(chat_id='123456789', name='andres'),
 UserTelegram(chat_id='5676878786', name='jairo'),
 UserTelegram(chat_id='565656565', name='daniel'),
 UserTelegram(chat_id='454534545', name='andre'),
 UserTelegram(chat_id='5646456565', name='daniela')]
 

podemos ver en que parte del código se encuentra el debug ejecutando comando list o l, pero lo mas genial aun es que se puede agregar breakpoints en ejecución es decir no se debe salir del programa o reiniciarlo, si no que utilizando el comando break se agrega un nuevo breakpoint

(Pdb) l
 37     async def send_message(message: str):
 38         for user_telegram in users_telegram:
 39             to_message = {"chat_id": user_telegram.chat_id, "text": message, "parse_mode": "Markdown"}
 40             token_telegram: str = os.getenv("API_KEY_TELEGRAM")
 41             breakpoint()
 42  ->         url: str = os.getenv("URL") + token_telegram + "/sendMessage"
 43  
 44             async with httpx.AsyncClient() as client:
 45                 await client.post(url=url, json=to_message)
 46  
 47  
(Pdb) break 44
Breakpoint 1 at /home/python/bot-telegram-github/bottelegram/bottelegram/main.py:44
 

para seguir el flujo de ejecución utilizamos dos comandos claves next y continue next se mueve a la siguiente línea de código en este ejemplo vemos que la siguiente sería la 43, continúe en cambio mueve al siguiente breakpoint que en este caso seria la linea de codigo 44, aqui se podrian hacer muchisimas mas cosas ya esto se podría consultar en la la documentación de la librería, por ahora proseguiremos con el ejemplo buscaremos el error para ello mostraremos en la consola interactiva la variable que esta generando error

(Pdb) os.getenv("URL") + token_telegram + "/sendMessage"
*** TypeError: unsupported operand type(s) for +: 'NoneType' and 'NoneType'

aqui se puede escribir código python, en este caso le diré que me muestre un True si es None para validar la sospecha de que la variable de entorno no se está encontrando

(Pdb) from pprint import pprint
(Pdb) if os.getenv("URL") is None: pprint(True)
True

efectivamente al ser None se valida que no esta leyendo la variable de entorno y aquí se encuentra el error, y ya para terminar de verificar podemos validar que la variable de entorno no existe utilizando el valor por defecto cuando se llama una key de un dict

(Pdb) os.getenv("URL","Does not Exists")
'Does not Exists'
(Pdb) 

así que sabiendo cual es el posible error,  encontré que efectivamente el archivo .env no estaba creado, se crea y se ejecuta de nuevo se revisa que exista la variable

(Pdb) os.getenv("URL")
'https://api.telegram.org/bot'
(Pdb) 

y felicidades el codigo funciona a la perfeccion

Herramienta para debuggear ipdb y pdbpp

Estas dos herramientas son especializaciones de pdb, básicamente los comandos son exactamente iguales pero tiene beneficios que ami personalmente me parecen geniales.

  • IPDB

La instalación es relativamente sencilla utilizando poetry

poetry add ipdb

ipdb no esta directamente diseñado para utilizar los breakpoints() de python 3.7 por ello se realiza un paso adicional que es agregar al debugger como debug por default

export PYTHONBREAKPOINT=ipdb.set_trace

Volviendo al ejemplo anterior se puede  aplicar el mismo llamado a un objeto user_telegram usando ipdb pero en este caso nos autocompleta   oprimiendo la tecla tab, esto es una super ayuda por que en ejecución tenemos quizás atributos que podemos ignorar

ipdb> user_telegram.
 chat_id               copy()                json()                parse_obj()           schema_json()        
 Config                dict()                name                  parse_raw()           update_forward_refs()
 construct()           from_orm()            parse_file()          schema()              validate()           

algo mas genial aun es que autocompleta dicts esto es otra función super genial, por ejemplo en caso del  ejemplo que se viene trabajando en el webhook pondremos el breakpoint() de nuevo en este método

@app.post("/webhook")
async def receive_webhook(req: Request):
    body = await req.json()
   
    breakpoint()
    
    if event == "star":
        try:
           ...
        except KeyError as error:
            logging.exception("error getting keys from json github")
            return Response(status_code=200)
	...
    return Response(status_code=200)

y al llamar a la variable body vemos como autocompleta los atributos buscando el atributo action

ipdb> body["a"]
              a            %alias       %autoawait   %automagic  
              action       %alias_magic %autocall                
              alias        args         %autoindent              
  • PDBPP

La instalación igualmente es bastante sencilla utilizando poetry

poetry add pdbpp

en este caso no se realiza ningún paso adicional ya que por defecto se agrega y permite utilizar los breakpoints()

Anteriormente llamado pdb++ tiene otras ventajas  interesantes para hacer debug, tiene el autocomplete mencionado en ipdb para objetos, en esta librería no tiene autocomplete para dicts lamentablemente, volviendo al ejemplo de recorrer los usuarios de telegram se puede llamar al usuario y oprimiendo la tecla tab se encuentran sus atributos

(Pdb++) user_telegram.
Config               copy                 json                 parse_obj            schema_json          
chat_id              dict                 name                 parse_raw            update_forward_refs  
construct            from_orm             parse_file           schema               validate        

Adicional tenemos el comando ll(long list) que cuando la función es muy larga en pdb no se muestra completa aquí si ,además se remarca de una manera más gráfica donde se encuentra el la ejecución

(Pdb++) ll
  37     async def send_message(message: str):                                                                                          
  38         for user_telegram in users_telegram:                                                                                       
  39             to_message = {"chat_id": user_telegram.chat_id, "text": message, "parse_mode": "Markdown"}                             
  40             token_telegram: str = os.getenv("API_KEY_TELEGRAM")                                                                    
  41             breakpoint()                                                                                                           
  42  ->         url: str = os.getenv("URL") + token_telegram + "/sendMessage"                                                          
  43                                                                                                                                    
  44             async with httpx.AsyncClient() as client:                                                                              
  45                 await client.post(url=url, json=to_message)        
Figura 3: Resultado de ejecutar el comando long list en pdbpp

Otra funcionalidad interesante es el sticky-mode que en mi caso lo he aplicado utilizando ejecutando una función de python en modo post-mortem, esto quiere decir que se agrega un breakpoint() automáticamente si ocurre una excepción, por ejemplo la siguiente función se ejecuta en modo post-mortem

def division(a:int, b:int):
    return a/b


division(1,0)
python -m pdb division.py

Al ejecutarla se genera una excepción por división por 0, y abre una consola interactiva en la cual llamando el comando sticky te lleva a la función en donde ocurrió la excepción

 (Pdb++) sticky
 1 -> def division(a:int, b:int):                                                                                                    
   2         return a/b                                                                                                                 
   3                                                                                                                                    
   4                                                                                                                                    
   5     division(1,0)                                                                                                                  
(Pdb++) 

este comando puntualmente es uno de los que mas me gusta ya que en algunas ocasiones librerías de terceros son los que tienen el bug y no propiamente el código que escribiste, encontrar esto puede ser un trabajo tedioso pero gracias a esta función se puede hacer mas sencillo

Conclusión

Existen una gran variedad de opciones para hacer debug de forma efectiva en python, incluso si lo desean existen opciones para integrarse con el editor de código directamente (confieso que soy mas de consola) print() es una función que usamos quizás por que siempre está a la mano y creemos que estas herramientas mencionadas son complejas de aprender pero lo cierto es que son bastante intuitivas, al final del día es importante conocerlas probarlas y entender cual te permite ser mas productivo

Referencias

Wallper tomado de https://www.studiopieters.nl/software-rubber-duck-debugging/