Was ist der __pycache__-Ordner in Python?


Wenn Sie ein eigenständiges Python-Skript entwickeln, fällt Ihnen möglicherweise nichts Ungewöhnliches an Ihrer Verzeichnisstruktur auf. Sobald Ihr Projekt jedoch komplexer wird, entscheiden Sie sich häufig dafür, Teile der Funktionalität in zusätzliche Module oder Pakete zu extrahieren. Dann bemerken Sie möglicherweise, dass aus dem Nichts neben Ihren Quelldateien an scheinbar zufälligen Stellen ein Ordner __pycache__ erscheint:

project/
│
├── mathematics/
│   │
│   ├── __pycache__/
│   │
│   ├── arithmetic/
│   │   ├── __init__.py
│   │   ├── add.py
│   │   └── sub.py
│   │
│   ├── geometry/
│   │   │
│   │   ├── __pycache__/
│   │   │
│   │   ├── __init__.py
│   │   └── shapes.py
│   │
│   └── __init__.py
│
└── calculator.py

Beachten Sie, dass der Ordner __pycache__ auf verschiedenen Ebenen in der Verzeichnisstruktur Ihres Projekts vorhanden sein kann, wenn Sie mehrere ineinander verschachtelte Unterpakete haben. Gleichzeitig enthalten andere Pakete oder Ordner mit Ihren Python-Quelldateien dieses mysteriöse Cache-Verzeichnis möglicherweise nicht.

Eine ähnliche Situation kann auftreten, nachdem Sie ein Remote-Git-Repository mit einem Python-Projekt geklont und den zugrunde liegenden Code ausgeführt haben. Was bewirkt also, dass der Ordner __pycache__ angezeigt wird, und zu welchem Zweck?

Kurz gesagt: Es beschleunigt den Import von Python-Modulen

Auch wenn Python eine interpretierte Programmiersprache ist, arbeitet der Interpreter nicht direkt mit Ihrem Python-Code, was sehr langsam wäre. Wenn Sie stattdessen ein Python-Skript ausführen oder ein Python-Modul importieren, kompiliert der Interpreter Ihren Python-Quellcode auf hoher Ebene in Bytecode, der eine binäre Zwischendarstellung des Codes darstellt.

Dieser Bytecode ermöglicht es dem Interpreter, wiederkehrende Schritte zu überspringen, wie z. B. das Lexen und Parsen des Codes in einen abstrakten Syntaxbaum und die Überprüfung seiner Richtigkeit jedes Mal, wenn Sie dasselbe Programm ausführen. Solange sich der zugrunde liegende Quellcode nicht geändert hat, kann Python die Zwischendarstellung wiederverwenden, die sofort zur Ausführung bereit ist. Dies spart Zeit und beschleunigt die Startzeit Ihres Skripts.

Denken Sie daran, dass das Laden des kompilierten Bytecodes von __pycache__ zwar den Import von Python-Modulen beschleunigt, ihre Ausführungsgeschwindigkeit jedoch nicht beeinträchtigt!

Warum sich überhaupt mit Bytecode beschäftigen, anstatt den Code direkt in den Low-Level-Maschinencode zu kompilieren? Während Maschinencode das ist, was auf der Hardware ausgeführt wird und die ultimative Leistung bietet, ist er nicht so portabel oder schnell zu erstellen wie Bytecode.

Maschinencode ist ein Satz binärer Anweisungen, die von Ihrer spezifischen CPU-Architektur verstanden werden und je nach Betriebssystem in ein Containerformat wie EXE, ELF oder Mach-O verpackt sind. Im Gegensatz dazu bietet Bytecode eine plattformunabhängige Abstraktionsschicht und ist in der Regel schneller zu kompilieren.

Python verwendet lokale __pycache__-Ordner, um den kompilierten Bytecode importierter Module in Ihrem Projekt zu speichern. Bei nachfolgenden Durchläufen wird der Interpreter versuchen, vorkompilierte Versionen von Modulen aus diesen Ordnern zu laden, sofern sie mit den entsprechenden Quelldateien auf dem neuesten Stand sind. Beachten Sie, dass dieser Caching-Mechanismus nur für Module ausgelöst wird, die Sie in Ihren Code importieren und nicht als Skripte im Terminal ausführen.

Zusätzlich zu diesem Bytecode-Caching auf der Festplatte behält Python einen In-Memory-Cache von Modulen bei, auf den Sie über sys.modules zugreifen können Wörterbuch. Dadurch wird sichergestellt, dass Python das bereits importierte Modul verwendet, wenn Sie dasselbe Modul mehrmals von verschiedenen Stellen in Ihrem Programm importieren, ohne es neu laden oder neu kompilieren zu müssen. Beide Mechanismen arbeiten zusammen, um den Aufwand beim Importieren von Python-Modulen zu reduzieren.

Als Nächstes erfahren Sie genau, wie viel schneller Python den zwischengespeicherten Bytecode lädt, als wenn Sie den Quellcode beim Importieren eines Moduls spontan kompilieren.

Wie viel schneller ist das Laden von Modulen aus dem Cache?

Das Caching geschieht im Verborgenen und bleibt normalerweise unbemerkt, da Python den Bytecode recht schnell kompiliert. Außerdem bleibt der Kompilierungsschritt im Vergleich zur Gesamtausführungszeit unbedeutend, es sei denn, Sie führen häufig kurzlebige Python-Skripte aus. Allerdings könnte sich der mit der Bytecode-Kompilierung verbundene Overhead ohne Caching summieren, wenn Sie viele Module hätten und diese viele Male importieren würden.

