pythonnative 0.37.0__py3-none-any.whl → 0.38.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. pythonnative/__init__.py +9 -1
  2. pythonnative/alerts.py +6 -14
  3. pythonnative/cli/pn.py +96 -10
  4. pythonnative/components/_base.py +31 -0
  5. pythonnative/components/controls.py +19 -16
  6. pythonnative/components/layout.py +5 -8
  7. pythonnative/components/lists.py +6 -6
  8. pythonnative/gestures.py +27 -6
  9. pythonnative/native_modules/__init__.py +19 -0
  10. pythonnative/native_modules/battery.py +13 -14
  11. pythonnative/native_modules/biometrics.py +12 -14
  12. pythonnative/native_modules/camera.py +10 -6
  13. pythonnative/native_modules/clipboard.py +9 -11
  14. pythonnative/native_modules/file_system.py +81 -172
  15. pythonnative/native_modules/haptics.py +14 -10
  16. pythonnative/native_modules/linking.py +9 -8
  17. pythonnative/native_modules/location.py +10 -12
  18. pythonnative/native_modules/net_info.py +1 -4
  19. pythonnative/native_modules/notifications.py +25 -32
  20. pythonnative/native_modules/permissions.py +70 -22
  21. pythonnative/native_modules/secure_store.py +16 -20
  22. pythonnative/native_modules/share.py +5 -5
  23. pythonnative/navigation/__init__.py +2 -1
  24. pythonnative/navigation/hooks.py +46 -7
  25. pythonnative/navigation/state.py +30 -9
  26. pythonnative/project/android.py +23 -0
  27. pythonnative/project/builder.py +64 -35
  28. pythonnative/project/config.py +84 -24
  29. pythonnative/project/deps.py +703 -0
  30. pythonnative/project/doctor.py +53 -4
  31. pythonnative/project/runtime_assets.py +13 -14
  32. pythonnative/style.py +94 -44
  33. pythonnative/templates/android_template/app/build.gradle +5 -2
  34. pythonnative/templates/android_template/build.gradle +7 -4
  35. pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +1 -1
  36. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/PermissionsModule.kt +15 -4
  37. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PermissionModules.swift +25 -10
  38. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +3 -2
  39. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +35 -9
  40. pythonnative/utils.py +22 -27
  41. pythonnative/virtual_rows.py +10 -20
  42. {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/METADATA +8 -8
  43. {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/RECORD +47 -46
  44. {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/WHEEL +0 -0
  45. {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/entry_points.txt +0 -0
  46. {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/licenses/LICENSE +0 -0
  47. {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/top_level.txt +0 -0
@@ -4,7 +4,7 @@
4
4
  iOS and Android permission models behind two calls, both served by the
5
5
  native ``Permissions`` module:
6
6
 
7
- - ``check(permission)``: synchronous, returns a status string without
7
+ - ``check(permission)``: synchronous, returns a status without
8
8
  prompting.
9
9
  - ``request(permission)``: a coroutine that shows the system prompt
10
10
  (if needed) and resolves to the resulting status.
@@ -12,27 +12,62 @@ native ``Permissions`` module:
12
12
  Statuses are ``"granted"``, ``"denied"``, ``"blocked"`` (denied with
13
13
  "don't ask again" / Settings required), or ``"undetermined"``.
14
14
 
15
- Supported ``permission`` names: ``"camera"``, ``"microphone"``,
16
- ``"location"``, ``"photos"``, ``"notifications"``, ``"contacts"``.
17
- Unknown names resolve to ``"undetermined"``.
15
+ Permission names are the same words you declare in the
16
+ ``[permissions]`` table of ``pythonnative.toml``, so the string that
17
+ puts ``NSCameraUsageDescription`` in your Info.plist is the string you
18
+ pass here: ``"camera"``, ``"microphone"``, ``"photo_library"``,
19
+ ``"location_when_in_use"``, ``"contacts"``, ``"notifications"``. The
20
+ full list is [`RUNTIME_PERMISSIONS`][pythonnative.native_modules.permissions.RUNTIME_PERMISSIONS];
21
+ capabilities that have no runtime prompt (``vibration``,
22
+ ``background_fetch``, ...) are declared in the config only. A name
23
+ outside the list raises ``ValueError`` before anything reaches native.
24
+
25
+ Example:
26
+ ```python
27
+ import pythonnative as pn
28
+
29
+ async def scan():
30
+ if await pn.Permissions.request("camera") != "granted":
31
+ return
32
+ await pn.Camera.take_photo()
33
+ ```
18
34
  """
19
35
 
20
36
  from __future__ import annotations
21
37
 
38
+ from typing import Literal
39
+
22
40
  from .registry import native_module
23
41
 
24
- PermissionStatus = str
25
- PermissionName = str
42
+ PermissionStatus = Literal["granted", "denied", "blocked", "undetermined"]
43
+ """Outcome of a check or request."""
44
+
45
+ PermissionName = Literal[
46
+ "camera",
47
+ "microphone",
48
+ "photo_library",
49
+ "location_when_in_use",
50
+ "contacts",
51
+ "notifications",
52
+ ]
53
+ """A capability that has a runtime prompt; the same names as ``[permissions]`` in ``pythonnative.toml``."""
26
54
 
27
- GRANTED = "granted"
28
- DENIED = "denied"
29
- BLOCKED = "blocked"
30
- UNDETERMINED = "undetermined"
55
+ GRANTED: PermissionStatus = "granted"
56
+ DENIED: PermissionStatus = "denied"
57
+ BLOCKED: PermissionStatus = "blocked"
58
+ UNDETERMINED: PermissionStatus = "undetermined"
31
59
 
32
60
  _STATUSES = (GRANTED, DENIED, BLOCKED, UNDETERMINED)
33
61
 
34
- PERMISSION_NAMES = ("camera", "microphone", "location", "photos", "notifications", "contacts")
35
- """Permission names understood by every platform."""
62
+ RUNTIME_PERMISSIONS = (
63
+ "camera",
64
+ "microphone",
65
+ "photo_library",
66
+ "location_when_in_use",
67
+ "contacts",
68
+ "notifications",
69
+ )
70
+ """Every permission name ``check`` / ``request`` accept, in the ``[permissions]`` vocabulary."""
36
71
 
37
72
 
38
73
  def _coerce(value: object) -> PermissionStatus:
@@ -40,21 +75,34 @@ def _coerce(value: object) -> PermissionStatus:
40
75
  return status if status in _STATUSES else UNDETERMINED
41
76
 
42
77
 
78
+ def _validate(permission: str) -> str:
79
+ if permission not in RUNTIME_PERMISSIONS:
80
+ raise ValueError(
81
+ f"Unknown permission {permission!r}. Use one of {', '.join(RUNTIME_PERMISSIONS)} "
82
+ "(the same names as [permissions] in pythonnative.toml)."
83
+ )
84
+ return permission
85
+
86
+
43
87
  class Permissions:
44
- """Runtime permission interface."""
88
+ """Runtime permission interface.
89
+
90
+ Raises:
91
+ ValueError: For a permission name outside
92
+ [`RUNTIME_PERMISSIONS`][pythonnative.native_modules.permissions.RUNTIME_PERMISSIONS].
93
+ NativeModuleError: If the native module fails.
94
+ """
45
95
 
46
96
  @staticmethod
47
97
  def check(permission: PermissionName) -> PermissionStatus:
48
98
  """Return the current status of ``permission`` without prompting."""
49
- try:
50
- return _coerce(native_module("Permissions").call("check", permission=permission))
51
- except Exception:
52
- return UNDETERMINED
99
+ return _coerce(native_module("Permissions").call("check", permission=_validate(permission)))
53
100
 
54
101
  @staticmethod
55
102
  async def request(permission: PermissionName) -> PermissionStatus:
56
- """Prompt for ``permission`` (if needed) and return the result."""
57
- try:
58
- return _coerce(await native_module("Permissions").call_async("request", permission=permission))
59
- except Exception:
60
- return UNDETERMINED
103
+ """Prompt for ``permission`` (if needed) and return the result.
104
+
105
+ Off device (``pn preview``, tests) the answer is always
106
+ ``"undetermined"``: there is no prompt to show.
107
+ """
108
+ return _coerce(await native_module("Permissions").call_async("request", permission=_validate(permission)))
@@ -6,10 +6,10 @@ native ``SecureStore`` module), the right place for auth tokens and
6
6
  other secrets that [`AsyncStorage`][pythonnative.AsyncStorage] (plain,
7
7
  unencrypted) should never hold.
8
8
 
9
- All methods are synchronous and return a ``bool`` (writes/deletes) or
10
- ``Optional[str]`` (reads). On desktop the module falls back to an
11
- in-process dict so code paths stay exercisable without a device
12
- Keychain.
9
+ Both backing stores complete on the calling thread, so every method is
10
+ synchronous. Reads return ``Optional[str]``; writes return nothing and
11
+ raise on failure. On desktop the module falls back to an in-process dict
12
+ so code paths stay exercisable without a device Keychain.
13
13
 
14
14
  Example:
15
15
  ```python
@@ -28,29 +28,25 @@ from .registry import native_module
28
28
 
29
29
 
30
30
  class SecureStore:
31
- """Encrypted secret storage (synchronous)."""
31
+ """Encrypted secret storage (synchronous).
32
+
33
+ Raises:
34
+ NativeModuleError: If the Keychain / EncryptedSharedPreferences
35
+ operation fails (for example a Keychain entitlement problem).
36
+ """
32
37
 
33
38
  @staticmethod
34
- def set_item(key: str, value: str) -> bool:
35
- """Store ``value`` under ``key``. Returns ``True`` on success."""
36
- try:
37
- return bool(native_module("SecureStore").call("set_item", key=key, value=value))
38
- except Exception:
39
- return False
39
+ def set_item(key: str, value: str) -> None:
40
+ """Store ``value`` under ``key``, replacing any previous value."""
41
+ native_module("SecureStore").call("set_item", key=key, value=value)
40
42
 
41
43
  @staticmethod
42
44
  def get_item(key: str) -> Optional[str]:
43
45
  """Return the value for ``key``, or ``None`` if absent."""
44
- try:
45
- value = native_module("SecureStore").call("get_item", key=key)
46
- except Exception:
47
- return None
46
+ value = native_module("SecureStore").call("get_item", key=key)
48
47
  return None if value is None else str(value)
49
48
 
50
49
  @staticmethod
51
50
  def delete_item(key: str) -> bool:
52
- """Delete ``key``. Returns ``True`` if it existed and was removed."""
53
- try:
54
- return bool(native_module("SecureStore").call("delete_item", key=key))
55
- except Exception:
56
- return False
51
+ """Delete ``key``. Returns ``True`` if it existed, ``False`` if there was nothing to delete."""
52
+ return bool(native_module("SecureStore").call("delete_item", key=key))
@@ -43,9 +43,9 @@ class Share:
43
43
 
44
44
  Returns:
45
45
  ``True`` if the user completed a share, ``False`` if they
46
- cancelled or no share UI is available.
46
+ dismissed the sheet or the platform has no share UI (desktop).
47
+
48
+ Raises:
49
+ NativeModuleError: If the sheet could not be presented.
47
50
  """
48
- try:
49
- return bool(await native_module("Share").call_async("share", message=message, url=url, title=title))
50
- except Exception:
51
- return False
51
+ return bool(await native_module("Share").call_async("share", message=message, url=url, title=title))
@@ -66,7 +66,7 @@ from .navigators import (
66
66
  create_tab_navigator,
67
67
  )
68
68
  from .screen import HeaderSlot, ScreenDef, ScreenOptions
69
- from .state import NavigationState, Route
69
+ from .state import NavigationState, Route, RouteParams
70
70
 
71
71
  __all__ = [
72
72
  "NAV_STATE_ARG",
@@ -87,6 +87,7 @@ __all__ = [
87
87
  "NavigationState",
88
88
  "NavigatorCore",
89
89
  "Route",
90
+ "RouteParams",
90
91
  "ScreenDef",
91
92
  "ScreenOptions",
92
93
  "StackNavigator",
@@ -2,11 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Callable, Optional, Sequence
5
+ from typing import Any, Callable, Dict, Optional, Sequence, overload
6
6
 
7
7
  from ..hooks import use_context, use_effect
8
8
  from .handle import FocusContext, Navigation, NavigationContext
9
- from .state import Route
9
+ from .state import Route, RouteParams
10
10
 
11
11
  __all__ = ["use_focus_effect", "use_is_focused", "use_navigation", "use_route"]
12
12
 
@@ -34,25 +34,64 @@ def use_navigation() -> Navigation:
34
34
  return nav
35
35
 
36
36
 
37
- def use_route() -> Route:
37
+ @overload
38
+ def use_route() -> Route[Dict[str, Any]]: ...
39
+
40
+
41
+ @overload
42
+ def use_route[P: RouteParams](params_type: type[P], /) -> Route[P]: ...
43
+
44
+
45
+ def use_route(params_type: Optional[type] = None, /) -> Route[Any]:
38
46
  """Return the current screen's [`Route`][pythonnative.navigation.Route].
39
47
 
48
+ Pass a ``TypedDict`` class as ``params_type`` to get a
49
+ ``Route[MyParams]`` whose ``params`` attribute is typed for editors
50
+ and type checkers. At runtime the hook also verifies that every
51
+ *required* key of the ``TypedDict`` is present on the active route,
52
+ so a screen opened with the wrong params fails at its first render
53
+ with a message naming the missing keys, instead of a ``KeyError``
54
+ deep inside the render.
55
+
40
56
  Outside any navigator a placeholder route named ``"__root__"`` with
41
- empty params is returned, so components can be rendered standalone
42
- (previews, tests) without special-casing.
57
+ empty params is returned (and no validation is performed), so
58
+ components can be rendered standalone (previews, tests) without
59
+ special-casing.
60
+
61
+ Args:
62
+ params_type: Optional ``TypedDict`` describing this screen's
63
+ params.
64
+
65
+ Raises:
66
+ TypeError: If the active route is missing a required param
67
+ declared by ``params_type``.
43
68
 
44
69
  Example:
45
70
  ```python
71
+ class DetailParams(TypedDict):
72
+ id: int
73
+ title: NotRequired[str]
74
+
46
75
  @pn.component
47
76
  def DetailScreen():
48
- route = pn.use_route()
77
+ route = pn.use_route(DetailParams)
49
78
  return pn.Text(f"Item {route.params['id']}")
50
79
  ```
51
80
  """
52
81
  nav = use_context(NavigationContext)
53
82
  if nav is None:
54
83
  return Route("__root__", {}, key="__root__")
55
- return nav.route
84
+ route = nav.route
85
+ if params_type is not None:
86
+ required = getattr(params_type, "__required_keys__", None)
87
+ if required:
88
+ missing = sorted(k for k in required if k not in route.params)
89
+ if missing:
90
+ raise TypeError(
91
+ f"Screen {route.name!r} is missing required params {missing} "
92
+ f"declared by {params_type.__name__}; got {sorted(route.params)}"
93
+ )
94
+ return route
56
95
 
57
96
 
58
97
  def use_is_focused() -> bool:
@@ -15,9 +15,16 @@ pushed screen the full history it belongs to.
15
15
  from __future__ import annotations
16
16
 
17
17
  import itertools
18
- from typing import Any, Dict, Iterable, Iterator, Mapping, Optional, Sequence, Tuple
18
+ from typing import Any, Dict, Iterable, Iterator, Mapping, Optional, Sequence, Tuple, cast
19
19
 
20
- __all__ = ["NavigationState", "Route"]
20
+ __all__ = ["NavigationState", "Route", "RouteParams"]
21
+
22
+ type RouteParams = Mapping[str, Any]
23
+ """Bound for the ``P`` type parameter of [`Route`][pythonnative.navigation.Route].
24
+
25
+ Declare a screen's params as a ``TypedDict`` and read them with
26
+ ``use_route(MyParams)`` for a fully typed ``route.params``.
27
+ """
21
28
 
22
29
  _route_keys = itertools.count(1)
23
30
 
@@ -26,13 +33,27 @@ def _new_key(name: str) -> str:
26
33
  return f"{name}-{next(_route_keys)}"
27
34
 
28
35
 
29
- class Route:
36
+ class Route[P: RouteParams = Dict[str, Any]]:
30
37
  """One entry in a navigator's state.
31
38
 
39
+ ``Route`` is generic in its params type. Bare ``Route`` is
40
+ ``Route[dict[str, Any]]``; pass a ``TypedDict`` to
41
+ [`use_route`][pythonnative.use_route] to get ``Route[MyParams]``
42
+ with a typed ``params`` attribute:
43
+
44
+ ```python
45
+ class DetailParams(TypedDict):
46
+ id: int
47
+
48
+ route = pn.use_route(DetailParams)
49
+ route.params["id"] # int
50
+ ```
51
+
32
52
  Attributes:
33
53
  name: The screen name this route renders.
34
54
  params: Parameters passed to the screen (read with
35
- [`use_route`][pythonnative.use_route]).
55
+ [`use_route`][pythonnative.use_route]). Always a plain
56
+ ``dict`` at runtime.
36
57
  key: Stable identity for this particular visit to the screen,
37
58
  unique per process. Two pushes of the same screen have
38
59
  different keys, so their component state never mixes.
@@ -51,16 +72,16 @@ class Route:
51
72
  state: Optional["NavigationState"] = None,
52
73
  ) -> None:
53
74
  self.name = name
54
- self.params: Dict[str, Any] = dict(params or {})
75
+ self.params: P = cast(P, dict(params or {}))
55
76
  self.key = key or _new_key(name)
56
77
  self.state = state
57
78
 
58
- def with_params(self, params: Mapping[str, Any], *, merge: bool = True) -> "Route":
79
+ def with_params(self, params: Mapping[str, Any], *, merge: bool = True) -> "Route[P]":
59
80
  """Return a copy carrying ``params`` (merged over the current ones by default)."""
60
81
  merged = {**self.params, **params} if merge else dict(params)
61
82
  return Route(self.name, merged, key=self.key, state=self.state)
62
83
 
63
- def with_state(self, state: Optional["NavigationState"]) -> "Route":
84
+ def with_state(self, state: Optional["NavigationState"]) -> "Route[P]":
64
85
  """Return a copy carrying a nested navigator seed ``state``."""
65
86
  return Route(self.name, self.params, key=self.key, state=state)
66
87
 
@@ -72,11 +93,11 @@ class Route:
72
93
  return out
73
94
 
74
95
  @classmethod
75
- def from_dict(cls, data: Mapping[str, Any]) -> "Route":
96
+ def from_dict(cls, data: Mapping[str, Any]) -> "Route[Dict[str, Any]]":
76
97
  """Rebuild a route from ``to_dict`` output, keeping its ``key`` so component state carries over."""
77
98
  params = data.get("params")
78
99
  nested = data.get("state")
79
- return cls(
100
+ return Route(
80
101
  str(data["name"]),
81
102
  params if isinstance(params, Mapping) else {},
82
103
  key=data.get("key"),
@@ -186,12 +186,35 @@ def configure_gradle(project_dir: Path, config: AppConfig) -> None:
186
186
  abi_csv = ", ".join(f'"{abi}"' for abi in config.android.abi_filters)
187
187
  content = re.sub(r"abiFilters[^\n]*", f"abiFilters {abi_csv}", content)
188
188
 
189
+ if config.extra_index_urls:
190
+ content = _inject_pip_options(content, config)
191
+
189
192
  if config.android.signing.is_configured:
190
193
  content = _inject_signing(content, config)
191
194
 
192
195
  gradle_path.write_text(content, encoding="utf-8")
193
196
 
194
197
 
198
+ def _inject_pip_options(content: str, config: AppConfig) -> str:
199
+ """Add ``--extra-index-url`` pip options for ``[requirements].extra_index_urls``.
200
+
201
+ Chaquopy runs pip at Gradle build time; ``options`` lines inside the
202
+ ``pip { }`` block are passed straight through, so a private index
203
+ declared once in ``pythonnative.toml`` applies to Android too.
204
+ """
205
+ marker = ' install "-r", "requirements.txt"'
206
+ if marker not in content:
207
+ return content
208
+ lines = "".join(
209
+ f' options "--extra-index-url", "{_gradle_escape(url)}"\n' for url in config.extra_index_urls
210
+ )
211
+ return content.replace(marker, lines + marker, 1)
212
+
213
+
214
+ def _gradle_escape(value: str) -> str:
215
+ return value.replace("\\", "\\\\").replace('"', '\\"')
216
+
217
+
195
218
  def _inject_signing(content: str, config: AppConfig) -> str:
196
219
  signing = config.android.signing
197
220
  assert signing.keystore is not None and signing.key_alias is not None
@@ -10,6 +10,10 @@ On iOS, the staged project is self-contained: ``Python.xcframework`` is
10
10
  linked at build time and an Xcode build phase installs the standard
11
11
  library and app code into the bundle, so ``xcodebuild`` output is
12
12
  directly runnable and archivable with no post-build patching.
13
+ Third-party packages are resolved per destination (see
14
+ [`deps`][pythonnative.project.deps]) into ``app_packages.iphoneos`` and
15
+ ``app_packages.iphonesimulator``; the build phase installs the slice
16
+ matching the SDK being built.
13
17
 
14
18
  All shell-outs go through a small
15
19
  [`CommandRunner`][pythonnative.project.builder.CommandRunner] abstraction
@@ -37,9 +41,9 @@ from pathlib import Path
37
41
  from typing import Callable, List, Optional, Sequence, Union
38
42
 
39
43
  from . import android as android_config
44
+ from . import deps, runtime_assets
40
45
  from . import ios as ios_config
41
46
  from . import plugins as native_plugins
42
- from . import runtime_assets
43
47
  from .android import AndroidLayout
44
48
  from .config import AppConfig
45
49
  from .ios import IOSLayout
@@ -272,7 +276,13 @@ class Builder:
272
276
 
273
277
  # -- Preparation ----------------------------------------------------
274
278
 
275
- def prepare(self, platform: str, *, release: bool = False) -> PreparedProject:
279
+ def prepare(
280
+ self,
281
+ platform: str,
282
+ *,
283
+ release: bool = False,
284
+ ios_sdks: Sequence[str] = deps.IOS_SDKS,
285
+ ) -> PreparedProject:
276
286
  """Stage and configure the native project for ``platform``.
277
287
 
278
288
  Args:
@@ -281,12 +291,20 @@ class Builder:
281
291
  byte-compiles the staged Python code and drops the
282
292
  ``.py`` sources from the bundle. (Chaquopy does the
283
293
  equivalent on Android automatically.)
294
+ ios_sdks: Which iOS SDKs to resolve third-party packages
295
+ for: ``"iphoneos"`` (devices, archives) and/or
296
+ ``"iphonesimulator"``. Each becomes an
297
+ ``app_packages.<sdk>`` slice in the staged project.
298
+ Defaults to both; the CLI narrows it to the destination
299
+ it is about to build so a package that only has a
300
+ Simulator wheel doesn't block a Simulator run.
284
301
 
285
302
  Returns:
286
303
  A [`PreparedProject`][pythonnative.project.builder.PreparedProject].
287
304
 
288
305
  Raises:
289
- BuildError: For an unknown platform or a staging failure.
306
+ BuildError: For an unknown platform, a staging failure, or a
307
+ requirement with no wheel for a requested iOS SDK.
290
308
  """
291
309
  if platform not in _TEMPLATE_NAMES:
292
310
  raise BuildError(f"Unknown platform: {platform!r} (expected 'android' or 'ios').")
@@ -314,7 +332,7 @@ class Builder:
314
332
 
315
333
  ios_layout = ios_config.configure(project_dir, self.config, log=self.log)
316
334
  native_plugins.stage_ios_plugins(project_dir, plugins, log=self.log)
317
- self._stage_ios_python(project_dir, release=release)
335
+ self._stage_ios_python(project_dir, release=release, sdks=ios_sdks)
318
336
  self._link_ios_runtime(project_dir)
319
337
  return PreparedProject(
320
338
  platform=platform,
@@ -338,46 +356,57 @@ class Builder:
338
356
  except native_plugins.PluginError as exc:
339
357
  raise BuildError(f"Invalid native plugin: {exc}") from exc
340
358
 
341
- def _stage_ios_python(self, project_dir: Path, *, release: bool) -> None:
342
- """Stage ``app/`` and ``app_packages/`` at the Xcode project root.
359
+ def _stage_ios_python(self, project_dir: Path, *, release: bool, sdks: Sequence[str]) -> None:
360
+ """Stage ``app/`` and one ``app_packages.<sdk>/`` slice per SDK.
343
361
 
344
- The Xcode project references both folders as bundle resources
345
- and the "Install Python runtime" build phase converts any
346
- binary modules inside them into signed frameworks.
362
+ ``app/`` is a bundle resource. The package slices are not: the
363
+ "Install Python runtime" build phase copies the slice matching
364
+ ``EFFECTIVE_PLATFORM_NAME`` into the bundle as ``app_packages``
365
+ and converts any binary modules inside it into signed
366
+ frameworks. Every slice carries the ``pythonnative`` package
367
+ (pure Python) plus the project's requirements resolved for that
368
+ SDK's wheel tags.
347
369
  """
348
370
  app_dir = project_dir / "app"
349
- packages_dir = project_dir / "app_packages"
350
- for directory in (app_dir, packages_dir):
351
- if directory.exists():
352
- shutil.rmtree(directory)
353
- directory.mkdir(parents=True, exist_ok=True)
371
+ if app_dir.exists():
372
+ shutil.rmtree(app_dir)
373
+ for stale in project_dir.glob("app_packages*"):
374
+ if stale.is_dir():
375
+ shutil.rmtree(stale)
376
+ else:
377
+ stale.unlink()
354
378
 
355
379
  app_src = self.config.project_root / "app"
356
380
  if not app_src.is_dir():
357
381
  raise BuildError(f"No app/ directory found at {app_src}; nothing to bundle.")
358
- shutil.copytree(app_src, app_dir, dirs_exist_ok=True)
359
-
360
- if self.dev_lib_root.is_dir():
361
- shutil.copytree(
362
- self.dev_lib_root,
363
- packages_dir / "pythonnative",
364
- dirs_exist_ok=True,
365
- ignore=android_config.LIB_IGNORE,
366
- )
367
-
368
- # The framework has no Python-side native dependencies (the
369
- # bridge into PythonNativeKit is ctypes); only user requirements
370
- # are installed into the site directory.
371
- if self.config.requirements:
372
- result = self.runner.run(
373
- [sys.executable, "-m", "pip", "install", "-t", str(packages_dir), *self.config.requirements],
374
- capture=True,
375
- )
376
- if not result.ok:
377
- raise BuildError(f"pip install of [requirements].packages failed:\n{result.stderr}")
382
+ shutil.copytree(app_src, app_dir)
383
+
384
+ slices: List[Path] = []
385
+ for target in deps.ios_targets(self.config, sdks=sdks):
386
+ packages_dir = project_dir / target.slice_name
387
+ packages_dir.mkdir(parents=True, exist_ok=True)
388
+ slices.append(packages_dir)
389
+
390
+ # The framework has no Python-side native dependencies (the
391
+ # bridge into PythonNativeKit is ctypes), so it is copied
392
+ # verbatim into every slice.
393
+ if self.dev_lib_root.is_dir():
394
+ shutil.copytree(
395
+ self.dev_lib_root,
396
+ packages_dir / "pythonnative",
397
+ dirs_exist_ok=True,
398
+ ignore=android_config.LIB_IGNORE,
399
+ )
400
+
401
+ if self.config.requirements:
402
+ self.log(f"Resolving {len(self.config.requirements)} requirement(s) for {target.label}...")
403
+ try:
404
+ deps.install(self.config, target, packages_dir, runner=self.runner)
405
+ except deps.DependencyError as exc:
406
+ raise BuildError(str(exc)) from exc
378
407
 
379
408
  if release:
380
- self._compile_ios_bytecode(app_dir, packages_dir)
409
+ self._compile_ios_bytecode(app_dir, *slices)
381
410
 
382
411
  def _compile_ios_bytecode(self, *roots: Path) -> None:
383
412
  """Byte-compile staged Python code and drop the ``.py`` sources.