gh-114099: Additions to standard library to support iOS (GH-117052)

Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
Co-authored-by: Malcolm Smith <smith@chaquo.com>
Co-authored-by: Ned Deily <nad@python.org>
This commit is contained in:
Russell Keith-Magee 2024-03-28 15:59:33 +08:00 committed by GitHub
parent b44898299a
commit f006338017
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 474 additions and 48 deletions

View File

@ -784,6 +784,11 @@ process and user.
:func:`socket.gethostname` or even :func:`socket.gethostname` or even
``socket.gethostbyaddr(socket.gethostname())``. ``socket.gethostbyaddr(socket.gethostname())``.
On macOS, iOS and Android, this returns the *kernel* name and version (i.e.,
``'Darwin'`` on macOS and iOS; ``'Linux'`` on Android). :func:`platform.uname()`
can be used to get the user-facing operating system name and version on iOS and
Android.
.. availability:: Unix. .. availability:: Unix.
.. versionchanged:: 3.3 .. versionchanged:: 3.3

View File

@ -148,6 +148,9 @@ Cross Platform
Returns the system/OS name, such as ``'Linux'``, ``'Darwin'``, ``'Java'``, Returns the system/OS name, such as ``'Linux'``, ``'Darwin'``, ``'Java'``,
``'Windows'``. An empty string is returned if the value cannot be determined. ``'Windows'``. An empty string is returned if the value cannot be determined.
On iOS and Android, this returns the user-facing OS name (i.e, ``'iOS``,
``'iPadOS'`` or ``'Android'``). To obtain the kernel name (``'Darwin'`` or
``'Linux'``), use :func:`os.uname()`.
.. function:: system_alias(system, release, version) .. function:: system_alias(system, release, version)
@ -161,6 +164,8 @@ Cross Platform
Returns the system's release version, e.g. ``'#3 on degas'``. An empty string is Returns the system's release version, e.g. ``'#3 on degas'``. An empty string is
returned if the value cannot be determined. returned if the value cannot be determined.
On iOS and Android, this is the user-facing OS version. To obtain the
Darwin or Linux kernel version, use :func:`os.uname()`.
.. function:: uname() .. function:: uname()
@ -238,7 +243,6 @@ Windows Platform
macOS Platform macOS Platform
-------------- --------------
.. function:: mac_ver(release='', versioninfo=('','',''), machine='') .. function:: mac_ver(release='', versioninfo=('','',''), machine='')
Get macOS version information and return it as tuple ``(release, versioninfo, Get macOS version information and return it as tuple ``(release, versioninfo,
@ -248,6 +252,24 @@ macOS Platform
Entries which cannot be determined are set to ``''``. All tuple entries are Entries which cannot be determined are set to ``''``. All tuple entries are
strings. strings.
iOS Platform
------------
.. function:: ios_ver(system='', release='', model='', is_simulator=False)
Get iOS version information and return it as a
:func:`~collections.namedtuple` with the following attributes:
* ``system`` is the OS name; either ``'iOS'`` or ``'iPadOS'``.
* ``release`` is the iOS version number as a string (e.g., ``'17.2'``).
* ``model`` is the device model identifier; this will be a string like
``'iPhone13,2'`` for a physical device, or ``'iPhone'`` on a simulator.
* ``is_simulator`` is a boolean describing if the app is running on a
simulator or a physical device.
Entries which cannot be determined are set to the defaults given as
parameters.
Unix Platforms Unix Platforms
-------------- --------------

View File

@ -33,6 +33,13 @@ allow the remote browser to maintain its own windows on the display. If remote
browsers are not available on Unix, the controlling process will launch a new browsers are not available on Unix, the controlling process will launch a new
browser and wait. browser and wait.
On iOS, the :envvar:`BROWSER` environment variable, as well as any arguments
controlling autoraise, browser preference, and new tab/window creation will be
ignored. Web pages will *always* be opened in the user's preferred browser, in
a new tab, with the browser being brought to the foreground. The use of the
:mod:`webbrowser` module on iOS requires the :mod:`ctypes` module. If
:mod:`ctypes` isn't available, calls to :func:`.open` will fail.
The script :program:`webbrowser` can be used as a command-line interface for the The script :program:`webbrowser` can be used as a command-line interface for the
module. It accepts a URL as the argument. It accepts the following optional module. It accepts a URL as the argument. It accepts the following optional
parameters: ``-n`` opens the URL in a new browser window, if possible; parameters: ``-n`` opens the URL in a new browser window, if possible;
@ -147,6 +154,8 @@ for the controller classes, all defined in this module.
+------------------------+-----------------------------------------+-------+ +------------------------+-----------------------------------------+-------+
| ``'chromium-browser'`` | ``Chromium('chromium-browser')`` | | | ``'chromium-browser'`` | ``Chromium('chromium-browser')`` | |
+------------------------+-----------------------------------------+-------+ +------------------------+-----------------------------------------+-------+
| ``'iosbrowser'`` | ``IOSBrowser`` | \(4) |
+------------------------+-----------------------------------------+-------+
Notes: Notes:
@ -161,7 +170,10 @@ Notes:
Only on Windows platforms. Only on Windows platforms.
(3) (3)
Only on macOS platform. Only on macOS.
(4)
Only on iOS.
.. versionadded:: 3.2 .. versionadded:: 3.2
A new :class:`!MacOSXOSAScript` class has been added A new :class:`!MacOSXOSAScript` class has been added
@ -176,6 +188,9 @@ Notes:
Removed browsers include Grail, Mosaic, Netscape, Galeon, Removed browsers include Grail, Mosaic, Netscape, Galeon,
Skipstone, Iceape, and Firefox versions 35 and below. Skipstone, Iceape, and Firefox versions 35 and below.
.. versionchanged:: 3.13
Support for iOS has been added.
Here are some simple examples:: Here are some simple examples::
url = 'https://docs.python.org/' url = 'https://docs.python.org/'

71
Lib/_ios_support.py Normal file
View File

@ -0,0 +1,71 @@
import sys
try:
from ctypes import cdll, c_void_p, c_char_p, util
except ImportError:
# ctypes is an optional module. If it's not present, we're limited in what
# we can tell about the system, but we don't want to prevent the module
# from working.
print("ctypes isn't available; iOS system calls will not be available")
objc = None
else:
# ctypes is available. Load the ObjC library, and wrap the objc_getClass,
# sel_registerName methods
lib = util.find_library("objc")
if lib is None:
# Failed to load the objc library
raise RuntimeError("ObjC runtime library couldn't be loaded")
objc = cdll.LoadLibrary(lib)
objc.objc_getClass.restype = c_void_p
objc.objc_getClass.argtypes = [c_char_p]
objc.sel_registerName.restype = c_void_p
objc.sel_registerName.argtypes = [c_char_p]
def get_platform_ios():
# Determine if this is a simulator using the multiarch value
is_simulator = sys.implementation._multiarch.endswith("simulator")
# We can't use ctypes; abort
if not objc:
return None
# Most of the methods return ObjC objects
objc.objc_msgSend.restype = c_void_p
# All the methods used have no arguments.
objc.objc_msgSend.argtypes = [c_void_p, c_void_p]
# Equivalent of:
# device = [UIDevice currentDevice]
UIDevice = objc.objc_getClass(b"UIDevice")
SEL_currentDevice = objc.sel_registerName(b"currentDevice")
device = objc.objc_msgSend(UIDevice, SEL_currentDevice)
# Equivalent of:
# device_systemVersion = [device systemVersion]
SEL_systemVersion = objc.sel_registerName(b"systemVersion")
device_systemVersion = objc.objc_msgSend(device, SEL_systemVersion)
# Equivalent of:
# device_systemName = [device systemName]
SEL_systemName = objc.sel_registerName(b"systemName")
device_systemName = objc.objc_msgSend(device, SEL_systemName)
# Equivalent of:
# device_model = [device model]
SEL_model = objc.sel_registerName(b"model")
device_model = objc.objc_msgSend(device, SEL_model)
# UTF8String returns a const char*;
SEL_UTF8String = objc.sel_registerName(b"UTF8String")
objc.objc_msgSend.restype = c_char_p
# Equivalent of:
# system = [device_systemName UTF8String]
# release = [device_systemVersion UTF8String]
# model = [device_model UTF8String]
system = objc.objc_msgSend(device_systemName, SEL_UTF8String).decode()
release = objc.objc_msgSend(device_systemVersion, SEL_UTF8String).decode()
model = objc.objc_msgSend(device_model, SEL_UTF8String).decode()
return system, release, model, is_simulator

View File

@ -496,6 +496,30 @@ def mac_ver(release='', versioninfo=('', '', ''), machine=''):
# If that also doesn't work return the default values # If that also doesn't work return the default values
return release, versioninfo, machine return release, versioninfo, machine
# A namedtuple for iOS version information.
IOSVersionInfo = collections.namedtuple(
"IOSVersionInfo",
["system", "release", "model", "is_simulator"]
)
def ios_ver(system="", release="", model="", is_simulator=False):
"""Get iOS version information, and return it as a namedtuple:
(system, release, model, is_simulator).
If values can't be determined, they are set to values provided as
parameters.
"""
if sys.platform == "ios":
import _ios_support
result = _ios_support.get_platform_ios()
if result is not None:
return IOSVersionInfo(*result)
return IOSVersionInfo(system, release, model, is_simulator)
def _java_getprop(name, default): def _java_getprop(name, default):
"""This private helper is deprecated in 3.13 and will be removed in 3.15""" """This private helper is deprecated in 3.13 and will be removed in 3.15"""
from java.lang import System from java.lang import System
@ -654,7 +678,7 @@ def _platform(*args):
if cleaned == platform: if cleaned == platform:
break break
platform = cleaned platform = cleaned
while platform[-1] == '-': while platform and platform[-1] == '-':
platform = platform[:-1] platform = platform[:-1]
return platform return platform
@ -695,7 +719,7 @@ def _syscmd_file(target, default=''):
default in case the command should fail. default in case the command should fail.
""" """
if sys.platform in ('dos', 'win32', 'win16'): if sys.platform in {'dos', 'win32', 'win16', 'ios', 'tvos', 'watchos'}:
# XXX Others too ? # XXX Others too ?
return default return default
@ -859,6 +883,14 @@ class _Processor:
csid, cpu_number = vms_lib.getsyi('SYI$_CPU', 0) csid, cpu_number = vms_lib.getsyi('SYI$_CPU', 0)
return 'Alpha' if cpu_number >= 128 else 'VAX' return 'Alpha' if cpu_number >= 128 else 'VAX'
# On the iOS simulator, os.uname returns the architecture as uname.machine.
# On device it returns the model name for some reason; but there's only one
# CPU architecture for iOS devices, so we know the right answer.
def get_ios():
if sys.implementation._multiarch.endswith("simulator"):
return os.uname().machine
return 'arm64'
def from_subprocess(): def from_subprocess():
""" """
Fall back to `uname -p` Fall back to `uname -p`
@ -1018,6 +1050,10 @@ def uname():
system = 'Android' system = 'Android'
release = android_ver().release release = android_ver().release
# Normalize responses on iOS
if sys.platform == 'ios':
system, release, _, _ = ios_ver()
vals = system, node, release, version, machine vals = system, node, release, version, machine
# Replace 'unknown' values with the more portable '' # Replace 'unknown' values with the more portable ''
_uname_cache = uname_result(*map(_unknown_as_blank, vals)) _uname_cache = uname_result(*map(_unknown_as_blank, vals))
@ -1297,7 +1333,10 @@ def platform(aliased=False, terse=False):
system, release, version = system_alias(system, release, version) system, release, version = system_alias(system, release, version)
if system == 'Darwin': if system == 'Darwin':
# macOS (darwin kernel) # macOS and iOS both report as a "Darwin" kernel
if sys.platform == "ios":
system, release, _, _ = ios_ver()
else:
macos_release = mac_ver()[0] macos_release = mac_ver()[0]
if macos_release: if macos_release:
system = 'macOS' system = 'macOS'

View File

@ -280,8 +280,8 @@ def _getuserbase():
if env_base: if env_base:
return env_base return env_base
# Emscripten, VxWorks, and WASI have no home directories # Emscripten, iOS, tvOS, VxWorks, WASI, and watchOS have no home directories
if sys.platform in {"emscripten", "vxworks", "wasi"}: if sys.platform in {"emscripten", "ios", "tvos", "vxworks", "wasi", "watchos"}:
return None return None
def joinuser(*args): def joinuser(*args):

View File

@ -21,6 +21,7 @@ __all__ = [
# Keys for get_config_var() that are never converted to Python integers. # Keys for get_config_var() that are never converted to Python integers.
_ALWAYS_STR = { _ALWAYS_STR = {
'IPHONEOS_DEPLOYMENT_TARGET',
'MACOSX_DEPLOYMENT_TARGET', 'MACOSX_DEPLOYMENT_TARGET',
} }
@ -57,6 +58,7 @@ _INSTALL_SCHEMES = {
'scripts': '{base}/Scripts', 'scripts': '{base}/Scripts',
'data': '{base}', 'data': '{base}',
}, },
# Downstream distributors can overwrite the default install scheme. # Downstream distributors can overwrite the default install scheme.
# This is done to support downstream modifications where distributors change # This is done to support downstream modifications where distributors change
# the installation layout (eg. different site-packages directory). # the installation layout (eg. different site-packages directory).
@ -114,8 +116,8 @@ def _getuserbase():
if env_base: if env_base:
return env_base return env_base
# Emscripten, VxWorks, and WASI have no home directories # Emscripten, iOS, tvOS, VxWorks, WASI, and watchOS have no home directories
if sys.platform in {"emscripten", "vxworks", "wasi"}: if sys.platform in {"emscripten", "ios", "tvos", "vxworks", "wasi", "watchos"}:
return None return None
def joinuser(*args): def joinuser(*args):
@ -290,6 +292,7 @@ def _get_preferred_schemes():
'home': 'posix_home', 'home': 'posix_home',
'user': 'osx_framework_user', 'user': 'osx_framework_user',
} }
return { return {
'prefix': 'posix_prefix', 'prefix': 'posix_prefix',
'home': 'posix_home', 'home': 'posix_home',
@ -623,6 +626,11 @@ def get_platform():
if m: if m:
release = m.group() release = m.group()
elif osname[:6] == "darwin": elif osname[:6] == "darwin":
if sys.platform == "ios":
release = get_config_vars().get("IPHONEOS_DEPLOYMENT_TARGET", "12.0")
osname = sys.platform
machine = sys.implementation._multiarch
else:
import _osx_support import _osx_support
osname, release, machine = _osx_support.get_platform_osx( osname, release, machine = _osx_support.get_platform_osx(
get_config_vars(), get_config_vars(),

View File

@ -290,6 +290,7 @@ def collect_os(info_add):
"HOMEDRIVE", "HOMEDRIVE",
"HOMEPATH", "HOMEPATH",
"IDLESTARTUP", "IDLESTARTUP",
"IPHONEOS_DEPLOYMENT_TARGET",
"LANG", "LANG",
"LDFLAGS", "LDFLAGS",
"LDSHARED", "LDSHARED",

View File

@ -49,6 +49,7 @@ class ThreadPoolExecutorTest(ThreadPoolMixin, ExecutorTest, BaseTestCase):
self.assertEqual(len(executor._threads), 1) self.assertEqual(len(executor._threads), 1)
executor.shutdown(wait=True) executor.shutdown(wait=True)
@support.requires_fork()
@unittest.skipUnless(hasattr(os, 'register_at_fork'), 'need os.register_at_fork') @unittest.skipUnless(hasattr(os, 'register_at_fork'), 'need os.register_at_fork')
@support.requires_resource('cpu') @support.requires_resource('cpu')
def test_hang_global_shutdown_lock(self): def test_hang_global_shutdown_lock(self):

View File

@ -1223,6 +1223,7 @@ class GCCallbackTests(unittest.TestCase):
self.assertEqual(len(gc.garbage), 0) self.assertEqual(len(gc.garbage), 0)
@requires_subprocess()
@unittest.skipIf(BUILD_WITH_NDEBUG, @unittest.skipIf(BUILD_WITH_NDEBUG,
'built with -NDEBUG') 'built with -NDEBUG')
def test_refcount_errors(self): def test_refcount_errors(self):

View File

@ -10,6 +10,14 @@ from unittest import mock
from test import support from test import support
from test.support import os_helper from test.support import os_helper
try:
# Some of the iOS tests need ctypes to operate.
# Confirm that the ctypes module is available
# is available.
import _ctypes
except ImportError:
_ctypes = None
FEDORA_OS_RELEASE = """\ FEDORA_OS_RELEASE = """\
NAME=Fedora NAME=Fedora
VERSION="32 (Thirty Two)" VERSION="32 (Thirty Two)"
@ -228,10 +236,21 @@ class PlatformTest(unittest.TestCase):
if sys.platform == "android": if sys.platform == "android":
self.assertEqual(res.system, "Android") self.assertEqual(res.system, "Android")
self.assertEqual(res.release, platform.android_ver().release) self.assertEqual(res.release, platform.android_ver().release)
elif sys.platform == "ios":
# Platform module needs ctypes for full operation. If ctypes
# isn't available, there's no ObjC module, and dummy values are
# returned.
if _ctypes:
self.assertIn(res.system, {"iOS", "iPadOS"})
self.assertEqual(res.release, platform.ios_ver().release)
else:
self.assertEqual(res.system, "")
self.assertEqual(res.release, "")
else: else:
self.assertEqual(res.system, uname.sysname) self.assertEqual(res.system, uname.sysname)
self.assertEqual(res.release, uname.release) self.assertEqual(res.release, uname.release)
@unittest.skipUnless(sys.platform.startswith('win'), "windows only test") @unittest.skipUnless(sys.platform.startswith('win'), "windows only test")
def test_uname_win32_without_wmi(self): def test_uname_win32_without_wmi(self):
def raises_oserror(*a): def raises_oserror(*a):
@ -422,6 +441,56 @@ class PlatformTest(unittest.TestCase):
# parent # parent
support.wait_process(pid, exitcode=0) support.wait_process(pid, exitcode=0)
def test_ios_ver(self):
result = platform.ios_ver()
# ios_ver is only fully available on iOS where ctypes is available.
if sys.platform == "ios" and _ctypes:
system, release, model, is_simulator = result
# Result is a namedtuple
self.assertEqual(result.system, system)
self.assertEqual(result.release, release)
self.assertEqual(result.model, model)
self.assertEqual(result.is_simulator, is_simulator)
# We can't assert specific values without reproducing the logic of
# ios_ver(), so we check that the values are broadly what we expect.
# System is either iOS or iPadOS, depending on the test device
self.assertIn(system, {"iOS", "iPadOS"})
# Release is a numeric version specifier with at least 2 parts
parts = release.split(".")
self.assertGreaterEqual(len(parts), 2)
self.assertTrue(all(part.isdigit() for part in parts))
# If this is a simulator, we get a high level device descriptor
# with no identifying model number. If this is a physical device,
# we get a model descriptor like "iPhone13,1"
if is_simulator:
self.assertIn(model, {"iPhone", "iPad"})
else:
self.assertTrue(
(model.startswith("iPhone") or model.startswith("iPad"))
and "," in model
)
self.assertEqual(type(is_simulator), bool)
else:
# On non-iOS platforms, calling ios_ver doesn't fail; you get
# default values
self.assertEqual(result.system, "")
self.assertEqual(result.release, "")
self.assertEqual(result.model, "")
self.assertFalse(result.is_simulator)
# Check the fallback values can be overridden by arguments
override = platform.ios_ver("Foo", "Bar", "Whiz", True)
self.assertEqual(override.system, "Foo")
self.assertEqual(override.release, "Bar")
self.assertEqual(override.model, "Whiz")
self.assertTrue(override.is_simulator)
@unittest.skipIf(support.is_emscripten, "Does not apply to Emscripten") @unittest.skipIf(support.is_emscripten, "Does not apply to Emscripten")
def test_libc_ver(self): def test_libc_ver(self):
# check that libc_ver(executable) doesn't raise an exception # check that libc_ver(executable) doesn't raise an exception

View File

@ -8,7 +8,11 @@ import shutil
from copy import copy from copy import copy
from test.support import ( from test.support import (
captured_stdout, PythonSymlink, requires_subprocess, is_wasi captured_stdout,
is_apple_mobile,
is_wasi,
PythonSymlink,
requires_subprocess,
) )
from test.support.import_helper import import_module from test.support.import_helper import import_module
from test.support.os_helper import (TESTFN, unlink, skip_unless_symlink, from test.support.os_helper import (TESTFN, unlink, skip_unless_symlink,
@ -346,6 +350,8 @@ class TestSysConfig(unittest.TestCase):
# XXX more platforms to tests here # XXX more platforms to tests here
@unittest.skipIf(is_wasi, "Incompatible with WASI mapdir and OOT builds") @unittest.skipIf(is_wasi, "Incompatible with WASI mapdir and OOT builds")
@unittest.skipIf(is_apple_mobile,
f"{sys.platform} doesn't distribute header files in the runtime environment")
def test_get_config_h_filename(self): def test_get_config_h_filename(self):
config_h = sysconfig.get_config_h_filename() config_h = sysconfig.get_config_h_filename()
self.assertTrue(os.path.isfile(config_h), config_h) self.assertTrue(os.path.isfile(config_h), config_h)
@ -423,6 +429,9 @@ class TestSysConfig(unittest.TestCase):
self.assertTrue(library.startswith(f'python{major}{minor}')) self.assertTrue(library.startswith(f'python{major}{minor}'))
self.assertTrue(library.endswith('.dll')) self.assertTrue(library.endswith('.dll'))
self.assertEqual(library, ldlibrary) self.assertEqual(library, ldlibrary)
elif is_apple_mobile:
framework = sysconfig.get_config_var('PYTHONFRAMEWORK')
self.assertEqual(ldlibrary, f"{framework}.framework/{framework}")
else: else:
self.assertTrue(library.startswith(f'libpython{major}.{minor}')) self.assertTrue(library.startswith(f'libpython{major}.{minor}'))
self.assertTrue(library.endswith('.a')) self.assertTrue(library.endswith('.a'))
@ -476,6 +485,8 @@ class TestSysConfig(unittest.TestCase):
self.assertEqual(my_platform, test_platform) self.assertEqual(my_platform, test_platform)
@unittest.skipIf(is_wasi, "Incompatible with WASI mapdir and OOT builds") @unittest.skipIf(is_wasi, "Incompatible with WASI mapdir and OOT builds")
@unittest.skipIf(is_apple_mobile,
f"{sys.platform} doesn't include config folder at runtime")
def test_srcdir(self): def test_srcdir(self):
# See Issues #15322, #15364. # See Issues #15322, #15364.
srcdir = sysconfig.get_config_var('srcdir') srcdir = sysconfig.get_config_var('srcdir')
@ -556,6 +567,8 @@ class MakefileTests(unittest.TestCase):
@unittest.skipIf(sys.platform.startswith('win'), @unittest.skipIf(sys.platform.startswith('win'),
'Test is not Windows compatible') 'Test is not Windows compatible')
@unittest.skipIf(is_wasi, "Incompatible with WASI mapdir and OOT builds") @unittest.skipIf(is_wasi, "Incompatible with WASI mapdir and OOT builds")
@unittest.skipIf(is_apple_mobile,
f"{sys.platform} doesn't include config folder at runtime")
def test_get_makefile_filename(self): def test_get_makefile_filename(self):
makefile = sysconfig.get_makefile_filename() makefile = sysconfig.get_makefile_filename()
self.assertTrue(os.path.isfile(makefile), makefile) self.assertTrue(os.path.isfile(makefile), makefile)

View File

@ -5,11 +5,14 @@ import sys
import subprocess import subprocess
from unittest import mock from unittest import mock
from test import support from test import support
from test.support import is_apple_mobile
from test.support import import_helper from test.support import import_helper
from test.support import os_helper from test.support import os_helper
from test.support import requires_subprocess
from test.support import threading_helper
if not support.has_subprocess_support: # The webbrowser module uses threading locks
raise unittest.SkipTest("test webserver requires subprocess") threading_helper.requires_working_threading(module=True)
URL = 'https://www.example.com' URL = 'https://www.example.com'
CMD_NAME = 'test' CMD_NAME = 'test'
@ -24,6 +27,7 @@ class PopenMock(mock.MagicMock):
return 0 return 0
@requires_subprocess()
class CommandTestMixin: class CommandTestMixin:
def _test(self, meth, *, args=[URL], kw={}, options, arguments): def _test(self, meth, *, args=[URL], kw={}, options, arguments):
@ -219,6 +223,73 @@ class ELinksCommandTest(CommandTestMixin, unittest.TestCase):
arguments=['openURL({},new-tab)'.format(URL)]) arguments=['openURL({},new-tab)'.format(URL)])
@unittest.skipUnless(sys.platform == "ios", "Test only applicable to iOS")
class IOSBrowserTest(unittest.TestCase):
def _obj_ref(self, *args):
# Construct a string representation of the arguments that can be used
# as a proxy for object instance references
return "|".join(str(a) for a in args)
@unittest.skipIf(getattr(webbrowser, "objc", None) is None,
"iOS Webbrowser tests require ctypes")
def setUp(self):
# Intercept the the objc library. Wrap the calls to get the
# references to classes and selectors to return strings, and
# wrap msgSend to return stringified object references
self.orig_objc = webbrowser.objc
webbrowser.objc = mock.Mock()
webbrowser.objc.objc_getClass = lambda cls: f"C#{cls.decode()}"
webbrowser.objc.sel_registerName = lambda sel: f"S#{sel.decode()}"
webbrowser.objc.objc_msgSend.side_effect = self._obj_ref
def tearDown(self):
webbrowser.objc = self.orig_objc
def _test(self, meth, **kwargs):
# The browser always gets focus, there's no concept of separate browser
# windows, and there's no API-level control over creating a new tab.
# Therefore, all calls to webbrowser are effectively the same.
getattr(webbrowser, meth)(URL, **kwargs)
# The ObjC String version of the URL is created with UTF-8 encoding
url_string_args = [
"C#NSString",
"S#stringWithCString:encoding:",
b'https://www.example.com',
4,
]
# The NSURL version of the URL is created from that string
url_obj_args = [
"C#NSURL",
"S#URLWithString:",
self._obj_ref(*url_string_args),
]
# The openURL call is invoked on the shared application
shared_app_args = ["C#UIApplication", "S#sharedApplication"]
# Verify that the last call is the one that opens the URL.
webbrowser.objc.objc_msgSend.assert_called_with(
self._obj_ref(*shared_app_args),
"S#openURL:options:completionHandler:",
self._obj_ref(*url_obj_args),
None,
None
)
def test_open(self):
self._test('open')
def test_open_with_autoraise_false(self):
self._test('open', autoraise=False)
def test_open_new(self):
self._test('open_new')
def test_open_new_tab(self):
self._test('open_new_tab')
class BrowserRegistrationTest(unittest.TestCase): class BrowserRegistrationTest(unittest.TestCase):
def setUp(self): def setUp(self):
@ -314,6 +385,10 @@ class ImportTest(unittest.TestCase):
webbrowser.register(name, None, webbrowser.GenericBrowser(name)) webbrowser.register(name, None, webbrowser.GenericBrowser(name))
webbrowser.get(sys.executable) webbrowser.get(sys.executable)
@unittest.skipIf(
is_apple_mobile,
"Apple mobile doesn't allow modifying browser with environment"
)
def test_environment(self): def test_environment(self):
webbrowser = import_helper.import_fresh_module('webbrowser') webbrowser = import_helper.import_fresh_module('webbrowser')
try: try:
@ -325,6 +400,10 @@ class ImportTest(unittest.TestCase):
webbrowser = import_helper.import_fresh_module('webbrowser') webbrowser = import_helper.import_fresh_module('webbrowser')
webbrowser.get() webbrowser.get()
@unittest.skipIf(
is_apple_mobile,
"Apple mobile doesn't allow modifying browser with environment"
)
def test_environment_preferred(self): def test_environment_preferred(self):
webbrowser = import_helper.import_fresh_module('webbrowser') webbrowser = import_helper.import_fresh_module('webbrowser')
try: try:

View File

@ -478,6 +478,9 @@ def register_standard_browsers():
# OS X can use below Unix support (but we prefer using the OS X # OS X can use below Unix support (but we prefer using the OS X
# specific stuff) # specific stuff)
if sys.platform == "ios":
register("iosbrowser", None, IOSBrowser(), preferred=True)
if sys.platform == "serenityos": if sys.platform == "serenityos":
# SerenityOS webbrowser, simply called "Browser". # SerenityOS webbrowser, simply called "Browser".
register("Browser", None, BackgroundBrowser("Browser")) register("Browser", None, BackgroundBrowser("Browser"))
@ -599,6 +602,70 @@ if sys.platform == 'darwin':
rc = osapipe.close() rc = osapipe.close()
return not rc return not rc
#
# Platform support for iOS
#
if sys.platform == "ios":
from _ios_support import objc
if objc:
# If objc exists, we know ctypes is also importable.
from ctypes import c_void_p, c_char_p, c_ulong
class IOSBrowser(BaseBrowser):
def open(self, url, new=0, autoraise=True):
sys.audit("webbrowser.open", url)
# If ctypes isn't available, we can't open a browser
if objc is None:
return False
# All the messages in this call return object references.
objc.objc_msgSend.restype = c_void_p
# This is the equivalent of:
# NSString url_string =
# [NSString stringWithCString:url.encode("utf-8")
# encoding:NSUTF8StringEncoding];
NSString = objc.objc_getClass(b"NSString")
constructor = objc.sel_registerName(b"stringWithCString:encoding:")
objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_char_p, c_ulong]
url_string = objc.objc_msgSend(
NSString,
constructor,
url.encode("utf-8"),
4, # NSUTF8StringEncoding = 4
)
# Create an NSURL object representing the URL
# This is the equivalent of:
# NSURL *nsurl = [NSURL URLWithString:url];
NSURL = objc.objc_getClass(b"NSURL")
urlWithString_ = objc.sel_registerName(b"URLWithString:")
objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p]
ns_url = objc.objc_msgSend(NSURL, urlWithString_, url_string)
# Get the shared UIApplication instance
# This code is the equivalent of:
# UIApplication shared_app = [UIApplication sharedApplication]
UIApplication = objc.objc_getClass(b"UIApplication")
sharedApplication = objc.sel_registerName(b"sharedApplication")
objc.objc_msgSend.argtypes = [c_void_p, c_void_p]
shared_app = objc.objc_msgSend(UIApplication, sharedApplication)
# Open the URL on the shared application
# This code is the equivalent of:
# [shared_app openURL:ns_url
# options:NIL
# completionHandler:NIL];
openURL_ = objc.sel_registerName(b"openURL:options:completionHandler:")
objc.objc_msgSend.argtypes = [
c_void_p, c_void_p, c_void_p, c_void_p, c_void_p
]
# Method returns void
objc.objc_msgSend.restype = None
objc.objc_msgSend(shared_app, openURL_, ns_url, None, None)
return True
def main(): def main():
import getopt import getopt

View File

@ -186,12 +186,18 @@ PYTHONFRAMEWORKPREFIX= @PYTHONFRAMEWORKPREFIX@
PYTHONFRAMEWORKINSTALLDIR= @PYTHONFRAMEWORKINSTALLDIR@ PYTHONFRAMEWORKINSTALLDIR= @PYTHONFRAMEWORKINSTALLDIR@
PYTHONFRAMEWORKINSTALLNAMEPREFIX= @PYTHONFRAMEWORKINSTALLNAMEPREFIX@ PYTHONFRAMEWORKINSTALLNAMEPREFIX= @PYTHONFRAMEWORKINSTALLNAMEPREFIX@
RESSRCDIR= @RESSRCDIR@ RESSRCDIR= @RESSRCDIR@
# Deployment target selected during configure, to be checked # macOS deployment target selected during configure, to be checked
# by distutils. The export statement is needed to ensure that the # by distutils. The export statement is needed to ensure that the
# deployment target is active during build. # deployment target is active during build.
MACOSX_DEPLOYMENT_TARGET=@CONFIGURE_MACOSX_DEPLOYMENT_TARGET@ MACOSX_DEPLOYMENT_TARGET=@CONFIGURE_MACOSX_DEPLOYMENT_TARGET@
@EXPORT_MACOSX_DEPLOYMENT_TARGET@export MACOSX_DEPLOYMENT_TARGET @EXPORT_MACOSX_DEPLOYMENT_TARGET@export MACOSX_DEPLOYMENT_TARGET
# iOS Deployment target selected during configure. Unlike macOS, the iOS
# deployment target is controlled using `-mios-version-min` arguments added to
# CFLAGS and LDFLAGS by the configure script. This variable is not used during
# the build, and is only listed here so it will be included in sysconfigdata.
IPHONEOS_DEPLOYMENT_TARGET=@IPHONEOS_DEPLOYMENT_TARGET@
# Option to install to strip binaries # Option to install to strip binaries
STRIPFLAG=-s STRIPFLAG=-s
@ -2038,11 +2044,23 @@ testios:
cp -r $(srcdir)/iOS/testbed $(XCFOLDER) cp -r $(srcdir)/iOS/testbed $(XCFOLDER)
# Copy the framework from the install location to the testbed project. # Copy the framework from the install location to the testbed project.
cp -r $(PYTHONFRAMEWORKPREFIX)/* $(XCFOLDER)/Python.xcframework/ios-arm64_x86_64-simulator cp -r $(PYTHONFRAMEWORKPREFIX)/* $(XCFOLDER)/Python.xcframework/ios-arm64_x86_64-simulator
# Run the test suite for the Xcode project, targeting the iOS simulator. # Run the test suite for the Xcode project, targeting the iOS simulator.
# If the suite fails, extract and print the console output, then re-raise the failure # If the suite fails, touch a file in the test folder as a marker
if ! xcodebuild test -project $(XCFOLDER)/iOSTestbed.xcodeproj -scheme "iOSTestbed" -destination "platform=iOS Simulator,name=iPhone SE (3rd Generation)" -resultBundlePath $(XCRESULT) ; then \ if ! xcodebuild test -project $(XCFOLDER)/iOSTestbed.xcodeproj -scheme "iOSTestbed" -destination "platform=iOS Simulator,name=iPhone SE (3rd Generation)" -resultBundlePath $(XCRESULT) ; then \
xcrun xcresulttool get --path $(XCRESULT) --id $$(xcrun xcresulttool get --path $(XCRESULT) --format json | $(PYTHON_FOR_BUILD) -c "import sys, json; result = json.load(sys.stdin); print(result['actions']['_values'][0]['actionResult']['logRef']['id']['_value'])"); \ touch $(XCFOLDER)/failed; \
echo ; \ fi
# Regardless of success or failure, extract and print the test output
xcrun xcresulttool get --path $(XCRESULT) \
--id $$( \
xcrun xcresulttool get --path $(XCRESULT) --format json | \
$(PYTHON_FOR_BUILD) -c "import sys, json; result = json.load(sys.stdin); print(result['actions']['_values'][0]['actionResult']['logRef']['id']['_value'])" \
) \
--format json | \
$(PYTHON_FOR_BUILD) -c "import sys, json; result = json.load(sys.stdin); print(result['subsections']['_values'][1]['subsections']['_values'][0]['emittedOutput']['_value'])"
@if test -e $(XCFOLDER)/failed ; then \
exit 1; \ exit 1; \
fi fi
@ -2777,8 +2795,8 @@ frameworkinstallmobileheaders: frameworkinstallunversionedstructure inclinstall
echo "Removing old framework headers"; \ echo "Removing old framework headers"; \
rm -rf $(DESTDIR)$(PYTHONFRAMEWORKINSTALLDIR)/Headers; \ rm -rf $(DESTDIR)$(PYTHONFRAMEWORKINSTALLDIR)/Headers; \
fi fi
mv "$(DESTDIR)$(PYTHONFRAMEWORKPREFIX)/include/python$(VERSION)" "$(DESTDIR)$(PYTHONFRAMEWORKINSTALLDIR)/Headers" mv "$(DESTDIR)$(PYTHONFRAMEWORKPREFIX)/include/python$(LDVERSION)" "$(DESTDIR)$(PYTHONFRAMEWORKINSTALLDIR)/Headers"
$(LN) -fs "../$(PYTHONFRAMEWORKDIR)/Headers" "$(DESTDIR)$(PYTHONFRAMEWORKPREFIX)/include/python$(VERSION)" $(LN) -fs "../$(PYTHONFRAMEWORKDIR)/Headers" "$(DESTDIR)$(PYTHONFRAMEWORKPREFIX)/include/python$(LDVERSION)"
# Build the toplevel Makefile # Build the toplevel Makefile
Makefile.pre: $(srcdir)/Makefile.pre.in config.status Makefile.pre: $(srcdir)/Makefile.pre.in config.status

View File

@ -0,0 +1 @@
Modify standard library to allow for iOS platform differences.

View File

@ -14,6 +14,10 @@
#include "pycore_setobject.h" // _PySet_NextEntry() #include "pycore_setobject.h" // _PySet_NextEntry()
#include "marshal.h" // Py_MARSHAL_VERSION #include "marshal.h" // Py_MARSHAL_VERSION
#ifdef __APPLE__
# include "TargetConditionals.h"
#endif /* __APPLE__ */
/*[clinic input] /*[clinic input]
module marshal module marshal
[clinic start generated code]*/ [clinic start generated code]*/
@ -36,6 +40,9 @@ module marshal
# define MAX_MARSHAL_STACK_DEPTH 1000 # define MAX_MARSHAL_STACK_DEPTH 1000
#elif defined(__wasi__) #elif defined(__wasi__)
# define MAX_MARSHAL_STACK_DEPTH 1500 # define MAX_MARSHAL_STACK_DEPTH 1500
// TARGET_OS_IPHONE covers any non-macOS Apple platform.
#elif defined(__APPLE__) && TARGET_OS_IPHONE
# define MAX_MARSHAL_STACK_DEPTH 1500
#else #else
# define MAX_MARSHAL_STACK_DEPTH 2000 # define MAX_MARSHAL_STACK_DEPTH 2000
#endif #endif

View File

@ -38,6 +38,7 @@ static const char* _Py_stdlib_module_names[] = {
"_heapq", "_heapq",
"_imp", "_imp",
"_io", "_io",
"_ios_support",
"_json", "_json",
"_locale", "_locale",
"_lsprof", "_lsprof",

21
configure generated vendored
View File

@ -976,7 +976,7 @@ LDFLAGS
CFLAGS CFLAGS
CC CC
HAS_XCRUN HAS_XCRUN
IOS_DEPLOYMENT_TARGET IPHONEOS_DEPLOYMENT_TARGET
EXPORT_MACOSX_DEPLOYMENT_TARGET EXPORT_MACOSX_DEPLOYMENT_TARGET
CONFIGURE_MACOSX_DEPLOYMENT_TARGET CONFIGURE_MACOSX_DEPLOYMENT_TARGET
_PYTHON_HOST_PLATFORM _PYTHON_HOST_PLATFORM
@ -4442,15 +4442,16 @@ if test "$cross_compiling" = yes; then
_host_device=`echo $host | cut -d '-' -f4` _host_device=`echo $host | cut -d '-' -f4`
_host_device=${_host_device:=os} _host_device=${_host_device:=os}
IOS_DEPLOYMENT_TARGET=${_host_os:3} # IPHONEOS_DEPLOYMENT_TARGET is the minimum supported iOS version
IOS_DEPLOYMENT_TARGET=${IOS_DEPLOYMENT_TARGET:=12.0} IPHONEOS_DEPLOYMENT_TARGET=${_host_os:3}
IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET:=12.0}
case "$host_cpu" in case "$host_cpu" in
aarch64) aarch64)
_host_ident=${IOS_DEPLOYMENT_TARGET}-arm64-iphone${_host_device} _host_ident=${IPHONEOS_DEPLOYMENT_TARGET}-arm64-iphone${_host_device}
;; ;;
*) *)
_host_ident=${IOS_DEPLOYMENT_TARGET}-$host_cpu-iphone${_host_device} _host_ident=${IPHONEOS_DEPLOYMENT_TARGET}-$host_cpu-iphone${_host_device}
;; ;;
esac esac
;; ;;
@ -4597,6 +4598,9 @@ fi
CONFIGURE_MACOSX_DEPLOYMENT_TARGET= CONFIGURE_MACOSX_DEPLOYMENT_TARGET=
EXPORT_MACOSX_DEPLOYMENT_TARGET='#' EXPORT_MACOSX_DEPLOYMENT_TARGET='#'
# Record the value of IPHONEOS_DEPLOYMENT_TARGET enforced by the selected host triple.
# checks for alternative programs # checks for alternative programs
# compiler flags are generated in two sets, BASECFLAGS and OPT. OPT is just # compiler flags are generated in two sets, BASECFLAGS and OPT. OPT is just
@ -4632,9 +4636,8 @@ esac
case $ac_sys_system in #( case $ac_sys_system in #(
iOS) : iOS) :
as_fn_append CFLAGS " -mios-version-min=${IOS_DEPLOYMENT_TARGET}" as_fn_append CFLAGS " -mios-version-min=${IPHONEOS_DEPLOYMENT_TARGET}"
as_fn_append LDFLAGS " -mios-version-min=${IOS_DEPLOYMENT_TARGET}" as_fn_append LDFLAGS " -mios-version-min=${IPHONEOS_DEPLOYMENT_TARGET}"
;; #( ;; #(
*) : *) :
;; ;;
@ -27497,6 +27500,8 @@ else $as_nop
with_ensurepip=no ;; #( with_ensurepip=no ;; #(
WASI) : WASI) :
with_ensurepip=no ;; #( with_ensurepip=no ;; #(
iOS) :
with_ensurepip=no ;; #(
*) : *) :
with_ensurepip=upgrade with_ensurepip=upgrade
;; ;;

