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.
- pythonnative/__init__.py +9 -1
- pythonnative/alerts.py +6 -14
- pythonnative/cli/pn.py +96 -10
- pythonnative/components/_base.py +31 -0
- pythonnative/components/controls.py +19 -16
- pythonnative/components/layout.py +5 -8
- pythonnative/components/lists.py +6 -6
- pythonnative/gestures.py +27 -6
- pythonnative/native_modules/__init__.py +19 -0
- pythonnative/native_modules/battery.py +13 -14
- pythonnative/native_modules/biometrics.py +12 -14
- pythonnative/native_modules/camera.py +10 -6
- pythonnative/native_modules/clipboard.py +9 -11
- pythonnative/native_modules/file_system.py +81 -172
- pythonnative/native_modules/haptics.py +14 -10
- pythonnative/native_modules/linking.py +9 -8
- pythonnative/native_modules/location.py +10 -12
- pythonnative/native_modules/net_info.py +1 -4
- pythonnative/native_modules/notifications.py +25 -32
- pythonnative/native_modules/permissions.py +70 -22
- pythonnative/native_modules/secure_store.py +16 -20
- pythonnative/native_modules/share.py +5 -5
- pythonnative/navigation/__init__.py +2 -1
- pythonnative/navigation/hooks.py +46 -7
- pythonnative/navigation/state.py +30 -9
- pythonnative/project/android.py +23 -0
- pythonnative/project/builder.py +64 -35
- pythonnative/project/config.py +84 -24
- pythonnative/project/deps.py +703 -0
- pythonnative/project/doctor.py +53 -4
- pythonnative/project/runtime_assets.py +13 -14
- pythonnative/style.py +94 -44
- pythonnative/templates/android_template/app/build.gradle +5 -2
- pythonnative/templates/android_template/build.gradle +7 -4
- pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +1 -1
- pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/modules/PermissionsModule.kt +15 -4
- pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/PermissionModules.swift +25 -10
- pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +3 -2
- pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +35 -9
- pythonnative/utils.py +22 -27
- pythonnative/virtual_rows.py +10 -20
- {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/METADATA +8 -8
- {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/RECORD +47 -46
- {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.37.0.dist-info → pythonnative-0.38.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
16
|
-
``
|
|
17
|
-
|
|
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 =
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
""
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
10
|
-
``Optional[str]
|
|
11
|
-
|
|
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) ->
|
|
35
|
-
"""Store ``value`` under ``key
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
pythonnative/navigation/hooks.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
42
|
-
(previews, tests) without
|
|
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
|
-
|
|
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:
|
pythonnative/navigation/state.py
CHANGED
|
@@ -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:
|
|
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
|
|
100
|
+
return Route(
|
|
80
101
|
str(data["name"]),
|
|
81
102
|
params if isinstance(params, Mapping) else {},
|
|
82
103
|
key=data.get("key"),
|
pythonnative/project/android.py
CHANGED
|
@@ -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
|
pythonnative/project/builder.py
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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,
|
|
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.
|