7. Python unter iOS verwenden

Autoren:

Russell Keith-Magee (2024-03)

Python unter iOS unterscheidet sich von Python auf Desktop-Plattformen. Auf einer Desktop-Plattform wird Python in der Regel als Systemressource installiert, die von jedem Benutzer dieses Computers verwendet werden kann. Nutzer interagieren dann mit Python, indem sie eine ausführbare Datei python starten und Befehle an einer interaktiven Eingabeaufforderung eingeben, oder indem sie ein Python-Skript ausführen.

Unter iOS gibt es kein Konzept der Installation als Systemressource. Die einzige Verteilungseinheit für Software ist eine „App“. Es gibt auch keine Konsole, in der du eine ausführbare Datei python starten oder mit einem Python-REPL interagieren könntest.

Infolgedessen ist die einzige Möglichkeit, Python unter iOS zu verwenden, der eingebettete Modus – das heißt, indem du eine native iOS-Anwendung schreibst, einen Python-Interpreter mithilfe von libPython einbettest und Python-Code über die Python-Einbettungs-API aufrufst. Der vollständige Python-Interpreter, die Standardbibliothek und dein gesamter Python-Code werden dann als eigenständiges Paket gebündelt, das über den iOS App Store vertrieben werden kann.

Wenn du zum ersten Mal damit experimentieren möchtest, eine iOS-App in Python zu schreiben, bieten dir Projekte wie BeeWare und Kivy eine deutlich zugänglichere Nutzererfahrung. Diese Projekte übernehmen die Komplexität, die mit dem Zum-Laufen-Bringen eines iOS-Projekts verbunden ist, sodass du dich nur um den eigentlichen Python-Code kümmern musst.

7.1. Python zur Laufzeit unter iOS

7.1.1. iOS-Versionskompatibilität

Die minimal unterstützte iOS-Version wird zur Kompilierzeit über die Option --host von configure festgelegt. Standardmäßig wird Python bei der Kompilierung für iOS mit einer minimal unterstützten iOS-Version von 13.0 kompiliert. Um eine andere minimale iOS-Version zu verwenden, gib die Versionsnummer als Teil des Arguments --host an – zum Beispiel würde --host=arm64-apple-ios15.4-simulator einen ARM64-Simulator-Build mit einem Deployment-Ziel von 15.4 kompilieren.

7.1.2. Plattform-Identifikation

Bei der Ausführung unter iOS gibt sys.platform den Wert ios zurück. Dieser Wert wird sowohl auf einem iPhone als auch auf einem iPad zurückgegeben, unabhängig davon, ob die App im Simulator oder auf einem physischen Gerät läuft.

Informationen zur konkreten Laufzeitumgebung, einschließlich der iOS-Version, des Gerätemodells und ob es sich um einen Simulator handelt, können mit platform.ios_ver() abgerufen werden. platform.system() gibt je nach Gerät iOS oder iPadOS zurück.

os.uname() gibt Details auf Kernel-Ebene zurück; es wird der Name Darwin gemeldet.

7.1.3. Verfügbarkeit der Standardbibliothek

Die Python-Standardbibliothek weist unter iOS einige wesentliche Auslassungen und Einschränkungen auf. Siehe den API-Verfügbarkeitsleitfaden für iOS für Details.

7.1.4. Binäre Erweiterungsmodule

Ein wesentlicher Unterschied bei iOS als Plattform ist, dass die Verteilung über den App Store strenge Anforderungen an die Paketierung einer Anwendung stellt. Eine dieser Anforderungen regelt, wie binäre Erweiterungsmodule verteilt werden.

Der iOS App Store verlangt, dass alle Binärmodule in einer iOS-App dynamische Bibliotheken sein müssen, die in einem Framework mit entsprechenden Metadaten enthalten sind und im Ordner Frameworks der gepackten App gespeichert werden. Pro Framework darf es nur eine einzige Binärdatei geben, und außerhalb des Ordners Frameworks darf sich kein ausführbares Binärmaterial befinden.

