Engineering Blog

Back
Published on November 29, 2024 by

Searching Python Code based on its AST using XPath

This content is only available in German:

Das effektive durchforsten von Code und präzise finden von bestimmten Code-Stellen wird in grossen Software-Projekten zu einer wichtigen Aufgabe. Werkzeuge wie Regular Expressions kommen irgendwann an ihre Grenzen. Ich zeige ein Werkzeug, mit dem Python-Code anhand seines ASTs durchsucht werden kann.

Ein mittelgrosses Software-Projekt

Hier bei cloudscale arbeite ich hauptsächlich am Control-Panel, einer mittelgrossen Python/TypeScript-Applikation. Das Control-Panel ist die Applikation, welche unter control.cloudscale.ch erreichbar ist. Die Entwicklung begann vor 8 Jahren als Neu-Anfang und die Applikation wurde seither stetig weiterentwickelt und gepflegt.

In diesem Artikel konzentriere ich mich auf dem Python-Teil, welcher heute ~120 k Zeilen Code umfasst:

$ git ls-files '*.py' | wc -l
     674
$ git ls-files '*.py' | parallel -Xj1 cat | wc -l
  119248

Suchen von Code-Stellen

Wie bei jedem Software-Projekt, ist es bei der Arbeit am Control-Panel regelmässig notwendig, Code-Stellen, welche weit über die Applikation verteilt sind, zu finden oder anzupassen. Z.B. möchte ich herausfinden, ob eine interne API auf eine bestimmte Art verwendet wird oder ob diese überhaupt noch verwendet wird. Oder ich möchte prüfen, ob ein veraltetes oder problematisches Code-Muster, das ich entdeckt habe, noch an weiteren Stellen in der Applikation vorkommt. Das Ziel ist immer, den Code verständlicher und einfacher erweiterbar/wartbar zu machen.

Das erste Werkzeug, zu dem ich greife, ist "Suchen & Ersetzen" meiner IDE oder ein anderes, ähnlichen Werkzeug. Hier kann ich mit Regular Expressions mit wenig Aufwand nach Stellen im Code suchen. Dieser Ansatz ist sehr schnell und daher interaktiv. Die Geschwindigkeit von git grep wird wohl durch kein Werkzeug erreich, welches zuerst den Python-Source parsen müsste:

$ time git grep -P '\bsend_email\(' '*.py' > /dev/null

real	0m0.030s
user    0m0.014s
sys     0m0.098s

Aber Regular Expressions sind nicht geeignet, wenn komplexere zusammenhänge im Code erkennt werden müssen. Ein Beispiel wäre, alle Verwendungen einer Funktion zu finden, bei denen ein optionales Argument angegeben wird. Dafür gibt es besser geeignete Werkzeuge, welche allerdings auch mehr Vorbereitung bei der Anwendung benötigen. Im Folgenden Zeige ich eines der Werkzeuge, mit denen ich in den letzten Monaten viel gearbeitet habe.

pyastgrep

pyastgrep ist eine Library und eine CLI-Applikation, welche zum Durchsuchen von Python-Code anhand dessen AST (Abstract syntax tree) verwendet werden kann. pyastgrep stellt intern den Python-AST einer einzelnen Source-Datei oder eines ganzen Ordners als XML-Baum zur Verfügung. Diesen kann dann mit XPath-Ausdrücken durchsucht werden. Es ist dabei in jedem Fall sehr hilfreich, die Dokumentation des Python-Moduls ast und ein XPath Cheatsheet bereitzuhalten.

Als Beispiel zeige ich, wie ich alle Code-Stellen finden kann, bei denen die Funktion send_email() aufgerufen und ein Wert für das optionale Argument reply_to_address angegeben wird. Dazu baue ich schrittweise einen XPath-Ausdruck auf.

Im ersten Schritt selektiere ich alle Funktions-Aufrufe von Funktionen mit dem Namen send_email().