Um den Unterschied in der Importzeit zwischen einem zwischengespeicherten und einem nicht zwischengespeicherten Modul zu messen, können Sie die Option -X importtime an den Befehl python übergeben oder festlegen entsprechende Umgebungsvariable PYTHONPROFILEIMPORTTIME. Wenn diese Option aktiviert ist, zeigt Python eine Tabelle an, die zusammenfasst, wie lange es gedauert hat, jedes Modul zu importieren, einschließlich der Gesamtzeit für den Fall, dass ein Modul von anderen Modulen abhängt.

Angenommen, Sie hätten ein calculator.py-Skript, das eine Hilfsfunktion aus einem lokalen arithmetic.py-Modul importiert und aufruft:

from arithmetic import add

add(3, 4)

Das importierte Modul definiert eine einzelne Funktion:

def add(a, b):
    return a + b

Wie Sie sehen können, delegiert das Hauptskript die Addition von zwei Zahlen, drei und vier, an die Funktion add(), die aus dem Modul arithmetic importiert wurde.

Wenn Sie Ihr Skript zum ersten Mal ausführen, kompiliert Python den Bytecode des von Ihnen importierten Moduls und speichert ihn in einem lokalen Ordner __pycache__. Wenn ein solcher Ordner noch nicht vorhanden ist, erstellt Python automatisch einen, bevor es fortfährt. Wenn Sie nun Ihr Skript erneut ausführen, sollte Python den zwischengespeicherten Bytecode finden und laden, sofern Sie den zugehörigen Quellcode nicht geändert haben.

Sobald der Cache aufgewärmt ist, trägt er zu einer schnelleren Startzeit Ihres Python-Skripts bei:

$ python -X importtime calculator.py
(...)
import time:     20092 |      20092 | arithmetic

$ python -X importtime calculator.py
(...)
import time:       232 |        232 | arithmetic

$ python -X importtime calculator.py
(...)
import time:       203 |        203 | arithmetic

Obwohl die genauen Messwerte von Lauf zu Lauf variieren können, ist die Verbesserung deutlich sichtbar. Ohne den Ordner __pycache__ war der erste Versuch, arithmetic zu importieren, zwei Größenordnungen langsamer als bei nachfolgenden Durchläufen. Diese Veränderung mag auf den ersten Blick verblüffend erscheinen, aber diese Werte werden in Mikrosekunden ausgedrückt, sodass Sie den Unterschied trotz eines so dramatischen Rückgangs der Zahlen höchstwahrscheinlich nicht einmal bemerken werden.

Der Leistungsgewinn spiegelt sich in der Regel kaum in der Startzeit der meisten Python-Skripte wider, die Sie auf Unix-ähnlichen Systemen mit dem Befehl time abschätzen können:

$ rm -rf __pycache__
$ time python calculator.py

real    0m0.088s
user    0m0.064s
sys     0m0.028s

$ time python calculator.py

real    0m0.086s
user    0m0.060s
sys     0m0.030s

Dabei bleibt die Gesamtausführungszeit nahezu gleich, unabhängig davon, ob der Cache vorhanden ist oder nicht. Das Entfernen des Ordners __pycache__ verzögert die Ausführung um etwa zwei Millisekunden, was für die meisten Anwendungen vernachlässigbar ist.

Der Bytecode-Compiler von Python ist ziemlich schnell, wenn man ihn mit einem anspruchsvolleren Gegenstück in Java vergleicht, das die statische Typisierung nutzen kann.

Wenn Sie beispielsweise über eine Beispieldatei Calculator.java verfügen, können Sie diese entweder im Voraus in eine Datei .class kompilieren, was der üblichen Arbeitsweise mit Java-Code entspricht , oder führen Sie die Datei .java direkt aus. Im letzteren Fall kompiliert die Java-Laufzeitumgebung den Code im Hintergrund an einem temporären Speicherort, bevor er ausgeführt wird:

$ javac Calculator.java
$ time java Calculator

real    0m0.039s
user    0m0.026s
sys     0m0.019s

$ time java Calculator.java

real    0m0.574s
user    0m1.182s
sys     0m0.069s

Wenn Sie den Quellcode manuell mit dem Befehl javac kompilieren und den resultierenden Bytecode ausführen, dauert die Ausführung etwa vierzig Millisekunden. Wenn Sie die Kompilierung hingegen dem Befehl java überlassen, erhöht sich die Gesamtausführungszeit auf etwas mehr als eine halbe Sekunde. Daher ist der Overhead des Java-Compilers im Gegensatz zu Python selbst bei einer parallelen Kompilierung auf mehreren CPU-Kernen sehr spürbar!

Nachdem Sie nun den Zweck des Ordners __pycache__ kennen, sind Sie vielleicht neugierig auf seinen Inhalt.

Was befindet sich in einem __pycache__-Ordner?

Wenn Sie einen Blick in den Ordner __pycache__ werfen, sehen Sie eine oder mehrere Dateien, die mit der Erweiterung .pyc enden. Es steht für ein kompiliertes Python-Modul:

$ ls -1 __pycache__
arithmetic.cpython-311.pyc
arithmetic.cpython-312.pyc
arithmetic.pypy310.opt-1.pyc
arithmetic.pypy310.opt-2.pyc
arithmetic.pypy310.pyc
solver.cpython-312.pyc
units.cpython-312.pyc