Dies steht im Widerspruch zum üblichen Python-Ansatz zur Verteilung von Binärdateien, der es erlaubt, ein binäres Erweiterungsmodul von einem beliebigen Ort in sys.path zu laden. Um die Einhaltung der App-Store-Richtlinien sicherzustellen, muss ein iOS-Projekt jedes Python-Paket nachbearbeiten und dabei .so-Binärmodule in einzelne, eigenständige Frameworks mit entsprechenden Metadaten und Signatur umwandeln. Details zur Durchführung dieser Nachbearbeitung findest du im Leitfaden zum Hinzufügen von Python zu deinem Projekt.

Damit Python die Binärdateien an ihrem neuen Speicherort finden kann, wird die ursprüngliche .so-Datei in sys.path durch eine .fwork-Datei ersetzt. Diese Datei ist eine Textdatei, die den Speicherort der Framework-Binärdatei relativ zum App-Bundle enthält. Damit das Framework wieder zum ursprünglichen Speicherort zurückfinden kann, muss es eine .origin-Datei enthalten, die den Speicherort der .fwork-Datei relativ zum App-Bundle enthält.

Betrachte zum Beispiel den Fall eines Imports from foo.bar import _whiz, wobei _whiz mit dem Binärmodul sources/foo/bar/_whiz.abi3.so implementiert ist, wobei sources der auf sys.path registrierte Speicherort relativ zum Anwendungsbündel ist. Dieses Modul muss als Frameworks/foo.bar._whiz.framework/foo.bar._whiz verteilt werden (wobei der Framework-Name aus dem vollständigen Importpfad des Moduls gebildet wird), mit einer Info.plist-Datei im .framework-Verzeichnis, die die Binärdatei als Framework identifiziert. Das Modul foo.bar._whiz würde am ursprünglichen Speicherort durch eine Markierungsdatei sources/foo/bar/_whiz.abi3.fwork repräsentiert, die den Pfad Frameworks/foo.bar._whiz/foo.bar._whiz enthält. Das Framework würde außerdem Frameworks/foo.bar._whiz.framework/foo.bar._whiz.origin enthalten, welches den Pfad zur .fwork-Datei enthält.

Beim Ausführen unter iOS installiert der Python-Interpreter einen AppleFrameworkLoader, der in der Lage ist, .fwork-Dateien zu lesen und zu importieren. Nach dem Import gibt das Attribut __file__ des Binärmoduls den Speicherort der .fwork-Datei an. Die ModuleSpec des geladenen Moduls gibt jedoch als origin den Speicherort der Binärdatei im Framework-Ordner an.

7.1.5. Compiler-Stub-Binärdateien

Xcode stellt für iOS keine expliziten Compiler bereit. Stattdessen verwendet es ein xcrun-Skript, das zu einem vollständigen Compiler-Pfad aufgelöst wird (z. B. xcrun --sdk iphoneos clang, um den clang für ein iPhone-Gerät zu erhalten). Die Verwendung dieses Skripts bringt jedoch zwei Probleme mit sich:

  • Die Ausgabe von xcrun enthält maschinenspezifische Pfade, was zu einem sysconfig-Modul führt, das nicht zwischen Benutzern geteilt werden kann; und

  • Dies führt zu CC/CPP/LD/AR-Definitionen, die Leerzeichen enthalten. Es gibt viele Werkzeuge im C-Ökosystem, die davon ausgehen, dass man eine Befehlszeile am ersten Leerzeichen aufteilen kann, um den Pfad zur ausführbaren Compiler-Datei zu erhalten; das ist bei der Verwendung von xcrun nicht der Fall.

Um diese Probleme zu vermeiden, stellt Python Stubs für diese Werkzeuge bereit. Diese Stubs sind Shell-Skript-Wrapper um die zugrunde liegenden xcrun-Werkzeuge, die in einem bin-Ordner zusammen mit dem kompilierten iOS-Framework verteilt werden. Diese Skripte sind verschiebbar und lösen sich stets zu den passenden lokalen Systempfaden auf. Indem diese Skripte in den bin-Ordner aufgenommen werden, der ein Framework begleitet, wird der Inhalt des sysconfig-Moduls für Endnutzer nützlich, um ihre eigenen Module zu kompilieren. Beim Kompilieren von Python-Modulen von Drittanbietern für iOS solltest du sicherstellen, dass sich diese Stub-Binärdateien in deinem Pfad befinden.

