Engineering Blog
BackSearching 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
undName
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 namesend_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. inemail.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 Keywordreply_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 dieimport
-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. mitfrom panel.email import send_email as send_email_
, vielleicht um einem Namenskonflikt aus dem Weg zu gehen. Auch hierfür müssten dieimport
-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 Keywordsreply_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.