Jede dieser Dateien enthält den Bytecode des entsprechenden Python-Moduls, das im aktuellen Paket definiert ist und das Sie zur Laufzeit importiert haben. Der kompilierte Bytecode zielt auf eine bestimmte Python-Implementierung, -Version und eine optionale Optimierungsstufe ab. Alle diese Informationen sind im Dateinamen verschlüsselt.

Beispielsweise ist eine Datei namens arithmetic.pypy310.opt-2.pyc der Bytecode Ihres arithmetic.py-Moduls, das von PyPy 3.10 mit einer Optimierungsstufe von zwei kompiliert wurde. Diese Optimierung entfernt die assert-Anweisungen und verwirft alle Dokumentzeichenfolgen. Umgekehrt stellt arithmetic.cpython-312.pyc dasselbe Modul dar, das jedoch ohne Optimierungen für CPython 3.12 kompiliert wurde.

Insgesamt gibt es fünf Bytecode-Varianten, die aus einem einzigen Modul arithmetic.py im Ordner __pycache__ oben kompiliert wurden und hervorgehoben sind.

Ein solches Dateibenennungsschema gewährleistet die Kompatibilität zwischen verschiedenen Python-Versionen und -Varianten. Wenn Sie dasselbe Skript mit PyPy oder einer früheren CPython-Version ausführen, kompiliert der Interpreter alle importierten Module in seiner eigenen Laufzeitumgebung, sodass er sie später wiederverwenden kann. Die Python-Version wird zusammen mit anderen Metadaten auch in der Datei .pyc selbst gespeichert.

Am Ende dieses Tutorials werden Sie sich die im Cache-Ordner gespeicherten .pyc-Dateien genauer ansehen. Jetzt ist es an der Zeit, mehr über die Umstände zu erfahren, die Python dazu veranlassen, den Cache-Ordner zu erstellen.

Wann erstellt Python Cache-Ordner?

Der Interpreter speichert den kompilierten Bytecode von Python-Modulen nur dann im Ordner __pycache__, wenn Sie diese Module oder manchmal ihr übergeordnetes Paket importieren. Der Cache-Ordner wird nicht erstellt, wenn Sie ein normales Python-Skript ausführen, das keine Module oder Pakete importiert. Dies basiert auf der Annahme, dass sich Module weniger wahrscheinlich ändern und dass Sie sie während einer einzelnen Ausführung möglicherweise mehrmals importieren.

Wenn Sie ein einzelnes Modul aus einem Paket importieren, erstellt Python die entsprechende .pyc-Datei und speichert sie im Ordner __pycache__, der sich innerhalb dieses Pakets befindet . Außerdem wird die Datei __init__.py des Pakets kompiliert, andere Module oder verschachtelte Unterpakete werden jedoch nicht berührt. Wenn jedoch das importierte Modul selbst andere Module importiert, werden diese Module ebenfalls kompiliert und so weiter.

Hier ist ein Beispiel, das den einfachsten Fall demonstriert, vorausgesetzt, Sie haben eine geeignete import-Anweisung in das folgende Skript calculator.py eingefügt:

project/
│
├── arithmetic/
│   │
│   ├── __pycache__/
│   │   ├── __init__.cpython-312.pyc
│   │   └── add.cpython-312.pyc
│   │
│   ├── __init__.py
│   ├── add.py
│   └── sub.py
│
└── calculator.py

Nach der ersten Ausführung des Skripts stellt Python sicher, dass im Paket arithmetic ein Ordner __pycache__ vorhanden ist. Anschließend wird die Datei __init__.py des Pakets zusammen mit allen aus diesem Paket importierten Modulen kompiliert. In diesem Fall haben Sie nur den Import des Moduls arithmetic.add angefordert, sodass Sie eine .pyc-Datei sehen können, die mit add.py verknüpft ist nicht mit sub.py.

Alle folgenden import-Anweisungen würden das gleiche oben dargestellte Ergebnis liefern:

import arithmetic.add
from arithmetic import add
from arithmetic.add import add_function

Unabhängig davon, wie Sie ein Python-Modul importieren und ob Sie es vollständig oder nur ein bestimmtes Symbol, beispielsweise eine Klasse oder eine Konstante, importieren, kompiliert der Interpreter das gesamte Modul, da er Module nicht teilweise lesen kann.

Umgekehrt würde das Importieren eines ganzen Pakets normalerweise dazu führen, dass Python nur __init__.py in diesem Paket kompiliert. Es ist jedoch durchaus üblich, dass Pakete ihre internen Module oder Unterpakete aus __init__.py heraus zugänglich machen, um einen bequemeren Zugriff zu ermöglichen. Bedenken Sie zum Beispiel Folgendes:

from arithmetic import add
from arithmetic.sub import sub_function

Importe innerhalb von __init__.py wie diese erstellen zusätzliche .pyc-Dateien, selbst wenn Sie die einfache import arithmetic-Anweisung in Ihrem Skript verwenden.

Wenn Sie ein Unterpaket oder ein tief verschachteltes Modul oder Symbol importiert haben, werden für alle Zwischenpakete, die zum Paket der obersten Ebene führen, auch die Dateien __init__.py kompiliert und platziert in ihren jeweiligen Cache-Ordnern. Allerdings wird Python nicht in die andere Richtung gehen und die verschachtelten Unterpakete rekursiv scannen, da dies unnötig wäre. Es kompiliert nur die Module, die Sie wirklich benötigen, indem es sie explizit oder indirekt importiert.

Was passiert, wenn Python Ihr Modul bereits in eine .pyc-Datei kompiliert hat, Sie sich aber dazu entschließen, seinen Quellcode in der ursprünglichen .py-Datei zu ändern? Das erfahren Sie als Nächstes!