7.2. Python unter iOS installieren

7.2.1. Werkzeuge zum Erstellen von iOS-Apps

Der Build-Prozess für iOS erfordert die Verwendung von Apples Xcode-Werkzeugen. Es wird dringend empfohlen, die aktuellste stabile Xcode-Version zu verwenden. Dies erfordert in der Regel die Verwendung der neuesten (oder zweit-neuesten) veröffentlichten macOS-Version, da Apple Xcode nicht für ältere macOS-Versionen pflegt. Die Xcode Command Line Tools reichen für die iOS-Entwicklung nicht aus. Du benötigst eine vollständige Xcode-Installation.

Wenn du deinen Code im iOS-Simulator ausführen möchtest, musst du außerdem eine iOS Simulator Platform installieren. Beim ersten Start von Xcode solltest du aufgefordert werden, eine iOS Simulator Platform auszuwählen. Alternativ kannst du eine iOS Simulator Platform über den Reiter „Platforms“ im Xcode-Einstellungsfenster hinzufügen.

7.2.2. Python zu einem iOS-Projekt hinzufügen

Python kann jedem iOS-Projekt hinzugefügt werden, sowohl mit Swift als auch mit Objective C. Die folgenden Beispiele verwenden Objective C. Wenn du Swift verwendest, könnte dir eine Bibliothek wie PythonKit hilfreich sein.