View File

@ -715,16 +715,16 @@ if test "$cross_compiling" = yes; then
_host_device=`echo $host | cut -d '-' -f4` _host_device=`echo $host | cut -d '-' -f4`
_host_device=${_host_device:=os} _host_device=${_host_device:=os}
dnl IOS_DEPLOYMENT_TARGET is the minimum supported iOS version # IPHONEOS_DEPLOYMENT_TARGET is the minimum supported iOS version
IOS_DEPLOYMENT_TARGET=${_host_os:3} IPHONEOS_DEPLOYMENT_TARGET=${_host_os:3}
IOS_DEPLOYMENT_TARGET=${IOS_DEPLOYMENT_TARGET:=12.0} IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET:=12.0}
case "$host_cpu" in case "$host_cpu" in
aarch64) aarch64)
_host_ident=${IOS_DEPLOYMENT_TARGET}-arm64-iphone${_host_device} _host_ident=${IPHONEOS_DEPLOYMENT_TARGET}-arm64-iphone${_host_device}
;; ;;
*) *)
_host_ident=${IOS_DEPLOYMENT_TARGET}-$host_cpu-iphone${_host_device} _host_ident=${IPHONEOS_DEPLOYMENT_TARGET}-$host_cpu-iphone${_host_device}
;; ;;
esac esac
;; ;;
@ -866,6 +866,9 @@ AC_SUBST([EXPORT_MACOSX_DEPLOYMENT_TARGET])
CONFIGURE_MACOSX_DEPLOYMENT_TARGET= CONFIGURE_MACOSX_DEPLOYMENT_TARGET=
EXPORT_MACOSX_DEPLOYMENT_TARGET='#' EXPORT_MACOSX_DEPLOYMENT_TARGET='#'
# Record the value of IPHONEOS_DEPLOYMENT_TARGET enforced by the selected host triple.
AC_SUBST([IPHONEOS_DEPLOYMENT_TARGET])
# checks for alternative programs # checks for alternative programs
# compiler flags are generated in two sets, BASECFLAGS and OPT. OPT is just # compiler flags are generated in two sets, BASECFLAGS and OPT. OPT is just
@ -901,9 +904,8 @@ AS_CASE([$host],
dnl Add the compiler flag for the iOS minimum supported OS version. dnl Add the compiler flag for the iOS minimum supported OS version.
AS_CASE([$ac_sys_system], AS_CASE([$ac_sys_system],
[iOS], [ [iOS], [
AS_VAR_APPEND([CFLAGS], [" -mios-version-min=${IOS_DEPLOYMENT_TARGET}"]) AS_VAR_APPEND([CFLAGS], [" -mios-version-min=${IPHONEOS_DEPLOYMENT_TARGET}"])
AS_VAR_APPEND([LDFLAGS], [" -mios-version-min=${IOS_DEPLOYMENT_TARGET}"]) AS_VAR_APPEND([LDFLAGS], [" -mios-version-min=${IPHONEOS_DEPLOYMENT_TARGET}"])
AC_SUBST([IOS_DEPLOYMENT_TARGET])
], ],
) )
@ -6939,6 +6941,7 @@ AC_ARG_WITH([ensurepip],
AS_CASE([$ac_sys_system], AS_CASE([$ac_sys_system],
[Emscripten], [with_ensurepip=no], [Emscripten], [with_ensurepip=no],
[WASI], [with_ensurepip=no], [WASI], [with_ensurepip=no],
[iOS], [with_ensurepip=no],
[with_ensurepip=upgrade] [with_ensurepip=upgrade]
) )
]) ])

View File

@ -29,6 +29,6 @@
<string>iPhoneOS</string> <string>iPhoneOS</string>
</array> </array>
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>@IOS_DEPLOYMENT_TARGET@</string> <string>@IPHONEOS_DEPLOYMENT_TARGET@</string>
</dict> </dict>
</plist> </plist>

View File

@ -441,7 +441,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 3HEZE76D99; DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\""; HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\"";
INFOPLIST_FILE = "iOSTestbed/iOSTestbed-Info.plist"; INFOPLIST_FILE = "iOSTestbed/iOSTestbed-Info.plist";
@ -471,7 +471,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 3HEZE76D99; DEVELOPMENT_TEAM = "";
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\""; HEADER_SEARCH_PATHS = "\"$(BUILT_PRODUCTS_DIR)/Python.framework/Headers\"";