Creo que en la informática hay dos tipos esenciales de profesionales, sin entrar a desmerecer ninguno de ellos. Están los que primero trastean y estamos los que primero nos lanzamos con avidez a la documentación (luego trasteamos, y mucho). A mí me encanta leer así que soy de los que se lanzan a por la documentación. Aprecio una buena documentación tanto para introducirme en un nuevo concepto como para recurrir a ella después. No confío en mi memoria más allá de lo imprescindible, porque la conozco y sé que es falible. He tenido suficientes page fails al ir a la cocina y olvidarme de para qué había ido en primer lugar. Por eso resuena muy bien conmigo la cita de Damian Conway:

“Documentation is a love letter that you write to your future self”

También, obviamente, me gusta la programación. Creo que si pudiera lo trataría todo como código, así que, ¿por qué no la documentación? Esta idea no es mía, se llama Documentation as Code (o doc-as-code) y tiene bastantes defensores en La Red. Desde que tuve la oportunidad, preparé una Proof of Concept de doc-as-code y, ahora que trabajo como Tech Lead, voy a overclockear el nivel de proselitismo. Para mí doc-as-code es una manera cómoda y natural, además de mucho más auditable, de preparar la documentación. No es palabrería. Este blog está escrito como doc-as-code y la inmensa mayoría de la documentación que produzco en mi trabajo (salvo notas rápidas y meeting notes) también la genero utilizando ese PoC de doc-as-code. Genero documentos en Markdown y después la automatización se encarga de publicarlos dónde corresponda.

Testea tu documentación

Una de las grandes ventajas de tratar la documentación como código es que se puede testear. Todo código puede y debe ser testeado. ¿Qué tests debe pasar una documentación técnica? La pregunta es abierta y, en mi caso, en proceso de respuesta iterativa. En una primera versión hemos decidido que testearla es auditar su contenido contra una serie de normas de compliance. No un linter, que ya hay buenos linters para markdown, sino algo para asegurarse de que el documento tiene el formato que queremos y los apartados que nos exigimos y nos convienen. Por ejemplo, que tras el primer título viene una nota sobre que es un documento autogenerado para que nadie tenga la tentación de editarlo directamente (sus cambios desaparecerán en la próxima ejecución de la pipeline de CD). Como no encontré nada para testear documentation as code me lancé a programarlo yo. Este proyecto, que bauticé Dactester aún está en desarrollo y me generó toda una serie de necesidades bastante interesantes. Una de las más importantes fue: si quiero automatizar el testeo de un documento de Markdown usando Python ¿cómo lo represento en memoria?

Representando el documento: La solución naive 1.0

Markdown es un archivo en texto plano con una sintaxis propia que permite a los intérpretes renderizarlo. Pero a nivel de datos es sólo un archivo de texto plano. Un archivo de texto plano es una colección de líneas. Así que mi primera implementación es una lista de líneas de texto plano. El código es OOP y contiene los métodos necesarios para ofrecer una interfaz con funcionalidad mínima que necesita dactester.

El código

""" Represent a markdown document file """
__author__      = "Abian G. Rodriguez"
__version__     = "0.1"
__status__      = "PoC"

# This is a class representing a .md document and providing methods to handle it: read it, parse it, etc

class MdDoc:
    """
    Class abstraction of a markdown document file.
    """

    def __init__(self):
        self.doclines = []

    def load_from_file(self, filepath):
        try:
            with open(filepath,'r') as sourcefile:
                for line in sourcefile:
                    self.doclines.append(line)
        except FileNotFoundError as error:
            self.doclines = []

    def get_doc_lines(self):
        if len(self.doclines) > 0:
            return self.doclines
        else:
            return -1

    def get_line_number(self, linestring):
        """
        Get the line number of a given line.
        If two or more lines are equal, will get only the first one

        Parameters:
            linestring (str): The exact string representing a line to match

        Returns:
            linenumber (int): The number of the line found, or -1 if not found or document is empty
        """
        if len(self.doclines) > 0:
            if linestring in self.doclines:
                return(self.doclines.index(linestring)+1)
            else:
                return -1
        else:
            return -1

Funciona y eso vale para una primera aproximación, pero no me es suficiente. Me gustaría saber si es eficiente. Siempre admiré a quienes exprimían el hardware al máximo en lugar de conformarse sin importar los recursos que consumiera su código. Crecí admirando a gente que era capaz de exprimir polígonos y efectos en las primeras consolas de 32 bits. Gente capaz de correr Quake en la Sega Saturn.

Performance

Para saber si es eficiente, en esta primera aproximación me interesa ver los tiempos de ejecución según el tamaño del documento markdown, que es el único input variable. En la ejecución de todo el proceso de dactester también importan la cantidad de tests sobre los documentos y el tipo que sean, pero por ahora voy a centrarme sólo en el documento y su implementación. Me preocupan las tres operaciones principales que realiza la clase MdDoc, que serán los tres tests que voy a realizar:

  1. Leer y cargar en memoria un documento.
  2. Recorrer la lista realizando una operación (normalmente una comparación de strings)
  3. Buscar una línea concreta y devolver su número de línea