Welche Aktionen machen den Cache ungültig?

Das Ausführen eines veralteten Bytecodes könnte einen Fehler verursachen oder, schlimmer noch, zu völlig unvorhersehbarem Verhalten führen. Glücklicherweise ist Python clever genug, um zu erkennen, wenn Sie den Quellcode eines kompilierten Moduls ändern, und es bei Bedarf neu zu kompilieren.

Um festzustellen, ob ein Modul neu kompiliert werden muss, verwendet Python eine von zwei Cache-Invalidierungsstrategien:

  1. Zeitstempelbasiert
  2. Hash-basiert

Der erste vergleicht die Größe der Quelldatei und ihren Zeitstempel der letzten Änderung mit den Metadaten, die in der entsprechenden .pyc-Datei gespeichert sind. Später erfahren Sie, wie diese Werte zusammen mit anderen Metadaten in der Datei .pyc gespeichert werden.

Im Gegensatz dazu berechnet die zweite Strategie den Hash-Wert der Quelldatei und vergleicht ihn mit einem speziellen Feld im Header der Datei (PEP 552), das in Python 3.7 eingeführt wurde. Diese Strategie ist sicherer und deterministischer, aber auch etwas langsamer. Aus diesem Grund bleibt die zeitstempelbasierte Strategie vorerst die Standardstrategie.

Wenn Sie die Änderungszeit (mtime) der Quelldatei künstlich aktualisieren, beispielsweise durch Verwendung des Befehls touch unter macOS oder Linux, zwingen Sie Python, die Datei zu kompilieren Modul noch einmal:

$ tree -D --dirsfirst
[Apr 26 09:48]  .
├── [Apr 26 09:48]  __pycache__
│     └── [Apr 26 09:48]  arithmetic.cpython-312.pyc
├── [Apr 26 09:48]  arithmetic.py
└── [Apr 26 09:48]  calculator.py

2 directories, 3 files

$ touch arithmetic.py
$ python calculator.py

$ tree -D --dirsfirst
[Apr 26 09:48]  .
├── [Apr 26 09:52]  __pycache__
│     └── [Apr 26 09:52]  arithmetic.cpython-312.pyc
├── [Apr 26 09:52]  arithmetic.py
└── [Apr 26 09:48]  calculator.py

2 directories, 3 files

Ursprünglich wurde die zwischengespeicherte Datei arithmetic.cpython-312.pyc zuletzt um 09:48 Uhr geändert. Nach dem Berühren der Quelldatei arithmetic.py hält Python den kompilierten Bytecode für veraltet und kompiliert das Modul neu, wenn Sie das Skript ausführen, das arithmetic importiert. Dies führt zu einer neuen .pyc-Datei mit einem aktualisierten Zeitstempel von 09:52 Uhr.

Um Hash-basierte .pyc-Dateien zu erstellen, müssen Sie das compileall-Modul von Python mit der entsprechend eingestellten Option --invalidation-mode verwenden. Dieser Befehl kompiliert beispielsweise alle Module im aktuellen Ordner und den Unterordnern in der sogenannten checked Hash-basierten Variante:

$ python -m compileall --invalidation-mode checked-hash

Die offizielle Dokumentation erklärt den Unterschied zwischen geprüften und ungeprüften Varianten der Hash-basierten .pyc-Dateien wie folgt:

Bei überprüften Hash-basierten .pyc-Dateien validiert Python die Cache-Datei, indem es die Quelldatei hasht und den resultierenden Hash mit dem Hash in der Cache-Datei vergleicht. Wenn sich herausstellt, dass eine überprüfte Hash-basierte Cache-Datei ungültig ist, generiert Python sie neu und schreibt eine neue überprüfte Hash-basierte Cache-Datei. Bei ungeprüften Hash-basierten .pyc-Dateien geht Python einfach davon aus, dass die Cache-Datei gültig ist, sofern sie existiert. (Quelle)

Dennoch können Sie das Standardvalidierungsverhalten hashbasierter .pyc-Dateien jederzeit mit der Option --check-hash-based-pycs überschreiben, wenn Sie den Python-Interpreter ausführen.

Wenn Sie wissen, wann und wo Python die Ordner __pycache__ erstellt und wann deren Inhalt aktualisiert wird, erhalten Sie eine Vorstellung davon, ob es sicher ist, sie zu entfernen.

Ist es sicher, einen Cache-Ordner zu entfernen?

Ja, obwohl Sie sich fragen sollten, ob Sie das wirklich sollten! An diesem Punkt verstehen Sie, dass das Entfernen eines Ordners __pycache__ harmlos ist, da Python den Cache bei jedem Aufruf neu generiert. Ohnehin ist das manuelle Entfernen der einzelnen Cache-Ordner eine mühsame Arbeit. Außerdem dauert es nur bis zur nächsten Ausführung Ihres Codes.

Die gute Nachricht ist, dass Sie das Entfernen dieser Cache-Ordner aus Ihrem Projekt automatisieren können, wenn Sie wirklich darauf bestehen, was Sie jetzt tun werden.

Wie entferne ich alle Cache-Ordner rekursiv?

Okay, Sie haben bereits festgestellt, dass das Entfernen des zwischengespeicherten Bytecodes, den Python kompiliert, keine große Sache ist. Das Problem mit diesen __pycache__-Ordnern besteht jedoch darin, dass sie in mehreren Unterverzeichnissen erscheinen können, wenn Sie eine komplexe Projektstruktur haben. Sie manuell zu finden und zu löschen wäre mühsam, vor allem, da sie jedes Mal, wenn Sie Python ausführen, wie ein Phönix aus der Asche auferstehen.

