Skip to content

Commit

Permalink
[3.9] pythongh-114099 - Add iOS framework loading machinery. (pythonG…
Browse files Browse the repository at this point in the history
…H-116454)

Co-authored-by: Malcolm Smith <[email protected]>
Co-authored-by: Eric Snow <[email protected]>
  • Loading branch information
3 people committed Dec 13, 2024
1 parent 4a4b436 commit 004facc
Show file tree
Hide file tree
Showing 20 changed files with 3,008 additions and 2,750 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Lib/test/data/*
!Lib/test/data/README
/Makefile
/Makefile.pre
iOSTestbed.*
/iOSTestbed.*
iOS/Frameworks/
iOS/Resources/Info.plist
iOS/testbed/build
Expand Down
63 changes: 63 additions & 0 deletions Doc/library/importlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,69 @@ find and load modules.
Boolean indicating whether or not the module's "origin"
attribute refers to a loadable location.

.. class:: AppleFrameworkLoader(name, path)

A specialization of :class:`importlib.machinery.ExtensionFileLoader` that
is able to load extension modules in Framework format.

For compatibility with the iOS App Store, *all* binary modules in an iOS app
must be dynamic libraries, contained in a framework with appropriate
metadata, stored in the ``Frameworks`` folder of the packaged app. There can
be only a single binary per framework, and there can be no executable binary
material outside the Frameworks folder.

To accomodate this requirement, when running on iOS, extension module
binaries are *not* packaged as ``.so`` files on ``sys.path``, but as
individual standalone frameworks. To discover those frameworks, this loader
is be registered against the ``.fwork`` file extension, with a ``.fwork``
file acting as a placeholder in the original location of the binary on
``sys.path``. The ``.fwork`` file contains the path of the actual binary in
the ``Frameworks`` folder, relative to the app bundle. To allow for
resolving a framework-packaged binary back to the original location, the
framework is expected to contain a ``.origin`` file that contains the
location of the ``.fwork`` file, relative to the app bundle.

For example, consider the case of an import ``from foo.bar import _whiz``,
where ``_whiz`` is implemented with the binary module
``sources/foo/bar/_whiz.abi3.so``, with ``sources`` being the location
registered on ``sys.path``, relative to the application bundle. This module
*must* be distributed as
``Frameworks/foo.bar._whiz.framework/foo.bar._whiz`` (creating the framework
name from the full import path of the module), with an ``Info.plist`` file
in the ``.framework`` directory identifying the binary as a framework. The
``foo.bar._whiz`` module would be represented in the original location with
a ``sources/foo/bar/_whiz.abi3.fwork`` marker file, containing the path
``Frameworks/foo.bar._whiz/foo.bar._whiz``. The framework would also contain
``Frameworks/foo.bar._whiz.framework/foo.bar._whiz.origin``, containing the
path to the ``.fwork`` file.

When a module is loaded with this loader, the ``__file__`` for the module
will report as the location of the ``.fwork`` file. This allows code to use
the ``__file__`` of a module as an anchor for file system traveral.
However, the spec origin will reference the location of the *actual* binary
in the ``.framework`` folder.

The Xcode project building the app is responsible for converting any ``.so``
files from wherever they exist in the ``PYTHONPATH`` into frameworks in the
``Frameworks`` folder (including stripping extensions from the module file,
the addition of framework metadata, and signing the resulting framework),
and creating the ``.fwork`` and ``.origin`` files. This will usually be done
with a build step in the Xcode project; see the iOS documentation for
details on how to construct this build step.

.. versionadded:: 3.13

.. availability:: iOS.

.. attribute:: name

Name of the module the loader supports.

.. attribute:: path

Path to the ``.fwork`` file for the extension module.


:mod:`importlib.util` -- Utility code for importers
---------------------------------------------------

Expand Down
13 changes: 13 additions & 0 deletions Lib/ctypes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,19 @@ def __init__(self, name, mode=DEFAULT_MODE, handle=None,
use_errno=False,
use_last_error=False,
winmode=None):
if name:
name = _os.fspath(name)

# If the filename that has been provided is an iOS/tvOS/watchOS
# .fwork file, dereference the location to the true origin of the
# binary.
if name.endswith(".fwork"):
with open(name) as f:
name = _os.path.join(
_os.path.dirname(_sys.executable),
f.read().strip()
)

self._name = name
flags = self._func_flags_
if use_errno:
Expand Down
2 changes: 1 addition & 1 deletion Lib/ctypes/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def find_library(name):
return fname
return None

elif os.name == "posix" and sys.platform == "darwin":
elif os.name == "posix" and sys.platform in {"darwin", "ios", "tvos", "watchos"}:
from ctypes.macholib.dyld import dyld_find as _dyld_find
def find_library(name):
possible = ['lib%s.dylib' % name,
Expand Down
53 changes: 50 additions & 3 deletions Lib/importlib/_bootstrap_external.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

# Bootstrap-related code ######################################################
_CASE_INSENSITIVE_PLATFORMS_STR_KEY = 'win',
_CASE_INSENSITIVE_PLATFORMS_BYTES_KEY = 'cygwin', 'darwin'
_CASE_INSENSITIVE_PLATFORMS_BYTES_KEY = 'cygwin', 'darwin', 'ios', 'tvos', 'watchos'
_CASE_INSENSITIVE_PLATFORMS = (_CASE_INSENSITIVE_PLATFORMS_BYTES_KEY
+ _CASE_INSENSITIVE_PLATFORMS_STR_KEY)

Expand Down Expand Up @@ -1615,6 +1615,46 @@ def __repr__(self):
return 'FileFinder({!r})'.format(self.path)


class AppleFrameworkLoader(ExtensionFileLoader):
"""A loader for modules that have been packaged as frameworks for
compatibility with Apple's iOS App Store policies.
"""
def create_module(self, spec):
# If the ModuleSpec has been created by the FileFinder, it will have
# been created with an origin pointing to the .fwork file. We need to
# redirect this to the location in the Frameworks folder, using the
# content of the .fwork file.
if spec.origin.endswith(".fwork"):
with _io.FileIO(spec.origin, 'r') as file:
framework_binary = file.read().decode().strip()
bundle_path = _path_split(sys.executable)[0]
spec.origin = _path_join(bundle_path, framework_binary)

# If the loader is created based on the spec for a loaded module, the
# path will be pointing at the Framework location. If this occurs,
# get the original .fwork location to use as the module's __file__.
if self.path.endswith(".fwork"):
path = self.path
else:
with _io.FileIO(self.path + ".origin", 'r') as file:
origin = file.read().decode().strip()
bundle_path = _path_split(sys.executable)[0]
path = _path_join(bundle_path, origin)

module = _bootstrap._call_with_frames_removed(_imp.create_dynamic, spec)

_bootstrap._verbose_message(
"Apple framework extension module {!r} loaded from {!r} (path {!r})",
spec.name,
spec.origin,
path,
)

# Ensure that the __file__ points at the .fwork location
module.__file__ = path

return module

# Import setup ###############################################################

def _fix_up_module(ns, name, pathname, cpathname=None):
Expand Down Expand Up @@ -1645,10 +1685,17 @@ def _get_supported_file_loaders():
Each item is a tuple (loader, suffixes).
"""
extensions = ExtensionFileLoader, _imp.extension_suffixes()
if sys.platform in {"ios", "tvos", "watchos"}:
extension_loaders = [(AppleFrameworkLoader, [
suffix.replace(".so", ".fwork")
for suffix in _imp.extension_suffixes()
])]
else:
extension_loaders = []
extension_loaders.append((ExtensionFileLoader, _imp.extension_suffixes()))
source = SourceFileLoader, SOURCE_SUFFIXES
bytecode = SourcelessFileLoader, BYTECODE_SUFFIXES
return [extensions, source, bytecode]
return extension_loaders + [source, bytecode]


def _setup(_bootstrap_module):
Expand Down
6 changes: 5 additions & 1 deletion Lib/importlib/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,11 @@ def get_code(self, fullname):
else:
return self.source_to_code(source, path)

_register(ExecutionLoader, machinery.ExtensionFileLoader)
_register(
ExecutionLoader,
machinery.ExtensionFileLoader,
machinery.AppleFrameworkLoader,
)


class FileLoader(_bootstrap_external.FileLoader, ResourceLoader, ExecutionLoader):
Expand Down
1 change: 1 addition & 0 deletions Lib/importlib/machinery.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ._bootstrap_external import SourceFileLoader
from ._bootstrap_external import SourcelessFileLoader
from ._bootstrap_external import ExtensionFileLoader
from ._bootstrap_external import AppleFrameworkLoader


def all_suffixes():
Expand Down
3 changes: 2 additions & 1 deletion Lib/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@ def getmodule(object, _filename=None):
return object
if hasattr(object, '__module__'):
return sys.modules.get(object.__module__)

# Try the filename to modulename cache
if _filename is not None and _filename in modulesbyfile:
return sys.modules.get(modulesbyfile[_filename])
Expand Down Expand Up @@ -823,7 +824,7 @@ def findsource(object):
# Allow filenames in form of "<something>" to pass through.
# `doctest` monkeypatches `linecache` module to enable
# inspection, so let `linecache.getlines` to be called.
if not (file.startswith('<') and file.endswith('>')):
if (not (file.startswith('<') and file.endswith('>'))) or file.endswith('.fwork'):
raise OSError('source code not available')

module = getmodule(object, file)
Expand Down
7 changes: 6 additions & 1 deletion Lib/modulefinder.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ def _find_module(name, path=None):
if isinstance(spec.loader, importlib.machinery.SourceFileLoader):
kind = _PY_SOURCE

elif isinstance(spec.loader, importlib.machinery.ExtensionFileLoader):
elif isinstance(
spec.loader, (
importlib.machinery.ExtensionFileLoader,
importlib.machinery.AppleFrameworkLoader,
)
):
kind = _C_EXTENSION

elif isinstance(spec.loader, importlib.machinery.SourcelessFileLoader):
Expand Down
16 changes: 14 additions & 2 deletions Lib/test/test_capi.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,14 +697,21 @@ def test_module_state_shared_in_global(self):
self.addCleanup(os.close, r)
self.addCleanup(os.close, w)

# Apple extensions must be distributed as frameworks. This requires
# a specialist loader.
if support.is_apple_mobile:
loader = "AppleFrameworkLoader"
else:
loader = "ExtensionFileLoader"

script = textwrap.dedent(f"""
import importlib.machinery
import importlib.util
import os
fullname = '_test_module_state_shared'
origin = importlib.util.find_spec('_testmultiphase').origin
loader = importlib.machinery.ExtensionFileLoader(fullname, origin)
loader = importlib.machinery.{loader}(fullname, origin)
spec = importlib.util.spec_from_loader(fullname, loader)
module = importlib.util.module_from_spec(spec)
attr_id = str(id(module.Error)).encode()
Expand Down Expand Up @@ -883,7 +890,12 @@ class Test_ModuleStateAccess(unittest.TestCase):
def setUp(self):
fullname = '_testmultiphase_meth_state_access' # XXX
origin = importlib.util.find_spec('_testmultiphase').origin
loader = importlib.machinery.ExtensionFileLoader(fullname, origin)
# Apple extensions must be distributed as frameworks. This requires
# a specialist loader.
if support.is_apple_mobile:
loader = importlib.machinery.AppleFrameworkLoader(fullname, origin)
else:
loader = importlib.machinery.ExtensionFileLoader(fullname, origin)
spec = importlib.util.spec_from_loader(fullname, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
Expand Down
28 changes: 23 additions & 5 deletions Lib/test/test_importlib/extension/test_finder.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .. import abc
from .. import util
from test.support import is_apple_mobile
from test.test_importlib import abc, util

machinery = util.import_importlib('importlib.machinery')

Expand All @@ -12,9 +12,27 @@ class FinderTests(abc.FinderTests):
"""Test the finder for extension modules."""

def find_module(self, fullname):
importer = self.machinery.FileFinder(util.EXTENSIONS.path,
(self.machinery.ExtensionFileLoader,
self.machinery.EXTENSION_SUFFIXES))
if is_apple_mobile:
# Apple mobile platforms require a specialist loader that uses
# .fwork files as placeholders for the true `.so` files.
loaders = [
(
self.machinery.AppleFrameworkLoader,
[
ext.replace(".so", ".fwork")
for ext in self.machinery.EXTENSION_SUFFIXES
]
)
]
else:
loaders = [
(
self.machinery.ExtensionFileLoader,
self.machinery.EXTENSION_SUFFIXES
)
]

importer = self.machinery.FileFinder(util.EXTENSIONS.path, *loaders)
with warnings.catch_warnings():
warnings.simplefilter('ignore', DeprecationWarning)
return importer.find_module(fullname)
Expand Down
Loading

0 comments on commit 004facc

Please sign in to comment.