Skip to content

gh-114099 - Add iOS framework loading machinery. #116454

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Lib/test/data/*
/_bootstrap_python
/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 @@ -1241,6 +1241,69 @@ find and load modules.
and how the module's :attr:`__file__` is populated.


.. 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
2 changes: 1 addition & 1 deletion Doc/tools/extensions/pyspecific.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ class Availability(SphinxDirective):
known_platforms = frozenset({
"AIX", "Android", "BSD", "DragonFlyBSD", "Emscripten", "FreeBSD",
"GNU/kFreeBSD", "Linux", "NetBSD", "OpenBSD", "POSIX", "Solaris",
"Unix", "VxWorks", "WASI", "Windows", "macOS",
"Unix", "VxWorks", "WASI", "Windows", "macOS", "iOS",
# libc
"BSD libc", "glibc", "musl",
# POSIX platforms with pthreads
Expand Down
11 changes: 11 additions & 0 deletions Lib/ctypes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,17 @@ def __init__(self, name, mode=DEFAULT_MODE, handle=None,
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 @@ -52,7 +52,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 @@ -1711,6 +1711,46 @@ def __repr__(self):
return f'FileFinder({self.path!r})'


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 @@ -1743,10 +1783,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 _set_bootstrap_module(_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 @@ -180,7 +180,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 @@ -12,6 +12,7 @@
from ._bootstrap_external import SourceFileLoader
from ._bootstrap_external import SourcelessFileLoader
from ._bootstrap_external import ExtensionFileLoader
from ._bootstrap_external import AppleFrameworkLoader
from ._bootstrap_external import NamespaceLoader


Expand Down
7 changes: 6 additions & 1 deletion Lib/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,10 @@ def getsourcefile(object):
elif any(filename.endswith(s) for s in
importlib.machinery.EXTENSION_SUFFIXES):
return None
elif filename.endswith(".fwork"):
# Apple mobile framework markers are another type of non-source file
return None

# return a filename found in the linecache even if it doesn't exist on disk
if filename in linecache.cache:
return filename
Expand Down Expand Up @@ -984,6 +988,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 @@ -1119,7 +1124,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 @@ -72,7 +72,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/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2001,14 +2001,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 @@ -2316,7 +2323,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
Loading
Loading