Unten finden Sie die plattformspezifischen Befehle, die alle __pycache__-Ordner auf einmal rekursiv aus dem aktuellen Verzeichnis und allen darin verschachtelten Unterverzeichnissen entfernen:

Das erste Code-Snippet sollte unter Windows verwendet werden und das zweite Code-Snippet ist für Linux + macOS:

PS> $dirs = Get-ChildItem -Path . -Filter __pycache__ -Recurse -Directory
PS> $dirs | Remove-Item -Recurse -Force
$ find . -type d -name __pycache__ -exec rm -rf {} +

Sie sollten bei der Ausführung von Massenlöschbefehlen Vorsicht walten lassen, da dadurch mehr gelöscht werden kann, als Sie beabsichtigt haben, wenn Sie nicht vorsichtig sind. Überprüfen Sie immer die Pfade und Filterkriterien, bevor Sie solche Befehle ausführen!

Das Löschen der Ordner __pycache__ kann den Arbeitsbereich Ihres Projekts aufräumen, allerdings nur vorübergehend. Wenn es Sie immer noch nervt, dass Sie den rekursiven Löschbefehl wiederholt ausführen müssen, möchten Sie möglicherweise zunächst die Kontrolle über die Verwaltung des Cache-Ordners übernehmen. Als Nächstes erkunden Sie zwei Ansätze zur Lösung dieses Problems.

Wie kann verhindert werden, dass Python Cache-Ordner erstellt?

Wenn Sie nicht möchten, dass Python den kompilierten Bytecode zwischenspeichert, können Sie beim Ausführen eines Skripts die Option -B an den Befehl python übergeben. Dadurch wird verhindert, dass die Ordner __pycache__ angezeigt werden, es sei denn, sie sind bereits vorhanden. Allerdings wird Python weiterhin alle .pyc-Dateien nutzen, die es in vorhandenen Cache-Ordnern finden kann. Es werden einfach keine neuen Dateien auf die Festplatte geschrieben.

Für einen dauerhafteren und globalen Effekt, der sich über mehrere Python-Interpreter erstreckt, können Sie die Umgebungsvariable PYTHONDONTWRITEBYTECODE in Ihrer Shell oder deren Konfigurationsdatei festlegen:

export PYTHONDONTWRITEBYTECODE=1

Es wirkt sich auf jeden Python-Interpreter aus, auch auf einen in einer von Ihnen aktivierten virtuellen Umgebung.

Dennoch sollten Sie sorgfältig darüber nachdenken, ob die Unterdrückung der Bytecode-Kompilierung für Ihren Anwendungsfall der richtige Ansatz ist. Die Alternative besteht darin, Python anzuweisen, die einzelnen __pycache__-Ordner an einem einzigen freigegebenen Speicherort in Ihrem Dateisystem zu erstellen.

Wie speichere ich den Cache in einem zentralen Ordner?

Wenn Sie die Bytecode-Kompilierung vollständig deaktivieren, erhalten Sie einen aufgeräumteren Arbeitsbereich, verlieren jedoch die Vorteile des Cachings für schnellere Ladezeiten. Wenn Sie das Beste aus beiden Welten kombinieren möchten, können Sie Python mit dem -X pycache_prefix.pyc-Dateien in einen parallelen Baum zu schreiben, der im angegebenen Verzeichnis verwurzelt ist. Code> Option:

$ python -X pycache_prefix=/tmp/pycache calculator.py

In diesem Fall weisen Sie Python an, den kompilierten Bytecode in einem temporären Ordner unter /tmp/pycache in Ihrem Dateisystem zwischenzuspeichern. Wenn Sie diesen Befehl ausführen, versucht Python nicht mehr, lokale __pycache__-Ordner in Ihrem Projekt zu erstellen. Stattdessen wird die Verzeichnisstruktur Ihres Projekts im angegebenen Stammordner gespiegelt und alle .pyc-Dateien dort gespeichert:

tmp/
└── pycache/
    └── home/
        └── user/
            │
            ├── other_project/
            │   └── solver.cpython-312.pyc
            │
            └── project/
                │
                └── mathematics/
                    │
                    ├── arithmetic/
                    │   ├── __init__.cpython-312.pyc
                    │   ├── add.cpython-312.pyc
                    │   └── sub.cpython-312.pyc
                    │
                    ├── geometry/
                    │   ├── __init__.cpython-312.pyc
                    │   └── shapes.cpython-312.pyc
                    │
                    └── __init__.cpython-312.pyc

Beachten Sie hier zwei Dinge. Erstens besteht keine Notwendigkeit, die kompilierten .pyc-Dateien in den __pycache__-Ordnern zu verschachteln, da das Cache-Verzeichnis von Ihrem Quellcode getrennt gehalten wird. Zweitens können Sie diesen Cache-Ordner zwischen mehreren Projekten teilen, da die Hierarchie innerhalb eines solchen zentralen Caches mit der Struktur Ihres Projekts übereinstimmt.

Zu den weiteren Vorteilen dieses Setups gehört eine einfachere Bereinigung, da Sie alle .pyc-Dateien, die zum selben Projekt gehören, mit einem einzigen Tastendruck entfernen können, ohne alle Verzeichnisse manuell durchsuchen zu müssen. Darüber hinaus können Sie den Cache-Ordner auf einer separaten physischen Festplatte speichern, um parallele Lesevorgänge zu nutzen, oder den Cache in einem persistenten Volume aufbewahren, wenn Sie mit Docker-Containern arbeiten.

