Medir líneas de código, clases y funciones en Python

Lo cierto es que no importa el número, lo relevante de un código es si funciona y es mantenible. No obstante, como ejercicio de aprendizaje y para contemplar el código desde otra perspectiva, este tipo de métricas pueden resultar divertidas.

Este post surge a raíz de ver un vídeo en YouTube How Large is Your Project? Count The Lines with cloc! y probar la herramienta cloc. Está bien, pero no me convenció del todo. Sin embargo, me picó la curiosidad e investigué un poco sobre como podía obtener este tipo de información por otros medios.

Como por otro lado, yo quería jugar con el módulo ast, me pareció una buena oportunidad para poner en práctica lo aprendido.

Para ver de forma rápida el árbol de ast tenemos varias opciones:

A grades rasgos, con ast lo que hacemos es, a partir de un código fuente, crear un árbol de elementos según la gramática del lenguaje. Con ese árbol, bien podemos visitarlo, bien modificarlo. Es justamente esto lo que hacen herramientas como black cuando formatean el código.

Como guía de supervivencia de ast a la hora de visitar el código ahí va este snippet:

import ast
from _ast import FunctionDef

code = """
def foo(bar):
    print(bar)


foo("bar")
    """

tree = ast.parse(code)
print(ast.dump(tree, indent=4))

# Module(
#     body=[
#         FunctionDef(
#             name='foo',
#             args=arguments(
#                 posonlyargs=[],
#                 args=[
#                     arg(arg='bar')],
#                 kwonlyargs=[],
#                 kw_defaults=[],
#                 defaults=[]),
#             body=[
#                 Expr(
#                     value=Call(
#                         func=Name(id='print', ctx=Load()),
#                         args=[
#                             Name(id='bar', ctx=Load())],
#                         keywords=[]))],
#             decorator_list=[],
#             type_params=[]),
#         Expr(
#             value=Call(
#                 func=Name(id='foo', ctx=Load()),
#                 args=[
#                     Constant(value='bar')],
#                 keywords=[]))],
#     type_ignores=[])


class MyVisitor(ast.NodeVisitor):
    def visit_FunctionDef(self, node: FunctionDef) -> None:
        print(f"FunctionDef {node.name} at line: {node.lineno}")
        # propagate the visit on the children of the input node
        self.generic_visit(node)


my_visitor = MyVisitor()
my_visitor.visit(tree)
# FunctionDef foo at line: 2

Además de usar una clase que herede de ast.NodeVisitor, también hay métodos helper para recorrer el árbol.

import ast

code = """
def foo(bar):
    print(bar)


foo("bar")
    """

tree = ast.parse(code)

# ast.walk, ast.iter_fields, ast.iter_child_nodes, etc.
for node in ast.walk(tree):
    print(f"{node.__class__.__name__}")

# Module
# FunctionDef
# Expr
# arguments
# Expr
# Call
# arg
# Call
# Name
# Constant
# Name
# Name
# Load
# Load
# Load

Habiendo puesto ya en el mapa al módulo ast, ahora toca intentar hacer algo de valor con él y usarlo para medir símbolos en nuestro código parece una buena opción.

En nuestra herramienta yo me he interesado sólo por clases y funciones. Para el número de líneas de código he usado Python puro y duro con lo que, sería muy mejorable si quisiéramos por ejemplo no contar comentarios de tipo docstring o cualquier otra cosa que se te ocurra. Yo (no sé si por pereza o por convencimiento pleno) he llegado a la conclusión de que los docstring son código, así que no me siento culpable por tenerlos en cuenta.

Primero (y para tomarlo como referencia) midamos la librería requests con cloc.

cloc

Ahora, probemos con nuestra propia herramienta:

import ast
from dataclasses import dataclass
from functools import reduce
from pathlib import Path
from types import TracebackType
from typing import Self, TextIO
@dataclass
class Symbols:
classes: int = 0
functions: int = 0
class InsightsGenerator:
def __init__(self, source_dir: Path):
if not source_dir.exists() or not source_dir.is_dir():
raise ValueError(f"{source_dir} is not a valid directory")
self._source_dir = source_dir
self._output_file_path = source_dir / "index.html"
self._f: TextIO
def __enter__(self) -> Self:
self._f = open(self._output_file_path, "w")
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self._f.close()
@staticmethod
def _is_dir_excluded(dir_: Path) -> bool:
excluded_dirs = ["__pycache__"]
return any(dir_.name.endswith(exclude) for exclude in excluded_dirs)
@classmethod
def _get_symbols(cls, path: Path) -> Symbols:
classes = 0
functions = 0
if path.is_file():
return cls._get_symbols_from_file(path)
for file in path.glob("**/*.py"):
symbols = cls._get_symbols_from_file(file)
classes += symbols.classes
functions += symbols.functions
return Symbols(classes, functions)
@staticmethod
def _get_symbols_from_file(file_path: Path) -> Symbols:
with open(file_path, "r", errors="ignore") as f:
tree = ast.parse(f.read())
classes = 0
functions = 0
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
classes += 1
elif isinstance(node, ast.FunctionDef):
functions += 1
else:
continue
return Symbols(classes, functions)
@classmethod
def _get_kloc(cls, path: Path) -> int:
return cls._get_dir_kloc(path) if path.is_dir() else cls._get_file_kloc(path)
@staticmethod
def _get_file_kloc(file_path: Path) -> int:
with open(file_path, "r", errors="ignore") as f:
return len(
[
line
for line in f.readlines()
if line.strip() != "" and not line.strip().startswith("#")
]
)
@classmethod
def _get_dir_kloc(cls, dir_: Path) -> int:
return reduce(
lambda x, y: x + y,
[cls._get_file_kloc(f) for f in dir_.glob(f"**/*.py")],
0,
)
@classmethod
def _skip_path(cls, path: Path) -> bool:
return (path.is_file() and not path.suffix == ".py") or (
path.is_dir() and cls._is_dir_excluded(path)
)
def _to_html(self, path: Path) -> None:
for entry in path.iterdir():
if self._skip_path(entry):
continue
print("Generating insights for", entry)
self._f.write(
f"""<tr>
<td>{('d' if entry.is_dir() else '')}</td>
<td><a href='{entry}'>{entry}</a></td>
<td>{self._get_kloc(entry)}</td>"""
)
symbols = self._get_symbols(entry)
self._f.write(
f"""
<td>{symbols.classes}</td>
<td>{symbols.functions}</td>
</tr>"""
)
if entry.is_dir():
self._to_html(entry)
def generate(self) -> None:
style = r"""
table {
border: 1px solid #1C6EA4;
text-align: left;
border-collapse: collapse;
}
table td, table th {
border: 1px solid #AAAAAA;
padding: 3px 2px;
}
table tr:nth-child(even) {
background: #D0E4F5;
}
table thead {
background: #1C6EA4;
background: -moz-linear-gradient(top, #5592bb 0%, #327cad 66%, #1C6EA4 100%);
background: -webkit-linear-gradient(top, #5592bb 0%, #327cad 66%, #1C6EA4 100%);
background: linear-gradient(to bottom, #5592bb 0%, #327cad 66%, #1C6EA4 100%);
}
table thead th {
font-size: 15px;
font-weight: bold;
color: #FFFFFF;
}
table tbody td:nth-child(3), table tbody td:nth-child(4), table tbody td:nth-child(5) {
text-align: right;
}
"""
self._f.write(
f"""
<html>
<head>
<style>
{style}
</style>
</head>
<body>
<h1>{self._source_dir.name}</h1>
<table>
<thead>
<tr>
<th></th>
<th>path</th>
<th>kloc</th>
<th>classes</th>
<th>functions</th>
</thead>
<tbody>
<tr>
<td>d</td>
<td><a href='{self._source_dir}'>{self._source_dir}</a></td>
<td>{self._get_dir_kloc(self._source_dir)}</td>
"""
)
symbols = self._get_symbols(self._source_dir)
self._f.write(
f"""
<td>{symbols.classes}</td>
<td>{symbols.functions}</td>
</tr>"""
)
self._to_html(self._source_dir)
self._f.write(
"""
</tbody>
</table>
</body>
</html>"""
)
if __name__ == "__main__":
with InsightsGenerator(
Path(r"C:\Temp\requests\src\requests")
) as insights_generator:
insights_generator.generate()

requests

Un saludo!


Ver también