diff --git a/docs/tags.rst b/docs/tags.rst index ef446bb0..26afe92f 100644 --- a/docs/tags.rst +++ b/docs/tags.rst @@ -153,6 +153,21 @@ to the implementation to provide. compatibility +.. function:: ios_platforms(version=None, multiarch=None) + + Yields the :attr:`~Tag.platform` tags for iOS. + + :param tuple version: A two-item tuple representing the version of iOS. + Defaults to the current system's version. + :param str multiarch: The CPU architecture+ABI to be used. This should be in + the format by ``sys.implementation._multiarch`` (e.g., + ``arm64_iphoneos`` or ``x84_64_iphonesimulator``). + Defaults to the current system's multiarch value. + + .. note:: + Behavior of this method is undefined if invoked on non-iOS platforms + without providing explicit version and multiarch arguments. + .. function:: platform_tags(version=None, arch=None) Yields the :attr:`~Tag.platform` tags for the running interpreter. diff --git a/src/packaging/tags.py b/src/packaging/tags.py index d28e0262..977cc122 100644 --- a/src/packaging/tags.py +++ b/src/packaging/tags.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) PythonVersion = Sequence[int] -MacVersion = Tuple[int, int] +AppleVersion = Tuple[int, int] INTERPRETER_SHORT_NAMES: dict[str, str] = { "python": "py", # Generic. @@ -362,7 +362,7 @@ def _mac_arch(arch: str, is_32bit: bool = _32_BIT_INTERPRETER) -> str: return "i386" -def _mac_binary_formats(version: MacVersion, cpu_arch: str) -> list[str]: +def _mac_binary_formats(version: AppleVersion, cpu_arch: str) -> list[str]: formats = [cpu_arch] if cpu_arch == "x86_64": if version < (10, 4): @@ -395,7 +395,7 @@ def _mac_binary_formats(version: MacVersion, cpu_arch: str) -> list[str]: def mac_platforms( - version: MacVersion | None = None, arch: str | None = None + version: AppleVersion | None = None, arch: str | None = None ) -> Iterator[str]: """ Yields the platform tags for a macOS system. @@ -407,7 +407,7 @@ def mac_platforms( """ version_str, _, cpu_arch = platform.mac_ver() if version is None: - version = cast("MacVersion", tuple(map(int, version_str.split(".")[:2]))) + version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2]))) if version == (10, 16): # When built against an older macOS SDK, Python will report macOS 10.16 # instead of the real version. @@ -423,7 +423,7 @@ def mac_platforms( stdout=subprocess.PIPE, text=True, ).stdout - version = cast("MacVersion", tuple(map(int, version_str.split(".")[:2]))) + version = cast("AppleVersion", tuple(map(int, version_str.split(".")[:2]))) else: version = version if arch is None: @@ -473,6 +473,63 @@ def mac_platforms( yield f"macosx_{major_version}_{minor_version}_{binary_format}" +def ios_platforms( + version: AppleVersion | None = None, multiarch: str | None = None +) -> Iterator[str]: + """ + Yields the platform tags for an iOS system. + + :param version: A two-item tuple specifying the iOS version to generate + platform tags for. Defaults to the current iOS version. + :param multiarch: The CPU architecture+ABI to generate platform tags for - + (the value used by `sys.implementation._multiarch` e.g., + `arm64_iphoneos` or `x84_64_iphonesimulator`). Defaults to the current + multiarch value. + """ + if version is None: + # if iOS is the current platform, ios_ver *must* be defined. However, + # it won't exist for CPython versions before 3.13, which causes a mypy + # error. + _, release, _, _ = platform.ios_ver() # type: ignore[attr-defined] + version = cast("AppleVersion", tuple(map(int, release.split(".")[:2]))) + + if multiarch is None: + multiarch = sys.implementation._multiarch + multiarch = multiarch.replace("-", "_") + + ios_platform_template = "ios_{major}_{minor}_{multiarch}" + + # Consider any iOS major.minor version from the version requested, down to + # 12.0. 12.0 is the first iOS version that is known to have enough features + # to support CPython. Consider every possible minor release up to X.9. There + # highest the minor has ever gone is 8 (14.8 and 15.8) but having some extra + # candidates that won't ever match doesn't really hurt, and it saves us from + # having to keep an explicit list of known iOS versions in the code. Return + # the results descending order of version number. + + # If the requested major version is less than 12, there won't be any matches. + if version[0] < 12: + return + + # Consider the actual X.Y version that was requested. + yield ios_platform_template.format( + major=version[0], minor=version[1], multiarch=multiarch + ) + + # Consider every minor version from X.0 to the minor version prior to the + # version requested by the platform. + for minor in range(version[1] - 1, -1, -1): + yield ios_platform_template.format( + major=version[0], minor=minor, multiarch=multiarch + ) + + for major in range(version[0] - 1, 11, -1): + for minor in range(9, -1, -1): + yield ios_platform_template.format( + major=major, minor=minor, multiarch=multiarch + ) + + def _linux_platforms(is_32bit: bool = _32_BIT_INTERPRETER) -> Iterator[str]: linux = _normalize_string(sysconfig.get_platform()) if not linux.startswith("linux_"): @@ -502,6 +559,8 @@ def platform_tags() -> Iterator[str]: """ if platform.system() == "Darwin": return mac_platforms() + elif platform.system() == "iOS": + return ios_platforms() elif platform.system() == "Linux": return _linux_platforms() else: diff --git a/tests/test_tags.py b/tests/test_tags.py index e37de63c..5ec89b24 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -53,6 +53,30 @@ def mock(name): return mock +@pytest.fixture +def mock_ios(monkeypatch): + # Monkeypatch the platform to be iOS + monkeypatch.setattr(sys, "platform", "ios") + + # Mock a fake architecture that will fit the expected pattern, but + # wont actually be a legal multiarch. + monkeypatch.setattr( + sys.implementation, + "_multiarch", + "gothic-iphoneos", + raising=False, + ) + + # Mock the return value of platform.ios_ver. + def mock_ios_ver(*args): + return ("iOS", "13.2", "iPhone15,2", False) + + if sys.version_info < (3, 13): + platform.ios_ver = mock_ios_ver + else: + monkeypatch.setattr(platform, "ios_ver", mock_ios_ver) + + class TestTag: def test_lowercasing(self): tag = tags.Tag("PY3", "None", "ANY") @@ -335,6 +359,84 @@ def test_macos_11(self, major, minor): assert "macosx_12_0_universal2" in platforms +class TestIOSPlatforms: + def test_version_detection(self, mock_ios): + platforms = list(tags.ios_platforms(multiarch="arm64-iphoneos")) + assert platforms == [ + "ios_13_2_arm64_iphoneos", + "ios_13_1_arm64_iphoneos", + "ios_13_0_arm64_iphoneos", + "ios_12_9_arm64_iphoneos", + "ios_12_8_arm64_iphoneos", + "ios_12_7_arm64_iphoneos", + "ios_12_6_arm64_iphoneos", + "ios_12_5_arm64_iphoneos", + "ios_12_4_arm64_iphoneos", + "ios_12_3_arm64_iphoneos", + "ios_12_2_arm64_iphoneos", + "ios_12_1_arm64_iphoneos", + "ios_12_0_arm64_iphoneos", + ] + + def test_multiarch_detection(self, mock_ios): + platforms = list(tags.ios_platforms(version=(12, 0))) + assert platforms == ["ios_12_0_gothic_iphoneos"] + + def test_ios_platforms(self, mock_ios): + # Pre-iOS 12.0 releases won't match anything + platforms = list(tags.ios_platforms((7, 0), "arm64-iphoneos")) + assert platforms == [] + + # iOS 12.0 returns exactly 1 match + platforms = list(tags.ios_platforms((12, 0), "arm64-iphoneos")) + assert platforms == ["ios_12_0_arm64_iphoneos"] + + # iOS 13.0 returns a match for 13.0, plus every 12.X + platforms = list(tags.ios_platforms((13, 0), "x86_64-iphonesimulator")) + assert platforms == [ + "ios_13_0_x86_64_iphonesimulator", + "ios_12_9_x86_64_iphonesimulator", + "ios_12_8_x86_64_iphonesimulator", + "ios_12_7_x86_64_iphonesimulator", + "ios_12_6_x86_64_iphonesimulator", + "ios_12_5_x86_64_iphonesimulator", + "ios_12_4_x86_64_iphonesimulator", + "ios_12_3_x86_64_iphonesimulator", + "ios_12_2_x86_64_iphonesimulator", + "ios_12_1_x86_64_iphonesimulator", + "ios_12_0_x86_64_iphonesimulator", + ] + + # iOS 14.3 returns a match for 14.3-14.0, plus every 13.X and every 12.X + platforms = list(tags.ios_platforms((14, 3), "arm64-iphoneos")) + assert platforms == [ + "ios_14_3_arm64_iphoneos", + "ios_14_2_arm64_iphoneos", + "ios_14_1_arm64_iphoneos", + "ios_14_0_arm64_iphoneos", + "ios_13_9_arm64_iphoneos", + "ios_13_8_arm64_iphoneos", + "ios_13_7_arm64_iphoneos", + "ios_13_6_arm64_iphoneos", + "ios_13_5_arm64_iphoneos", + "ios_13_4_arm64_iphoneos", + "ios_13_3_arm64_iphoneos", + "ios_13_2_arm64_iphoneos", + "ios_13_1_arm64_iphoneos", + "ios_13_0_arm64_iphoneos", + "ios_12_9_arm64_iphoneos", + "ios_12_8_arm64_iphoneos", + "ios_12_7_arm64_iphoneos", + "ios_12_6_arm64_iphoneos", + "ios_12_5_arm64_iphoneos", + "ios_12_4_arm64_iphoneos", + "ios_12_3_arm64_iphoneos", + "ios_12_2_arm64_iphoneos", + "ios_12_1_arm64_iphoneos", + "ios_12_0_arm64_iphoneos", + ] + + class TestManylinuxPlatform: def teardown_method(self): # Clear the version cache @@ -619,6 +721,7 @@ def test_linux_not_linux(self, monkeypatch): "platform_name,dispatch_func", [ ("Darwin", "mac_platforms"), + ("iOS", "ios_platforms"), ("Linux", "_linux_platforms"), ("Generic", "_generic_platforms"), ],