Denken Sie daran, dass Sie die Option -X pycache_prefix jedes Mal verwenden müssen, wenn Sie den Befehl python ausführen, damit dies konsistent funktioniert. Alternativ können Sie den Pfad zu einem freigegebenen Cache-Ordner über die Umgebungsvariable PYTHONPYCACHEPREFIX festlegen:

export PYTHONPYCACHEPREFIX=/tmp/pycache

In beiden Fällen können Sie programmgesteuert überprüfen, ob Python das angegebene Cache-Verzeichnis verwendet oder auf das Standardverhalten zurückgreift und lokale __pycache__-Ordner erstellt:

>>> import sys
>>> sys.pycache_prefix
'/tmp/pycache'

Die Variable sys.pycache_prefix kann entweder eine Zeichenfolge oder Keine sein.

Sie haben mit diesem Tutorial einen langen Weg zurückgelegt und wissen jetzt ein oder zwei Dinge über den Umgang mit __pycache__-Ordnern in Ihren Python-Projekten. Endlich ist es an der Zeit, zu sehen, wie Sie direkt mit den in diesen Ordnern gespeicherten .pyc-Dateien arbeiten können.

Was befindet sich in einer zwischengespeicherten .pyc-Datei?

Eine .pyc-Datei besteht aus einem Header mit Metadaten, gefolgt vom serialisierten Codeobjekt, das zur Laufzeit ausgeführt werden soll. Der Header der Datei beginnt mit einer magischen Zahl, die die spezifische Python-Version, für die der Bytecode kompiliert wurde, eindeutig identifiziert. Als nächstes gibt es ein in PEP 552 definiertes Bitfeld, das eine der drei zuvor erläuterten Cache-Invalidierungsstrategien bestimmt.

In zeitstempelbasierten .pyc-Dateien wird das Bitfeld mit Nullen gefüllt, gefolgt von zwei Vier-Byte-Feldern. Diese Felder entsprechen der Unix-Zeit der letzten Änderung bzw. der Größe der Quelldatei .py:

Offset Field Size Field Description
0 4 Magic number Identifies the Python version
4 4 Bit field Filled with zeros
8 4 Timestamp The time of .py file’s modification
12 4 File size Concerns the source .py file

Umgekehrt kann das Bitfeld bei Hash-basierten .pyc-Dateien entweder gleich eins sein, was eine ungeprüfte Variante anzeigt, oder drei, was bedeutet die geprüfte Variante. Dann gibt es statt des Zeitstempels und der Dateigröße nur ein acht Byte langes Feld mit dem Hash-Wert des Python-Quellcodes:

Offset Field Size Field Description
0 4 Magic number Identifies the Python version
4 4 Bit field Equals 1 (unchecked) or 3 (checked)
8 8 Hash value Source code’s hash value

In beiden Fällen ist der Header sechzehn Bytes lang. Sie können ihn überspringen, wenn Sie nicht daran interessiert sind, die codierten Metadaten zu lesen. Auf diese Weise springen Sie direkt zu dem mit dem Modul marshal serialisierten Codeobjekt, das den verbleibenden Teil der Datei .pyc einnimmt.

Mit diesen Informationen können Sie eine Ihrer kompilierten .pyc-Dateien durchleuchten und den zugrunde liegenden Bytecode direkt ausführen, auch wenn Sie nicht mehr über die ursprüngliche .py-Datei verfügen zugehörigen Quellcode.

Wie lese und führe ich den zwischengespeicherten Bytecode aus?

Python selbst führt nur dann eine begleitende .pyc-Datei aus, wenn die ursprüngliche .py-Datei noch vorhanden ist. Wenn Sie das Quellmodul entfernen, nachdem es bereits kompiliert wurde, weigert sich Python, die Datei .pyc auszuführen. Das ist beabsichtigt. Sie können den Bytecode jedoch jederzeit manuell ausführen, wenn Sie möchten.

Das folgende Python-Skript zeigt, wie Sie den Header der Datei .pyc lesen und den dazugehörigen Bytecode deserialisieren und ausführen können:

import marshal
from datetime import datetime, timezone
from importlib.util import MAGIC_NUMBER
from pathlib import Path
from pprint import pp
from py_compile import PycInvalidationMode
from sys import argv
from types import SimpleNamespace

def main(path):
    metadata, code = load_pyc(path)
    pp(vars(metadata))
    if metadata.magic_number == MAGIC_NUMBER:
        exec(code, globals())
    else:
        print("Bytecode incompatible with this interpreter")

def load_pyc(path):
    with Path(path).open(mode="rb") as file:
        return (
            parse_header(file.read(16)),
            marshal.loads(file.read()),
        )

def parse_header(header):
    metadata = SimpleNamespace()
    metadata.magic_number = header[0:4]
    metadata.magic_int = int.from_bytes(header[0:4][:2], "little")
    metadata.python_version = f"3.{(metadata.magic_int - 2900) // 50}"
    metadata.bit_field = int.from_bytes(header[4:8], "little")
    metadata.pyc_type = {
        0: PycInvalidationMode.TIMESTAMP,
        1: PycInvalidationMode.UNCHECKED_HASH,
        3: PycInvalidationMode.CHECKED_HASH,
    }.get(metadata.bit_field)
    if metadata.pyc_type is PycInvalidationMode.TIMESTAMP:
        metadata.timestamp = datetime.fromtimestamp(
            int.from_bytes(header[8:12], "little"),
            timezone.utc,
        )
        metadata.file_size = int.from_bytes(header[12:16], "little")
    else:
        metadata.hash_value = header[8:16]
    return metadata