$ pyastgrep './/Call[func/Name[@id="send_email"]]' src
src/db/access/member_helper.py:57:9:        send_email(
src/services/openstack/functions.py:103:5:    send_email(

Call und Name sind die Knoten im Syntaxbaum, welche einen Funktionsaufruf respektive eine die Verwendung einer globalen oder lokalen Variable darstellen. Name[@id="send_email"] selektiert alle Variablen-Verwendungen von Variablen mit dem name send_email.

Soweit so gut! Ich weiss aber, dass weitaus mehr Code-Stellen diese Funktion aufrufen. Das Problem ist, dass die Funktion entweder als send_email() oder aber auch als email.send_email() aufgerufen werden kann. Da dies die einzige Funktion mit diesem Namen ist, kann ich etwas ungenau arbeiten, und alle Stellen selektieren, in denen auf einem beliebigen Objekt oder Modul eine Funktion mit diesem Namen aufgerufen wird:

$ pyastgrep './/Call[func[Name[@id="send_email"] or Attribute[@attr="send_email"]]]' src
src/panel/signals.py:18:13:            email.send_email(
src/panel/invoices/__init__.py:169:30:    to_address, cc_address = email.send_email(
src/panel/payment/__init__.py:46:9:        email.send_email(
src/panel/email/tests/test_template_rendering.py:15:5:    email.send_email(
src/panel/billing/notifications.py:28:30:    to_address, cc_address = email.send_email(
[... 10 weitere Resultate]

Attribute sind die Knoten, bei denen mit dem .-Operator auf ein Attribut eines anderen Objektes zugegriffen wird, wie z.B. in email.send_email. func[... or ...] selektiert die Funktionsaufrufe beider Varianten (lokale/globale Variable und Attribut).

Als Letztes schränke ich die Suche auf alle Stellen ein, an denen das Keyword-Only-Argument reply_to_address übergeben wird:

$ pyastgrep './/Call[func[Name[@id="send_email"] or Attribute[@attr="send_email"]] and keywords/keyword[@arg="reply_to_address"]]' src
src/panel/signals.py:18:13:            email.send_email(
src/panel/billing/notifications.py:51:5:    email.send_email(
src/project/tests/test_email_backend.py:30:5:    email.send_email(
src/db/access/member_helper.py:57:9:        send_email(
src/db/access/user/tickets.py:51:9:        email.send_email(
[... 5 weitere Resultate]

Call[... and ...] selektiert alle Funktionsaufrufe die beiden Bedingungen entsprechen (Funktionsname und Vorhandensein des Keyword-Arguments). keywords/keyword[...] iteriert über alle Keyword-Argumente des Funktionsaufrufs. @arg="reply_to_address" selektiert die Keyword-Argumente, die das Keyword reply_to_address verwenden (send_email(..., reply_to_address=...)).

Die Bedingung für das Keyword-Argument kann auch gut umgedreht werden. Als letztes Beispiel selektiere ich hier alle Aufrufe von send_email() bei denen das Argument reply_to_address nicht übergeben wird:

$ pyastgrep './/Call[func[Name[@id="send_email"] or Attribute[@attr="send_email"]] and not(keywords/keyword[@arg="reply_to_address"])]' src
src/panel/invoices/__init__.py:169:30:    to_address, cc_address = email.send_email(
src/panel/payment/__init__.py:46:9:        email.send_email(
src/panel/email/tests/test_template_rendering.py:15:5:    email.send_email(
src/panel/billing/notifications.py:28:30:    to_address, cc_address = email.send_email(
src/services/openstack/functions.py:103:5:    send_email(

Genauigkeit ist immer eine Abwägung

Als Abschluss möchte ich anmerken, dass das oben gezeigte Beispiel aus verschiedenen Gründen falsche Resultate liefern kann, also zu viele oder zu wenige. Jede dieser Abweichungen kann begegnet werden, mit jeweils unterschiedlichem Aufwand und nicht in jedem Fall perfekt. Dies sind ein paar Beispiele für Ungenauigkeiten, die ich im Beispiel oben zugelassen habe:

  • Es könnte neben der gesuchten Funktion weitere Funktionen mit dem Namen send_mail geben. Um dem zu entgegnen, müssten die import-Anweisungen in jeder Source-Datei sowie das Vorhandensein von lokalen Variablen analysiert werden.
  • Die Funktion send_mail könnte in einer Datei unter einem anderen Namen importiert worden sein, z.B. mit from panel.email import send_email as send_email_, vielleicht um einem Namenskonflikt aus dem Weg zu gehen. Auch hierfür müssten die import-Anweisungen analysiert werden.
  • Das Argument reply_to_address könnte als Positional-Argument übergeben werden. In dem Fall müsste das Argument anhand der Position in der Argumentliste statt des Keywords reply_to_address selektiert werden.
  • Das Argument reply_to_address könnte dynamisch via **kwargs übergeben werden. Dieser Fall ist sehr schwierig automatisiert vollständig zu erkennen. In unserem Fall wäre es am effektivsten gewesen, die Stellen, an denen **kwargs verwendet wird, automatisiert zu finden und diese dann manuell zu prüfen.

In den meisten Fällen gibt es keine perfekte Lösung, oder der Aufwand dafür ist grösser als der Nutzen. In diesen Fällen ist man gezwungen, eine Abwägung zwischen Genauigkeit, Flexibilität und Aufwand zu machen. Je grösser eine Applikation wird, je mehr gewinnen in meiner Erfahrung Genauigkeit und Flexibilität an Gewicht.


Wenn du uns Kommentare oder Korrekturen mitteilen möchtest, kannst du unsere Engineers unter engineering-blog@cloudscale.ch erreichen.

Back to overview