Analizando una base de código enorme con Python
Escribí un programa en Python para analizar una enorme base de código C ++ de más de millones de líneas de código. El trabajo del programa es simplemente buscar todos los comentarios de estilo C y C ++ y extraer todos los comentarios que contienen palabras clave y frases específicas y escribir esos comentarios en un archivo asociado. Hasta ahora, el programa funciona bastante bien. Es bastante rápido y fácil de configurar y hace el trabajo, pero no busca tan rápido como me gustaría y me gustaría algunos consejos sobre cómo hacer que las cosas funcionen más rápido.
Configuración
El programa es muy rápido y fácil de configurar. Si desea buscar una base de código para una sola expresión, todo lo que tiene que hacer es pasar esa expresión como argumento y el directorio para buscar y hará el resto por usted. Para buscar comentarios que contengan la palabra "hack" en el directorio src /, simplemente puede escribir:
./main.py -e hack -s ./src/
Sin embargo, si desea pasar muchas expresiones, debe utilizar un archivo JSON especialmente diseñado y pasar el nombre de ese archivo como argumento. Un archivo JSON de ejemplo podría verse así:
[
{
"hack-stuff": [
"hack",
"hacked",
"hacky"
]
},
"WARNING",
"DANGER",
[
"bad",
"badly"
]
]
Luego, el archivo se pasa al programa de la siguiente manera:
./main.py -f test-words.json -s ./src/
Esto creará cuatro archivos: "hack-stuff", "ADVERTENCIA", "PELIGRO" y "malo". El archivo "hack-stuff" será donde se colocarán todos los comentarios que contengan las palabras hack, hacked y hacky, "bad" será donde se colocarán todos los comentarios que contengan "bad" y "badly", y "WARNING" y " PELIGRO "será donde se colocarán los comentarios que contengan" ADVERTENCIA "y" PELIGRO "respectivamente. Este ejemplo demuestra las tres formas (cadena, lista o diccionario) en las que puede especificar qué comentarios coincide con los que desea colocar en qué archivos.
Estructura del programa
En el núcleo del programa se encuentra la clase SourceWalker, que contiene toda la lógica interna necesaria para analizar el código base y escribir los comentarios en sus respectivos archivos. También hay una función principal en un archivo separado que lee los argumentos en sus variables relevantes y realiza el procesamiento JSON (si es necesario) antes de inicializar y ejecutar una instancia de la clase a través del método walk ().
Actuación
He intentado varias cosas para que el programa tenga el mayor rendimiento posible, incluida la incorporación de multiprocesamiento que produjo grandes mejoras, pero no estoy seguro de qué puedo hacer para hacerlo más rápido en este momento. La principal ralentización es causada por el bucle for dentro de _process_files () en la línea 117. Esta es la parte del programa que se ejecuta en cada uno de los procesos secundarios y busca en cada archivo en busca de comentarios válidos antes de compararlos con una serie de compilamos expresiones regulares para ver si coinciden con una de las expresiones que estamos buscando. Estoy seguro de que hay mejores formas de extraer los comentarios de cada archivo y luego buscarlos, pero no estoy seguro de cuáles serían. Cualquier sugerencia aquí será muy apreciada.
Comentarios adicionales
Si bien el rendimiento es mi principal preocupación aquí, también agradecería cualquier comentario sobre la corrección y el estilo de mi programa. Parece funcionar según lo previsto, pero no puedo garantizar que no haya algunos casos extremos que me haya perdido durante mis pruebas.
El código
main.py
#!/usr/bin/python3
import sys
import json
import os
import argparse
import SourceWalker
def initialiseParser():
parser = argparse.ArgumentParser(description = "Search the contents of comments within source code files")
parser.add_argument("--file_name", "--file", "-f", help = "Name of the file containing the JSON list of expressions to search for", type = str)
parser.add_argument("--source-dir", "--src", "-s", help = "The root directory of the source files to search over", type = str)
parser.add_argument("--output-dir", "--out", "-o", help = "The directory the output files will be placed in", type = str)
parser.add_argument("--expression", "--expr", "-e", help = "The expression to search for within the source comments", type = str)
parser.add_argument("--language", "--lang", "-l", help = "The style of comments to look for within the file", type = str)
return parser
def main():
parser = initialiseParser()
args = parser.parse_args()
if args.source_dir:
source_dir = args.source_dir
else:
sys.exit("Source directory must be specified!")
if args.file_name:
file_name = args.file_name
input_file = open(file_name, "r")
expressions = json.loads(input_file.read())
elif args.expression:
expressions = []
expressions.append(str(args.expression))
else:
sys.exit("Error: Expression or file containing expressions must be specified!")
output_dir = "./comments/"
if args.output_dir:
output_dir = args.output_dir
if not os.path.exists(output_dir):
os.makedirs(output_dir)
walker = SourceWalker.SourceWalker(source_dir, output_dir, expressions, extensions=[ ".c", ".cpp", ".h", ".cxx" ])
walker.walk()
if "input_file" in locals():
input_file.close()
return
if __name__=="__main__":
main()
SourceWalker.py #! / Usr / bin / python3
import sys
import json
import re
import os
import multiprocessing
import codecs
from pathlib import Path
class SourceWalkerException(Exception):
"""
Raised when there is an error processing the given expressions
TODO: Make error handling more informative and clean up. Should display a message to the user explaing what went wrong and close all open files.
"""
pass
class SourceWalker:
_output_file_names = []
_regexes = {}
_expr_file_names = {}
def __init__(self, source_dir, output_dir, expr_list, encoding = "ISO-8859-1", process_count = 12, extensions = [ ".c", ".h" ]):
try:
if not os.path.exists(source_dir) or not os.path.exists(output_dir):
raise NotADirectoryError
if process_count < 1:
raise SourceWalkerException("Process count cannot be less than one!")
codecs.lookup(encoding)
if not isinstance(extensions, list):
raise SourceWalkerException("Extensions must be passed as a list!")
for extension in extensions:
if extension[0] != '.':
raise SourceWalkerException("Extensions must start with a \'.\'!")
elif len(extension) <= 1:
raise SourceWalkerException("Extensions must be more than one character long!")
except NotADirectoryError as exception:
raise SourceWalkerException("Directory does not exist! " + str(exception))
else:
self._source_dir = source_dir
self._output_dir = output_dir
self._encoding = encoding
self._expr_list = expr_list
self._process_count = process_count
self._extensions = extensions
self._process_expr_list()
def _process_expr_list(self):
for expr in self._expr_list:
try:
if isinstance(expr, list):
if len(expr) == 0:
raise SourceWalkerException("Expression list cannot be empty!")
output_file_name = expr[0]
if not isinstance(output_file_name, str):
raise SourceWalkerException("Expression sub-lists can only contain strings!")
for sub_expr in expr:
if not isinstance(sub_expr, str):
raise SourceWalkerException("Expression sub-lists can only contain strings!")
elif sub_expr in self._regexes.keys():
raise SourceWalkerException("Expressions can only appear once in the expression list!")
self._regexes[sub_expr] = re.compile("\s+%s(\s|,|:|;|\n)+" % (sub_expr)) # Naieve regex to catch expressions
self._expr_file_names[sub_expr] = self._output_dir + output_file_name
self._output_file_names.append(self._output_dir + output_file_name)
elif isinstance(expr, dict):
if len(expr.keys()) == 0:
raise SourceWalkerException("Expression dictionary cannot be empty!")
output_file_name = list(expr)[0]
if not isinstance(expr[output_file_name], list):
raise SourceWalkerException("Expression dictionary cannot be empty!")
for sub_expr in expr[output_file_name]:
if not isinstance(sub_expr, str):
raise SourceWalkerException("Expression sub-lists can only contain strings!")
elif sub_expr in self._regexes.keys():
raise SourceWalkerException("Expressions can only appear once in the expression list!")
self._regexes[sub_expr] = re.compile("\s+%s(\s|,|:|;|\n)+" % (sub_expr))
self._expr_file_names[sub_expr] = self._output_dir + output_file_name
self._output_file_names.append(self._output_dir + output_file_name)
elif isinstance(expr, str):
if expr in self._regexes.keys():
raise SourceWalkerException("Expressions can only appear once in the expression list!")
self._output_file_names.append(self._output_dir + expr)
self._regexes[expr] = re.compile("\s+%s(\s|,|:|;|\n)+" % (expr))
self._expr_file_names[expr] = self._output_dir + expr
else:
raise SourceWalkerException("Expression list can only contain dictionaries, lists, and strings!")
except SourceWalkerException as exception:
self.cleanup()
raise
def _process_files(self, input_files, output_files, mutexes): # Find way to process different types of source file, I'd rather not be limited to C only...
for file_name in iter(input_files.get, None):
with open(file_name, "r", encoding = self._encoding) as file_object:
in_multi_comment = False
in_single_comment = False
in_string = False
prev_char = ''
comment = ''
for line_num, line in enumerate(file_object, 1):
for char in line:
if char == '/':
if in_string or in_single_comment:
prev_char = char
continue
if prev_char == '*':
in_multi_comment = False
comment += char
for expr in self._regexes.keys():
if self._regexes[expr].search(comment):
mutexes[expr].acquire()
os.write(output_files[expr], ("%s: %s %s\n" % (file_name, str(line_num), comment)).encode())
mutexes[expr].release()
comment = ''
elif prev_char == '/':
in_single_comment = True
comment += prev_char
elif char == '*':
if in_string or in_single_comment or in_multi_comment:
if in_single_comment or in_multi_comment:
comment += char
prev_char = char
continue
if prev_char == '/':
in_multi_comment = True
comment += prev_char
elif char == '"':
if prev_char == '\\' or in_single_comment or in_multi_comment:
prev_char = char
continue
in_string = not in_string
prev_char = char
if in_single_comment or in_multi_comment:
comment += char
if in_single_comment:
in_single_comment = False
for expr in self._regexes.keys():
if self._regexes[expr].search(comment):
mutexes[expr].acquire()
os.write(output_files[expr], ("%s: %s %s" % (file_name, str(line_num), comment)).encode())
mutexes[expr].release()
comment = ''
def walk(self):
input_files = multiprocessing.Queue(0)
processes = []
mutexes = {}
output_files = {}
for fname in self._output_file_names:
try:
file_handle = os.open(fname, os.O_WRONLY | os.O_CREAT)
mutex = multiprocessing.Lock()
except IOError:
for file in output_files.keys():
output_files[file].close()
raise SourceWalkerException("Error: Could not open output file %s, skipping!" % fname)
for expr in self._expr_file_names.keys():
if self._expr_file_names[expr] == fname:
output_files[expr] = file_handle
mutexes[expr] = mutex
for root, dirs, file_names in os.walk(self._source_dir):
for file_name in file_names:
if any(ext in Path(file_name).suffix for ext in self._extensions):
input_files.put(os.path.join(root, file_name))
for i in range(self._process_count):
input_files.put(None)
for cur_process in range(self._process_count):
process = multiprocessing.Process(target = self._process_files, args = (input_files, output_files, mutexes))
processes.append(process)
process.start()
for i in range(1, self._process_count):
processes[i].join()
for file in output_files.keys(): # Close the file associated with each expression
try:
os.close(output_files[file]) # Since multiple expressions can be associated with the same file we need to avoid invalid file closures
except:
pass
Respuestas
Si entiendo su descripción, solo está buscando comentarios, pero siempre está buscando en la base de código completa. Dado que los comentarios son normalmente una pequeña parte del código (¿menos del 10%?), Sugiero hacer primero un paso previo al proceso en el que simplemente extraiga todos los comentarios y luego realice la búsqueda real en ellos.
Por "extraer" me refiero a guardar el comentario en archivos separados para que pueda buscar solo en esos archivos.
Para búsquedas repetidas en la misma base de código, esto debería ayudar, ya que el preprocesamiento se puede realizar una vez y luego la búsqueda real tiene menos texto para revisar.
Puede especificar los argumentos requeridos en argparselugar de manejarlos usted mismo. También puede especificar valores predeterminados , por ejemplo, para output_dir.
os.makedirstoma exist_ok=Truepara indicar que solo debe crear el directorio si aún no existe.
El extensionsargumento del SourceWalkerconstructor probablemente no debería estar codificado.
Úselo with open(file_name, "r") as file_handle:para asegurarse de que el archivo siempre esté cerrado al salir de ese contexto. En una nota relacionada, la locals()OMI solo debería usarse como último recurso, ya que es difícil seguir el código donde se usan cadenas para referirse a variables y los IDE no pueden hacer nada útil con ese código. Por ejemplo, si tuviera que cambiar el nombre input_filea input_file_path(lo recomendaría en cualquier caso) sería trivial olvidarse de cambiar la referencia de la cadena.
Recomendaría usar blacky isortformatear el código. Estará más cerca del estilo idiomático de esa manera, sin trabajo manual.
Después de formatear, recomendaría ejecutar flake8para encontrar otro código no idiomático. Por ejemplo, parece que el SourceWalkercódigo está roto: los métodos están al mismo nivel que la definición de la clase.
Crear una excepción con una cadena estática, como SourceWalkerException("Process count cannot be less than one!"), es un olor a código. No es necesariamente malo, pero al igual que los parámetros booleanos, generalmente significa que algo debe separarse. En este caso, sería mejor tener una clase de excepción separada para un recuento de procesos demasiado bajo (y las otras condiciones de error), que podría ser algo tan simple como ProcessCountTooLowError. De esa manera, lo único que se le pasa a un constructor de excepciones (si es que hay algo) es cualquier contenido dinámico que se pueda usar para depurar esa excepción. Además, esto significa que si alguna vez desea manejar la excepción, puede except ProcessCountTooLowErrorhacerlo en lugar de tener que analizar el mensaje de error dentro de un archivo genérico except SourceWalkerException. (Ah, y las clases de excepción personalizadas deberían terminar en Error, no Exception).
En lugar de verificar cosas como si el directorio de salida existe, dejaría que la aplicación fallara una vez que llega al código que intenta escribir en ese directorio. A esto se le llama "tiempo de verificación a tiempo de uso" - básicamente, si el directorio existe cuando usted hace esa verificación no tiene relación con si existe cuando el código realmente intenta usarlo. También es una comprobación demasiado limitada, ya que, por ejemplo, el usuario actual tampoco puede escribir en el directorio o el sistema de archivos podría estar lleno.
En una nota similar, ejecutar codecs.lookup(encoding)para verificar que existe la codificación antes de usarla en una llamada completamente diferente parece que fácilmente podría ser una verificación incompleta. Puede ser mejor limitar el parámetro de codificación optionssolo a las codificaciones disponibles. De esa manera, se verifica lo antes posible, los usuarios obtienen un error procesable agradable y el texto de ayuda muestra los valores posibles.
El plural de "regex" es "regex e s".
No use argumentos predeterminados mutables ( discusión ). En una nota relacionada, ¿cuándo debería usar argumentos predeterminados? Dos reglas generales:
- Si el valor predeterminado no se utiliza nunca porque todas las llamadas especifican un valor, el valor predeterminado no tiene sentido.
- Si ninguna de las llamadas anula el valor predeterminado, tampoco tiene sentido; bien podría ser una variable o una constante.
if not isinstance(extensions, list):no es idiomático; debería ser perfectamente válido pasar cualquier iterable como un tuple.
Los constructores, en general, no deberían hacer nada más complejo que establecer valores de campo. Una vez hecho esto run, se processdebe ejecutar un método u otro por separado para realizar el procesamiento real. No recuerdo dónde vi por primera vez esto explicado claramente, pero vea, por ejemplo, ¿Por qué el uso de efectos secundarios es una mala práctica en los constructores de JavaScript? y los constructores deben estar libres de códigos
Lista de literales
expressions = []
expressions.append(str(args.expression))
debería ser
expressions = [str(args.expression)]
Pathlib
Esta:
if not os.path.exists(output_dir):
os.makedirs(output_dir)
debería usar el nuevo pathlibequivalente brillante :
Path(output_dir).mkdir(exist_ok=True)
El mismo módulo se puede utilizar para
if not os.path.exists(source_dir) or not os.path.exists(output_dir):
Existencia variable
if "input_file" in locals():
input_file.close()
es incompleto. Por lo general, la forma de indicar en Python que una variable tiene un valor o no es potencialmente tomar None, no potencialmente no declararse. Incluso puede marcarlo como tal vez ninguno usando la Optionalsugerencia de tipo.
Regreso
El sencillo returnal final de main()es redundante.
Longitud del diccionario
if len(expr.keys()) == 0:
puede ser
if len(expr) == 0:
o incluso
if not expr:
Regex compilación
self._regexs[sub_expr] = re.compile("\s+%s(\s|,|:|;|\n)+" % (sub_expr))
necesita, al menos, un adelanto rpara convertir esa cadena literalmente en bruto.
Generalmente, tomar la entrada de un archivo y tratarlo como una sub-expresión regular no validada es una mala idea. No es de evalnivel malo, pero no es bueno. ¿Son estas expresiones regulares reales o solo subcadenas? Si solo son subcadenas, invocalas escapeantes de insertarlas en tu expresión regular externa.
Si en realidad son su propia expresión regular, querrá al menos poner cada uno en su propio grupo de no captura en la expresión externa, para evitar sorpresas desagradables.
Excepciones encadenadas
except NotADirectoryError as exception:
raise SourceWalkerException("Directory does not exist! " + str(exception))
debiera ser
except NotADirectoryError as exception:
raise SourceWalkerException("Directory does not exist! " + str(exception)) from exception