if __name__ == "__main__":
    main(argv[1])

Hier passieren mehrere Dinge, die Sie Zeile für Zeile aufschlüsseln können:

  • Zeile 11 empfängt ein Tupel, das die aus dem Header der Datei analysierten Metadaten und ein deserialisiertes Codeobjekt enthält, das zur Ausführung bereit ist. Beide werden aus einer .pyc-Datei geladen, die als einziges erforderliches Befehlszeilenargument angegeben ist.
  • Zeile 12 gibt die entschlüsselten Metadaten hübsch auf dem Bildschirm aus.
  • Zeilen 13 bis 16 führen den Bytecode aus der Datei .pyc bedingt mit exec() aus oder geben eine Fehlermeldung aus. Um festzustellen, ob die Datei für die aktuelle Interpreterversion kompiliert wurde, vergleicht dieses Codefragment die aus dem Header erhaltene magische Zahl mit der magischen Zahl des Interpreters. Wenn alles gelingt, werden die Symbole des geladenen Moduls in globals() importiert.
  • Zeilen 18 bis 23 öffnen die Datei .pyc im Binärmodus mit dem Modul pathlib, analysieren den Header und entmarshalieren das Codeobjekt.
  • Zeilen 25 bis 44 analysieren die Header-Felder unter Verwendung ihrer entsprechenden Offsets und Bytegrößen und interpretieren Multibyte-Werte mit der Little-Endian-Bytereihenfolge.
  • Zeilen 28 und 29 extrahieren die Python-Version aus der magischen Zahl, die mit jeder Nebenversion von Python gemäß der Formel 2900 + 50n erhöht wird, wobei n die Nebenversion von Python 3.11 oder höher ist.
  • Zeilen 31 bis 35 bestimmen den Typ der .pyc-Datei basierend auf dem vorhergehenden Bitfeld (PEP 552).
  • Zeilen 37 bis 40 konvertieren die Änderungszeit der Quelldatei in ein datetime-Objekt in der UTC-Zeitzone.

Sie können das oben beschriebene Röntgenskript für eine .pyc-Datei Ihrer Wahl ausführen. Wenn Sie den interaktiven Modus von Python aktivieren (-i), können Sie die Variablen und den Status des Programms nach dessen Beendigung überprüfen:

$ python -i xray.py __pycache__/arithmetic.cpython-312.pyc
{'magic_number': b'\xcb\r\r\n',
 'magic_int': 3531,
 'python_version': '3.12',
 'bit_field': 0,
 'pyc_type': <PycInvalidationMode.TIMESTAMP: 1>,
 'timestamp': datetime.datetime(2024, 4, 26, 17, 34, 57, tzinfo=….utc),
 'file_size': 32}
>>> add(3, 4)
7

Das Skript gibt die dekodierten Header-Felder aus, einschließlich der magischen Zahl und der Änderungszeit der Quelldatei. Unmittelbar danach gelangen Sie aufgrund der Option python -i in die interaktive Python-REPL, wo Sie add() aufrufen, die von in den globalen Namespace importiert wurde Ausführen des Bytecodes des Moduls.

Dies funktioniert wie erwartet, da der Python-Interpreter, den Sie gerade ausführen, zufällig mit der Bytecode-Version des Moduls übereinstimmt. Folgendes würde passieren, wenn Sie versuchen würden, eine andere .pyc-Datei auszuführen, die auf eine andere Python-Version oder eine ihrer alternativen Implementierungen abzielt:

$ python -i xray.py __pycache__/arithmetic.cpython-311.pyc
{'magic_number': b'\xa7\r\r\n',
 'magic_int': 3495,
 'python_version': '3.11',
 'bit_field': 0,
 'pyc_type': <PycInvalidationMode.TIMESTAMP: 1>,
 'timestamp': datetime.datetime(2024, 4, 25, 14, 40, 26, tzinfo=….utc),
 'file_size': 32}
Bytecode incompatible with this interpreter
>>> add(3, 4)
Traceback (most recent call last):
  ...
NameError: name 'add' is not defined

Dieses Mal zeigt die Ausgabe eine Meldung an, die auf eine Nichtübereinstimmung zwischen der Bytecode-Version in der Datei .pyc und dem verwendeten Interpreter hinweist. Infolgedessen wurde der Bytecode nicht ausgeführt und die Funktion add() wurde nicht definiert, sodass Sie sie nicht aufrufen können.

Wenn Sie nun eine Hash-basierte .pyc-Datei (aktiviert oder deaktiviert) durchleuchten, erhalten Sie möglicherweise Folgendes:

$ python xray.py __pycache__/arithmetic.cpython-312.pyc
{'magic_number': b'\xcb\r\r\n',
 'magic_int': 3531,
 'python_version': '3.12',
 'bit_field': 3,
 'pyc_type': <PycInvalidationMode.CHECKED_HASH: 2>,
 'hash_value': b'\xf3\xdd\x87j\x8d>\x0e)'}

Python kann den in der Datei .pyc eingebetteten Hash-Wert mit einem vergleichen, den es aus der zugehörigen Datei .py berechnet, indem es source_hash() aufruft der Quellcode:

>>> from importlib.util import source_hash
>>> from pathlib import Path
>>> source_hash(Path("arithmetic.py").read_bytes())
b'\xf3\xdd\x87j\x8d>\x0e)'