Um Python zu einem iOS-Xcode-Projekt hinzuzufügen:

  1. Build or obtain a Python XCFramework. See the instructions in iOS/README.rst (in the CPython source distribution) for details on how to build a Python XCFramework. At a minimum, you will need a build that supports arm64-apple-ios, plus one of either arm64-apple-ios-simulator or x86_64-apple-ios-simulator.

  2. Zieh das XCframework in dein iOS-Projekt. In der folgenden Anleitung nehmen wir an, dass du das XCframework in die Wurzel deines Projekts gelegt hast. Du kannst aber jeden anderen Ort verwenden und die Pfade entsprechend anpassen.

  3. Drag the iOS/Resources/dylib-Info-template.plist file into your project, and ensure it is associated with the app target.

  4. Füge deinen Anwendungscode als Ordner in dein Xcode-Projekt ein. In der folgenden Anleitung nehmen wir an, dass dein eigener Code in einem Ordner namens app in der Wurzel deines Projekts liegt. Du kannst jeden anderen Ort verwenden und die Pfade entsprechend anpassen. Stelle sicher, dass dieser Ordner dem Ziel deiner App zugeordnet ist.

  5. Wähle das App-Ziel aus, indem du den Wurzelknoten deines Xcode-Projekts anklickst und dann den Zielnamen in der erscheinenden Seitenleiste.

  6. Füge in den Einstellungen unter „General“ bei „Frameworks, Libraries and Embedded Content“ das Python.xcframework hinzu, mit „Embed & Sign“ ausgewählt.

  7. Ändere im Reiter „Build Settings“ Folgendes:

    • Build Options

      • User Script Sandboxing: No

      • Enable Testability: Yes

    • Pfade suchen

      • Framework Search Paths: $(PROJECT_DIR)

      • Header Search Paths: "$(BUILT_PRODUCTS_DIR)/Python.framework/Headers"

    • Apple Clang - Warnings - All languages

      • Quoted Include In Framework Header: No

  8. Add a build step that copies the Python standard library into your app. In the „Build Phases“ tab, add a new „Run Script“ build step before the „Embed Frameworks“ step, but after the „Copy Bundle Resources“ step. Name the step „Install Target Specific Python Standard Library“, disable the „Based on dependency analysis“ checkbox, and set the script content to:

    set -e
    
    mkdir -p "$CODESIGNING_FOLDER_PATH/python/lib"
    if [ "$EFFECTIVE_PLATFORM_NAME" = "-iphonesimulator" ]; then
        echo "Installing Python modules for iOS Simulator"
        rsync -au --delete "$PROJECT_DIR/Python.xcframework/ios-arm64_x86_64-simulator/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
    else
        echo "Installing Python modules for iOS Device"
        rsync -au --delete "$PROJECT_DIR/Python.xcframework/ios-arm64/lib/" "$CODESIGNING_FOLDER_PATH/python/lib/"
    fi
    

    Note that the name of the simulator „slice“ in the XCframework may be different, depending the CPU architectures your XCFramework supports.

  9. Add a second build step that processes the binary extension modules in the standard library into „Framework“ format. Add a „Run Script“ build step directly after the one you added in step 8, named „Prepare Python Binary Modules“. It should also have „Based on dependency analysis“ unchecked, with the following script content:

    set -e
    
    install_dylib () {
        INSTALL_BASE=$1
        FULL_EXT=$2
    
        # The name of the extension file
        EXT=$(basename "$FULL_EXT")
        # The location of the extension file, relative to the bundle
        RELATIVE_EXT=${FULL_EXT#$CODESIGNING_FOLDER_PATH/}
        # The path to the extension file, relative to the install base
        PYTHON_EXT=${RELATIVE_EXT/$INSTALL_BASE/}
        # The full dotted name of the extension module, constructed from the file path.
        FULL_MODULE_NAME=$(echo $PYTHON_EXT | cut -d "." -f 1 | tr "/" ".");
        # A bundle identifier; not actually used, but required by Xcode framework packaging
        FRAMEWORK_BUNDLE_ID=$(echo $PRODUCT_BUNDLE_IDENTIFIER.$FULL_MODULE_NAME | tr "_" "-")
        # The name of the framework folder.
        FRAMEWORK_FOLDER="Frameworks/$FULL_MODULE_NAME.framework"
    
        # If the framework folder doesn't exist, create it.
        if [ ! -d "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER" ]; then
            echo "Creating framework for $RELATIVE_EXT"
            mkdir -p "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER"
            cp "$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
            plutil -replace CFBundleExecutable -string "$FULL_MODULE_NAME" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
            plutil -replace CFBundleIdentifier -string "$FRAMEWORK_BUNDLE_ID" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist"
        fi
    
        echo "Installing binary for $FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
        mv "$FULL_EXT" "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME"
        # Create a placeholder .fwork file where the .so was
        echo "$FRAMEWORK_FOLDER/$FULL_MODULE_NAME" > ${FULL_EXT%.so}.fwork
        # Create a back reference to the .so file location in the framework
        echo "${RELATIVE_EXT%.so}.fwork" > "$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME.origin"
     }
    
     PYTHON_VER=$(ls -1 "$CODESIGNING_FOLDER_PATH/python/lib")
     echo "Install Python $PYTHON_VER standard library extension modules..."
     find "$CODESIGNING_FOLDER_PATH/python/lib/$PYTHON_VER/lib-dynload" -name "*.so" | while read FULL_EXT; do
        install_dylib python/lib/$PYTHON_VER/lib-dynload/ "$FULL_EXT"
     done
    
     # Clean up dylib template
     rm -f "$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist"
    
     echo "Signing frameworks as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)..."
     find "$CODESIGNING_FOLDER_PATH/Frameworks" -name "*.framework" -exec /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" ${OTHER_CODE_SIGN_FLAGS:-} -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der "{}" \;
    
  10. Füge Objective-C-Code hinzu, um einen Python-Interpreter im eingebetteten Modus zu starten und zu verwenden. Du solltest sicherstellen, dass:

  • der UTF-8-Modus (PyPreConfig.utf8_mode) eingeschaltet ist;

  • die gepufferte Standard-Ein-/Ausgabe (PyConfig.buffered_stdio) abgeschaltet ist;

  • das Schreiben von Bytecode (PyConfig.write_bytecode) abgeschaltet ist;

  • die Signalbehandlungen (PyConfig.install_signal_handlers) eingeschaltet sind;

  • PYTHONHOME für den Interpreter auf den Unterordner python des Bündels deiner App zeigt, und

  • der PYTHONPATH für den Interpreter Folgendes enthält:

    • den Unterordner python/lib/python3.X des Bündels deiner App,

    • den Unterordner python/lib/python3.X/lib-dynload des Bündels deiner App, und

    • den Unterordner app des Bündels deiner App

Den Ort des Bündels deiner App kannst du über [[NSBundle mainBundle] resourcePath] ermitteln.

Steps 8, 9 and 10 of these instructions assume that you have a single folder of pure Python application code, named app. If you have third-party binary modules in your app, some additional steps will be required:

  • You need to ensure that any folders containing third-party binaries are either associated with the app target, or copied in as part of step 8. Step 8 should also purge any binaries that are not appropriate for the platform a specific build is targeting (i.e., delete any device binaries if you’re building an app targeting the simulator).

  • Any folders that contain third-party binaries must be processed into framework form by step 9. The invocation of install_dylib that processes the lib-dynload folder can be copied and adapted for this purpose.

  • If you’re using a separate folder for third-party packages, ensure that folder is included as part of the PYTHONPATH configuration in step 10.

  • Wenn einer der Ordner mit Paketen von Dritten .pth-Dateien enthält, solltest du diesen Ordner als Site-Verzeichnis hinzufügen (mit site.addsitedir()), statt ihn direkt zu PYTHONPATH oder sys.path hinzuzufügen.

7.2.3. Ein Python-Paket testen

The CPython source tree contains a testbed project that is used to run the CPython test suite on the iOS simulator. This testbed can also be used as a testbed project for running your Python library’s test suite on iOS.

After building or obtaining an iOS XCFramework (See iOS/README.rst for details), create a clone of the Python iOS testbed project by running:

$ python iOS/testbed clone --framework <path/to/Python.xcframework> --app <path/to/module1> --app <path/to/module2> app-testbed

You will need to modify the iOS/testbed reference to point to that directory in the CPython source tree; any folders specified with the --app flag will be copied into the cloned testbed project. The resulting testbed will be created in the app-testbed folder. In this example, the module1 and module2 would be importable modules at runtime. If your project has additional dependencies, they can be installed into the app-testbed/iOSTestbed/app_packages folder (using pip install --target app-testbed/iOSTestbed/app_packages or similar).

Anschließend kannst du den Ordner app-testbed verwenden, um die Testsammlung deiner App auszuführen. Wenn zum Beispiel module1.tests der Einsprungpunkt deiner Testsammlung war, könntest du Folgendes ausführen:

$ python app-testbed run -- module1.tests

Das entspricht dem Aufruf von python -m module1.tests auf einem Desktop-Python-Build. Alle Argumente nach dem -- werden an das Testbed weitergegeben, als wären sie Argumente von python -m auf einem Desktop-Rechner.

Du kannst das Testbed-Projekt auch in Xcode öffnen:

$ open app-testbed/iOSTestbed.xcodeproj

Damit stehen dir die vollständigen Xcode-Werkzeuge zur Fehlersuche zur Verfügung.

Die Argumente, mit denen die Testsammlung ausgeführt wird, sind Teil des Testplans. Um den Testplan zu ändern, wähle den Testplan-Knoten im Projektbaum (er sollte das erste Kind des Wurzelknotens sein) und dann den Reiter „Configurations“. Ändere den Wert „Arguments Passed On Launch“, um die Testargumente anzupassen.

The test plan also disables parallel testing, and specifies the use of the iOSTestbed.lldbinit file for providing configuration of the debugger. The default debugger configuration disables automatic breakpoints on the SIGINT, SIGUSR1, SIGUSR2, and SIGXFSZ signals.

7.3. Vorgaben des App Store erfüllen

The only mechanism for distributing apps to third-party iOS devices is to submit the app to the iOS App Store; apps submitted for distribution must pass Apple’s app review process. This process includes a set of automated validation rules that inspect the submitted application bundle for problematic code.

Die Python-Standardbibliothek enthält Code, von dem bekannt ist, dass er gegen diese automatischen Regeln verstößt. Diese Verstöße scheinen zwar Fehlalarme zu sein, Apples Prüfregeln lassen sich aber nicht anfechten. Deshalb ist es nötig, die Python-Standardbibliothek zu verändern, damit eine App die Prüfung des App Store besteht.

Der Python-Quellbaum enthält eine Patch-Datei, die allen Code entfernt, von dem bekannt ist, dass er im Prüfverfahren des App Store Schwierigkeiten macht. Dieser Patch wird beim Bauen für iOS automatisch angewandt.