Para medir la performance voy a usar timeit de las standard libraries de Python. Da el tiempo de ejecución en segundos del snippet o función de código que se le pase por parámetro. Por cierto, estoy empezando con esto del performance testing, así que si mis métodos no son del todo precisos o presentan errores, así que si tienes alguna sugerencia de mejora o comentario, recuerda que puedes contactarme.

Resultados

He realizado cada uno de los test básicos con tres tamaños de documento diferente. Un markdownn pequeño de 50 lineas, uno mediano de 500 y uno grande de 5000. Así que cada documento tiene diez veces más lineas que el anterior. timeit devuelve el tiempo total que toma la ejecución del código number veces, siendo number un parámetro integer. En mi caso cada resultado será el tiempo total de 10.000 ejecuciones, ya que el código es sencillo y bastante pequeño y quiero un número relativamente alto para que cualquier diferencia mínima según el tamaño del documento sea visible. Sin más, los resultados del test.

@@@@ Document load tests
Load test for small doc took: 1.9765488000120968 seconds
=======================================
Load test for medium doc took: 2.843191900057718 seconds
=======================================
Load test for big doc took: 10.372771300142631 seconds
=======================================
@@@@ END of Document load tests

@@@@ Load Document and loop through content tests
Load & Loop test for small doc took: 2.015927999978885 seconds
=======================================
Load & Loop test for medium doc took: 2.0034616000484675 seconds
=======================================
Load & Loop test for big doc took: 2.1118023002054542 seconds
=======================================
@@@@ END of Load Document and loop through content tests

@@@@ Load Document, find line and return line number tests
Load & Loop test for SMALL doc (line near start) took: 1.9678535000421107 seconds
=======================================
Load & Loop test for SMALL doc (line near the middle) took: 1.9765974001493305 seconds
=======================================
Load & Loop test for SMALL doc (line near EoF) took: 1.9668072001077235 seconds
=======================================
Load & Loop test for MEDIUM doc (line near start) took: 1.9576516000088304 seconds
=======================================
Load & Loop test for MEDIUM doc (line near the middle) took: 1.9541629999876022 seconds
=======================================
Load & Loop test for MEDIUM doc (line near EoF) took: 2.0762366999406368 seconds
=======================================
Load & Loop test for BIG doc (line near start) took: 1.9916893998160958 seconds
=======================================
Load & Loop test for BIG doc (line near the middle) took: 2.003709400072694 seconds
=======================================
Load & Loop test for BIG doc (line near EoF) took: 1.978114299941808 seconds
=======================================
@@@@ END of Load Document, find line and return line number tests

La operación donde más diferencia hay es en la de cargar el documento en memoria. El doc grande tarda alrededor de diez veces más que el doc pequeño. Esto tiene sentido ya que la carga la realizo con .append(), que aunque tiene una complejidad temporal de O(1) -tiempo constante- es llamada n veces según las n líneas que tenga el documento. Con lo cual la complejidad temporal de la carga en memoria para MdDoc se acerca más a O(n).

Para la operación de carga del documento y recorrido (loop) los tiempos son bastante parecidos, aunque curiosamente el doc mediano tarda menos o lo mismo que el doc pequeño. Esto ha ocurrido consistentemente en todas las ejecuciones del test y me abre una nueva pregunta que tendré que lanzarme a responder en otro momento. La comparativa MdDoc la realiza con value in string que para los strings se convierte en una llamada a str.__contains__() que, según su implementación en CPython utiliza el algoritmo Boyer-Moore-Horspool y es sublinear en los mejores casos O(n/m), O(n) de media y y O(nm) en los peores. Por el aspecto de los resultados en mi tests, se acerca más a algo sublinear.

La operación para devolver el número de línea de una línea del documento tiene una duración que se acerca a un tiempo constante. En teoría, según la información que he podido encontrar list.index() tiene una complejidad linear O(n), el hecho de que en los tests el comportamiento se aproxime a O(1) puede ser debido a que el tamaño del documento no es lo suficientemente grande como para que la diferencia sea tan visible.

Así que las tres operaciones principales son de media O(n), no es demasiado eficiente pero no es terriblemente ineficiente. El hecho de que en mis tests, dónde el documento medium se aproxima al tamaño de aquellos con los que trabajamos en mi equipo, los tiempos de las operaciones se comporten de manera quasi constante, me tranquiliza. Por ahora, servirá.

Pero no me conformo, entre los apuntes del proyecto figura ahora una razón medible para mejorar la eficiencia de la clase MdDoc.

Dactester action

Como dije, no encontré nada para automatizar el testeo de doc-as-code y tuve que hacérmelo yo. Así que me animé a publicar la misma Github Action que uso en la pipeline de CI/CD que tenemos para la documentación en nuestro equipo. Tiene el original nombre de dactester-action. Por si a alguien le puede interesar después de leer este post. Sharing is caring.

Kudos