Dies ist eine zuverlässigere Methode zur Cache-Ungültigmachung und Überprüfung der Codeintegrität als der Vergleich eines flüchtigen, zuletzt geänderten Attributs der Quelldatei. Beachten Sie, dass der berechnete Hash-Wert mit dem aus der Datei .pyc gelesenen übereinstimmt.

Nachdem Sie nun wissen, wie Sie Python-Module aus einer kompilierten Binärform importieren, könnte es verlockend sein, Ihre kommerziellen Python-Programme zu verbreiten, ohne den Quellcode weiterzugeben.

Kann Bytecode Python-Programme verschleiern?

Zuvor haben Sie erfahren, dass Python ein Modul nicht aus einer .pyc-Datei importiert, wenn die zugehörige .py-Datei nicht gefunden werden kann. Es gibt jedoch eine bemerkenswerte Ausnahme, da genau das passiert, wenn Sie Python-Code aus einer ZIP-Datei importieren, die im PYTHONPATH angegeben ist. Solche Archive enthalten normalerweise nur die kompilierten .pyc-Dateien ohne den zugehörigen Quellcode.

Durch die Möglichkeit, kompilierte Module entweder manuell oder über diese ZIP-Dateien zu importieren, können Sie einen rudimentären Code-Verschleierungsmechanismus implementieren. Leider wäre es nicht besonders kugelsicher, da technisch versiertere Benutzer versuchen könnten, Ihre .pyc-Dateien mit speziellen Tools wie uncompyle6 oder wieder in Python-Code auf hoher Ebene zu dekompilieren pycdc.

Aber auch ohne diese externen Tools können Sie den Bytecode von Python in für Menschen lesbare Opcodes zerlegen und so die Analyse und das Reverse Engineering Ihrer Programme relativ einfach gestalten. Der richtige Weg, Python-Quellcode zu verbergen, besteht darin, ihn in Maschinencode zu kompilieren. Sie können sich beispielsweise mit Tools wie Cython helfen oder die Kernteile Ihres Codes in einer niedrigeren Programmiersprache wie C, C++ oder Rust neu schreiben.

Wie zerlege ich den zwischengespeicherten Bytecode?

Sobald Sie ein Codeobjekt in Python im Griff haben, können Sie das Modul dis aus der Standardbibliothek verwenden, um den kompilierten Bytecode zu zerlegen. Beispielsweise generieren Sie schnell selbst ein Codeobjekt, ohne sich auf die von Python zwischengespeicherten .pyc-Dateien verlassen zu müssen:

>>> from pathlib import Path
>>> source_code = Path("arithmetic.py").read_text(encoding="utf-8")
>>> module = compile(source_code, "arithmetic.py", mode="exec")
>>> module
<code object <module> at 0x7d09a9c92f50, file "arithmetic.py", line 1>

Sie rufen die integrierte Funktion compile() mit dem Modus "exec" als Parameter auf, um ein Python-Modul zu kompilieren. Jetzt können Sie mit dis die für Menschen lesbaren Opcode-Namen des resultierenden Codeobjekts anzeigen:

>>> from dis import dis
>>> dis(module)
  0           0 RESUME                   0

  1           2 LOAD_CONST               0 (<code object add at 0x7d...>)
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (add)
              8 RETURN_CONST             1 (None)

Disassembly of <code object add at 0x7d...>:
  1           0 RESUME                   0

  2           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                0 (+)
             10 RETURN_VALUE

Die Opcodes MAKE_FUNCTION und STORE_NAME sagen Ihnen, dass es in diesem Bytecode eine Funktion namens add() gibt. Wenn Sie sich das zerlegte Codeobjekt dieser Funktion genau ansehen, werden Sie feststellen, dass es zwei Argumente namens a und b benötigt und diese mithilfe des binären Plusoperators (+) und gibt den berechneten Wert zurück.

Alternativ können Sie durch den Opcode-Baum gehen und die einzelnen Anweisungen durchlaufen, um zu versuchen, den Python-Quellcode auf hoher Ebene zu rekonstruieren:

>>> from dis import Bytecode
>>> from types import CodeType

>>> def traverse(code):
...     print(code.co_name, code.co_varnames)
...     for instruction in Bytecode(code):
...         if isinstance(instruction.argval, CodeType):
...             traverse(instruction.argval)
...
>>> traverse(module)
<module> ()
add ('a', 'b')

Dieser Codeausschnitt ist noch lange nicht vollständig. Außerdem ist die Dekompilierung im Allgemeinen ein komplizierter Prozess. Dies führt oft zu unvollständigen Ergebnissen, da einige Informationen irreversibel verloren gehen, wenn bestimmte Optimierungen während der Bytecode-Kompilierung angewendet werden. Auf jeden Fall sollte es Ihnen eine ungefähre Vorstellung davon geben, wie Dekompilierer funktionieren.

Abschluss

In diesem Tutorial haben Sie sich mit der Funktionsweise des Bytecode-Caching-Mechanismus von Python befasst. Sie verstehen jetzt, dass es beim Caching ausschließlich um die Module geht, die Sie importieren. Durch das Speichern von kompiliertem Bytecode in __pycache__-Ordnern vermeidet Python den Aufwand, Module bei jedem Programmlauf neu zu kompilieren, was zu schnelleren Startzeiten führt.

Jetzt verstehen Sie, was die Erstellung von Cache-Ordnern auslöst, wie Sie diese unterdrücken und wie Sie sie in einen zentralen Ordner in Ihrem Dateisystem verschieben. Nebenbei haben Sie ein Hilfsprogramm erstellt, mit dem Sie die einzelnen .pyc-Dateien aus einem __pycache__-Ordner lesen und ausführen können.