pythonnative 0.20.0__py3-none-any.whl → 0.22.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 +14 -3
- pythonnative/animated.py +420 -135
- pythonnative/cli/pn.py +450 -956
- pythonnative/components.py +519 -235
- pythonnative/events.py +210 -0
- pythonnative/gestures.py +875 -0
- pythonnative/layout.py +463 -149
- pythonnative/mutations.py +130 -0
- pythonnative/native_views/__init__.py +161 -97
- pythonnative/native_views/android.py +1050 -1124
- pythonnative/native_views/base.py +108 -18
- pythonnative/native_views/desktop.py +460 -417
- pythonnative/native_views/ios.py +1918 -1916
- pythonnative/project/__init__.py +68 -0
- pythonnative/project/android.py +504 -0
- pythonnative/project/builder.py +555 -0
- pythonnative/project/config.py +642 -0
- pythonnative/project/doctor.py +233 -0
- pythonnative/project/icons.py +247 -0
- pythonnative/project/ios.py +344 -0
- pythonnative/project/permissions.py +343 -0
- pythonnative/project/runtime_assets.py +272 -0
- pythonnative/reconciler.py +540 -470
- pythonnative/screen.py +5 -2
- pythonnative/sdk/_components.py +2 -2
- pythonnative/templates/android_template/app/build.gradle +2 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""Build orchestration for PythonNative projects.
|
|
2
|
+
|
|
3
|
+
The [`Builder`][pythonnative.project.builder.Builder] ties the pieces
|
|
4
|
+
together: it stages the bundled native template, runs the platform
|
|
5
|
+
[`configurators`][pythonnative.project.android], invokes the native
|
|
6
|
+
toolchains (Gradle / Xcode), and—on iOS—embeds the CPython runtime into
|
|
7
|
+
the built app.
|
|
8
|
+
|
|
9
|
+
All shell-outs go through a small
|
|
10
|
+
[`CommandRunner`][pythonnative.project.builder.CommandRunner] abstraction
|
|
11
|
+
so the orchestration logic can be unit tested with a recording fake
|
|
12
|
+
instead of a real device toolchain. The default
|
|
13
|
+
[`SubprocessRunner`][pythonnative.project.builder.SubprocessRunner] simply
|
|
14
|
+
delegates to :mod:`subprocess`.
|
|
15
|
+
|
|
16
|
+
Device interaction that genuinely needs a device (booting simulators,
|
|
17
|
+
installing, launching, streaming logs, hot reload) lives in the CLI; the
|
|
18
|
+
builder stops at producing installable/archivable artifacts.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from importlib import resources
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Callable, List, Optional, Sequence, Union
|
|
30
|
+
|
|
31
|
+
from . import android as android_config
|
|
32
|
+
from . import ios as ios_config
|
|
33
|
+
from . import runtime_assets
|
|
34
|
+
from .android import AndroidLayout
|
|
35
|
+
from .config import AppConfig
|
|
36
|
+
from .ios import IOSLayout
|
|
37
|
+
|
|
38
|
+
Logger = Callable[[str], None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BuildError(Exception):
|
|
42
|
+
"""Raised when a native build step fails.
|
|
43
|
+
|
|
44
|
+
Carries a user-facing message; the CLI prints it and exits non-zero.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ======================================================================
|
|
49
|
+
# Command runner abstraction
|
|
50
|
+
# ======================================================================
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class CommandResult:
|
|
55
|
+
"""The outcome of a single command invocation.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
returncode: Process exit status.
|
|
59
|
+
stdout: Captured standard output (empty unless captured).
|
|
60
|
+
stderr: Captured standard error (empty unless captured).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
returncode: int
|
|
64
|
+
stdout: str = ""
|
|
65
|
+
stderr: str = ""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def ok(self) -> bool:
|
|
69
|
+
"""Whether the command exited successfully (return code 0)."""
|
|
70
|
+
return self.returncode == 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CommandRunner:
|
|
74
|
+
"""Protocol for running external commands.
|
|
75
|
+
|
|
76
|
+
Implementations execute a command and return a
|
|
77
|
+
[`CommandResult`][pythonnative.project.builder.CommandResult]. Tests
|
|
78
|
+
provide a recording fake; production uses
|
|
79
|
+
[`SubprocessRunner`][pythonnative.project.builder.SubprocessRunner].
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def run(
|
|
83
|
+
self,
|
|
84
|
+
args: Sequence[str],
|
|
85
|
+
*,
|
|
86
|
+
cwd: Optional[Path] = None,
|
|
87
|
+
env: Optional[dict] = None,
|
|
88
|
+
capture: bool = False,
|
|
89
|
+
) -> CommandResult:
|
|
90
|
+
"""Run a command.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
args: The command and its arguments.
|
|
94
|
+
cwd: Working directory, or ``None`` for the current one.
|
|
95
|
+
env: Full environment mapping, or ``None`` to inherit.
|
|
96
|
+
capture: Whether to capture and return stdout/stderr.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
A [`CommandResult`][pythonnative.project.builder.CommandResult].
|
|
100
|
+
"""
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SubprocessRunner(CommandRunner):
|
|
105
|
+
"""A [`CommandRunner`][pythonnative.project.builder.CommandRunner] backed by :mod:`subprocess`."""
|
|
106
|
+
|
|
107
|
+
def run(
|
|
108
|
+
self,
|
|
109
|
+
args: Sequence[str],
|
|
110
|
+
*,
|
|
111
|
+
cwd: Optional[Path] = None,
|
|
112
|
+
env: Optional[dict] = None,
|
|
113
|
+
capture: bool = False,
|
|
114
|
+
) -> CommandResult:
|
|
115
|
+
"""Execute ``args`` with :func:`subprocess.run`."""
|
|
116
|
+
completed = subprocess.run(
|
|
117
|
+
list(args),
|
|
118
|
+
cwd=str(cwd) if cwd else None,
|
|
119
|
+
env=env,
|
|
120
|
+
capture_output=capture,
|
|
121
|
+
text=True,
|
|
122
|
+
)
|
|
123
|
+
return CommandResult(
|
|
124
|
+
returncode=completed.returncode,
|
|
125
|
+
stdout=completed.stdout or "" if capture else "",
|
|
126
|
+
stderr=completed.stderr or "" if capture else "",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ======================================================================
|
|
131
|
+
# Result/state dataclasses
|
|
132
|
+
# ======================================================================
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class PreparedProject:
|
|
137
|
+
"""A staged, configured project ready to build.
|
|
138
|
+
|
|
139
|
+
Attributes:
|
|
140
|
+
platform: ``"android"`` or ``"ios"``.
|
|
141
|
+
build_dir: The ``build/<platform>`` directory.
|
|
142
|
+
project_dir: The staged native project directory.
|
|
143
|
+
app_id: The resolved application id / bundle id for the platform.
|
|
144
|
+
android: Android layout (when ``platform == "android"``).
|
|
145
|
+
ios: iOS layout (when ``platform == "ios"``).
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
platform: str
|
|
149
|
+
build_dir: Path
|
|
150
|
+
project_dir: Path
|
|
151
|
+
app_id: str
|
|
152
|
+
android: Optional[AndroidLayout] = None
|
|
153
|
+
ios: Optional[IOSLayout] = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class BuildArtifacts:
|
|
158
|
+
"""Paths to artifacts produced by a release/standalone build.
|
|
159
|
+
|
|
160
|
+
Attributes:
|
|
161
|
+
paths: Output artifact paths (APK/AAB/IPA/.app).
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
paths: List[Path] = field(default_factory=list)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ======================================================================
|
|
168
|
+
# Template staging
|
|
169
|
+
# ======================================================================
|
|
170
|
+
|
|
171
|
+
_TEMPLATE_NAMES = {"android": "android_template", "ios": "ios_template"}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def stage_template(template_name: str, destination: Path) -> Path:
|
|
175
|
+
"""Copy a bundled native template into ``destination``.
|
|
176
|
+
|
|
177
|
+
Resolution order mirrors the historical CLI: a local source checkout
|
|
178
|
+
first (so dev edits take effect immediately), then installed package
|
|
179
|
+
data via :mod:`importlib.resources`.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
template_name: ``"android_template"`` or ``"ios_template"``.
|
|
183
|
+
destination: Parent directory; the template lands at
|
|
184
|
+
``destination/<template_name>``.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The path to the staged template directory.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
BuildError: If no bundled copy can be located.
|
|
191
|
+
"""
|
|
192
|
+
import shutil
|
|
193
|
+
|
|
194
|
+
dest_path = destination / template_name
|
|
195
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
|
|
197
|
+
# Dev-first: local source package templates.
|
|
198
|
+
local = Path(__file__).resolve().parents[1] / "templates" / template_name
|
|
199
|
+
if local.is_dir():
|
|
200
|
+
shutil.copytree(local, dest_path, dirs_exist_ok=True)
|
|
201
|
+
return dest_path
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
candidate = resources.files("pythonnative").joinpath("templates").joinpath(template_name)
|
|
205
|
+
with resources.as_file(candidate) as resolved:
|
|
206
|
+
if Path(resolved).is_dir():
|
|
207
|
+
shutil.copytree(resolved, dest_path, dirs_exist_ok=True)
|
|
208
|
+
return dest_path
|
|
209
|
+
except (ModuleNotFoundError, FileNotFoundError, OSError):
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
raise BuildError(
|
|
213
|
+
f"Could not find bundled template {template_name!r}. Reinstall pythonnative or run from a source checkout."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ======================================================================
|
|
218
|
+
# The builder
|
|
219
|
+
# ======================================================================
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class Builder:
|
|
223
|
+
"""Stages, configures, and builds a PythonNative project.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
config: The validated app configuration.
|
|
227
|
+
runner: Command runner (defaults to
|
|
228
|
+
[`SubprocessRunner`][pythonnative.project.builder.SubprocessRunner]).
|
|
229
|
+
log: Progress logger (defaults to :func:`print`).
|
|
230
|
+
build_root: Override for the ``build/`` directory.
|
|
231
|
+
dev_lib_root: Override for the ``pythonnative`` package directory
|
|
232
|
+
to bundle (defaults to the running package).
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
config: AppConfig,
|
|
238
|
+
*,
|
|
239
|
+
runner: Optional[CommandRunner] = None,
|
|
240
|
+
log: Optional[Logger] = None,
|
|
241
|
+
build_root: Optional[Path] = None,
|
|
242
|
+
dev_lib_root: Optional[Path] = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
self.config = config
|
|
245
|
+
self.runner = runner or SubprocessRunner()
|
|
246
|
+
self.log: Logger = log or print
|
|
247
|
+
self.build_root = build_root or (config.project_root / "build")
|
|
248
|
+
# The currently-running pythonnative package is bundled into the app
|
|
249
|
+
# (works for both a source checkout and a pip install).
|
|
250
|
+
self.dev_lib_root = dev_lib_root or Path(__file__).resolve().parents[1]
|
|
251
|
+
|
|
252
|
+
# -- Preparation ----------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def prepare(self, platform: str) -> PreparedProject:
|
|
255
|
+
"""Stage and configure the native project for ``platform``.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
platform: ``"android"`` or ``"ios"``.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
A [`PreparedProject`][pythonnative.project.builder.PreparedProject].
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
BuildError: For an unknown platform or a staging failure.
|
|
265
|
+
"""
|
|
266
|
+
if platform not in _TEMPLATE_NAMES:
|
|
267
|
+
raise BuildError(f"Unknown platform: {platform!r} (expected 'android' or 'ios').")
|
|
268
|
+
|
|
269
|
+
build_dir = self.build_root / platform
|
|
270
|
+
build_dir.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
project_dir = stage_template(_TEMPLATE_NAMES[platform], build_dir)
|
|
272
|
+
|
|
273
|
+
if platform == "android":
|
|
274
|
+
layout = android_config.configure(
|
|
275
|
+
project_dir,
|
|
276
|
+
self.config,
|
|
277
|
+
dev_lib_root=self.dev_lib_root,
|
|
278
|
+
log=self.log,
|
|
279
|
+
)
|
|
280
|
+
return PreparedProject(
|
|
281
|
+
platform=platform,
|
|
282
|
+
build_dir=build_dir,
|
|
283
|
+
project_dir=project_dir,
|
|
284
|
+
app_id=layout.application_id,
|
|
285
|
+
android=layout,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
ios_layout = ios_config.configure(project_dir, self.config, log=self.log)
|
|
289
|
+
# iOS Python sources are staged into a side directory and embedded
|
|
290
|
+
# into the built .app after the build.
|
|
291
|
+
self._stage_ios_python(build_dir)
|
|
292
|
+
return PreparedProject(
|
|
293
|
+
platform=platform,
|
|
294
|
+
build_dir=build_dir,
|
|
295
|
+
project_dir=project_dir,
|
|
296
|
+
app_id=ios_layout.bundle_id,
|
|
297
|
+
ios=ios_layout,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def _stage_ios_python(self, build_dir: Path) -> Path:
|
|
301
|
+
import shutil
|
|
302
|
+
|
|
303
|
+
python_dir = build_dir / "python"
|
|
304
|
+
if python_dir.exists():
|
|
305
|
+
shutil.rmtree(python_dir)
|
|
306
|
+
python_dir.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
|
|
308
|
+
app_src = self.config.project_root / "app"
|
|
309
|
+
if app_src.is_dir():
|
|
310
|
+
shutil.copytree(app_src, python_dir / "app", dirs_exist_ok=True)
|
|
311
|
+
if self.dev_lib_root.is_dir():
|
|
312
|
+
shutil.copytree(
|
|
313
|
+
self.dev_lib_root,
|
|
314
|
+
python_dir / "pythonnative",
|
|
315
|
+
dirs_exist_ok=True,
|
|
316
|
+
ignore=android_config.LIB_IGNORE,
|
|
317
|
+
)
|
|
318
|
+
return python_dir
|
|
319
|
+
|
|
320
|
+
# -- Android builds -------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def install_android_debug(self, prepared: PreparedProject) -> None:
|
|
323
|
+
"""Build and install the debug APK on a connected device/emulator.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
prepared: A prepared Android project.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
BuildError: If the Gradle build fails.
|
|
330
|
+
"""
|
|
331
|
+
self._gradlew(prepared, ["installDebug"])
|
|
332
|
+
|
|
333
|
+
def build_android(self, prepared: PreparedProject, *, debug: bool = False) -> BuildArtifacts:
|
|
334
|
+
"""Assemble standalone Android artifacts (APK + AAB).
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
prepared: A prepared Android project.
|
|
338
|
+
debug: Build the debug variant instead of release.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
The produced artifact paths.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
BuildError: If the Gradle build fails.
|
|
345
|
+
"""
|
|
346
|
+
if debug:
|
|
347
|
+
self._gradlew(prepared, ["assembleDebug"])
|
|
348
|
+
outputs = prepared.project_dir / "app" / "build" / "outputs"
|
|
349
|
+
return BuildArtifacts(paths=_existing(outputs.rglob("*-debug.apk")))
|
|
350
|
+
|
|
351
|
+
self._gradlew(prepared, ["assembleRelease", "bundleRelease"])
|
|
352
|
+
outputs = prepared.project_dir / "app" / "build" / "outputs"
|
|
353
|
+
candidates = list(outputs.rglob("*-release.apk")) + list(outputs.rglob("*-release.aab"))
|
|
354
|
+
if not config_has_android_signing(self.config):
|
|
355
|
+
candidates += list(outputs.rglob("*-release-unsigned.apk"))
|
|
356
|
+
return BuildArtifacts(paths=_existing(candidates))
|
|
357
|
+
|
|
358
|
+
def _gradlew(self, prepared: PreparedProject, tasks: Sequence[str]) -> None:
|
|
359
|
+
gradlew = prepared.project_dir / "gradlew"
|
|
360
|
+
if gradlew.exists():
|
|
361
|
+
os.chmod(gradlew, 0o755)
|
|
362
|
+
env = self._android_env()
|
|
363
|
+
result = self.runner.run(["./gradlew", *tasks], cwd=prepared.project_dir, env=env)
|
|
364
|
+
if not result.ok:
|
|
365
|
+
raise BuildError(f"Gradle build failed ({' '.join(tasks)}). See output above.")
|
|
366
|
+
|
|
367
|
+
def _android_env(self) -> dict:
|
|
368
|
+
env = dict(os.environ)
|
|
369
|
+
if sys.platform == "darwin" and not env.get("JAVA_HOME"):
|
|
370
|
+
try:
|
|
371
|
+
jdk = subprocess.check_output(["brew", "--prefix", "openjdk@17"], text=True).strip()
|
|
372
|
+
if jdk:
|
|
373
|
+
env["JAVA_HOME"] = jdk
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
return env
|
|
377
|
+
|
|
378
|
+
# -- iOS builds -----------------------------------------------------
|
|
379
|
+
|
|
380
|
+
def build_ios_simulator(self, prepared: PreparedProject) -> Path:
|
|
381
|
+
"""Build the iOS app for the simulator and embed the runtime.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
prepared: A prepared iOS project.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Path to the built ``.app`` with the runtime embedded.
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
BuildError: If the runtime can't be prepared or the build
|
|
391
|
+
fails.
|
|
392
|
+
"""
|
|
393
|
+
runtime = self._ios_runtime()
|
|
394
|
+
derived = prepared.project_dir / "build"
|
|
395
|
+
settings = ios_config.build_settings(self.config)
|
|
396
|
+
result = self.runner.run(
|
|
397
|
+
[
|
|
398
|
+
"xcodebuild",
|
|
399
|
+
"-project",
|
|
400
|
+
ios_config.PROJECT_FILE,
|
|
401
|
+
"-scheme",
|
|
402
|
+
ios_config.PROJECT_NAME,
|
|
403
|
+
"-configuration",
|
|
404
|
+
"Debug",
|
|
405
|
+
"-destination",
|
|
406
|
+
"generic/platform=iOS Simulator",
|
|
407
|
+
"-derivedDataPath",
|
|
408
|
+
str(derived),
|
|
409
|
+
"build",
|
|
410
|
+
*settings,
|
|
411
|
+
],
|
|
412
|
+
cwd=prepared.project_dir,
|
|
413
|
+
)
|
|
414
|
+
if not result.ok:
|
|
415
|
+
raise BuildError("xcodebuild (simulator) failed. See output above.")
|
|
416
|
+
|
|
417
|
+
app_path = derived / "Build" / "Products" / "Debug-iphonesimulator" / ios_config.APP_BUNDLE_NAME
|
|
418
|
+
if not app_path.is_dir():
|
|
419
|
+
raise BuildError(f"Built app not found at {app_path}.")
|
|
420
|
+
|
|
421
|
+
site_packages = self._install_ios_site_packages(prepared.build_dir)
|
|
422
|
+
ios_config.embed_runtime(
|
|
423
|
+
app_path,
|
|
424
|
+
runtime=runtime,
|
|
425
|
+
destination="simulator",
|
|
426
|
+
python_sources=prepared.build_dir / "python",
|
|
427
|
+
site_packages=site_packages,
|
|
428
|
+
log=self.log,
|
|
429
|
+
)
|
|
430
|
+
return app_path
|
|
431
|
+
|
|
432
|
+
def build_ios_archive(self, prepared: PreparedProject) -> BuildArtifacts:
|
|
433
|
+
"""Archive the iOS app for a device and export a signed IPA.
|
|
434
|
+
|
|
435
|
+
This path is experimental: it embeds the device CPython slice into
|
|
436
|
+
the archive before export and relies on ``xcodebuild`` to re-sign
|
|
437
|
+
the embedded framework.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
prepared: A prepared iOS project.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
The produced ``.ipa`` (and ``.xcarchive``) paths.
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
BuildError: If archiving or export fails.
|
|
447
|
+
"""
|
|
448
|
+
runtime = self._ios_runtime()
|
|
449
|
+
archive_path = prepared.build_dir / "ios_template.xcarchive"
|
|
450
|
+
settings = ios_config.build_settings(self.config, for_archive=True)
|
|
451
|
+
result = self.runner.run(
|
|
452
|
+
[
|
|
453
|
+
"xcodebuild",
|
|
454
|
+
"-project",
|
|
455
|
+
ios_config.PROJECT_FILE,
|
|
456
|
+
"-scheme",
|
|
457
|
+
ios_config.PROJECT_NAME,
|
|
458
|
+
"-configuration",
|
|
459
|
+
"Release",
|
|
460
|
+
"-destination",
|
|
461
|
+
"generic/platform=iOS",
|
|
462
|
+
"-archivePath",
|
|
463
|
+
str(archive_path),
|
|
464
|
+
"archive",
|
|
465
|
+
*settings,
|
|
466
|
+
],
|
|
467
|
+
cwd=prepared.project_dir,
|
|
468
|
+
)
|
|
469
|
+
if not result.ok:
|
|
470
|
+
raise BuildError("xcodebuild archive failed. See output above.")
|
|
471
|
+
|
|
472
|
+
app_in_archive = archive_path / "Products" / "Applications" / ios_config.APP_BUNDLE_NAME
|
|
473
|
+
if app_in_archive.is_dir():
|
|
474
|
+
site_packages = self._install_ios_site_packages(prepared.build_dir)
|
|
475
|
+
ios_config.embed_runtime(
|
|
476
|
+
app_in_archive,
|
|
477
|
+
runtime=runtime,
|
|
478
|
+
destination="device",
|
|
479
|
+
python_sources=prepared.build_dir / "python",
|
|
480
|
+
site_packages=site_packages,
|
|
481
|
+
log=self.log,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
export_dir = prepared.build_dir / "export"
|
|
485
|
+
options = ios_config.write_export_options(self.config, prepared.build_dir / "exportOptions.plist")
|
|
486
|
+
result = self.runner.run(
|
|
487
|
+
[
|
|
488
|
+
"xcodebuild",
|
|
489
|
+
"-exportArchive",
|
|
490
|
+
"-archivePath",
|
|
491
|
+
str(archive_path),
|
|
492
|
+
"-exportOptionsPlist",
|
|
493
|
+
str(options),
|
|
494
|
+
"-exportPath",
|
|
495
|
+
str(export_dir),
|
|
496
|
+
],
|
|
497
|
+
cwd=prepared.project_dir,
|
|
498
|
+
)
|
|
499
|
+
if not result.ok:
|
|
500
|
+
raise BuildError("xcodebuild -exportArchive failed. Check signing settings in [ios.signing].")
|
|
501
|
+
|
|
502
|
+
return BuildArtifacts(paths=_existing(list(export_dir.rglob("*.ipa")) + [archive_path]))
|
|
503
|
+
|
|
504
|
+
def _ios_runtime(self) -> runtime_assets.IOSRuntime:
|
|
505
|
+
cache = self.build_root / "ios_runtime"
|
|
506
|
+
try:
|
|
507
|
+
return runtime_assets.prepare_ios_runtime(cache, self.config.python_version, log=self.log)
|
|
508
|
+
except RuntimeError as exc:
|
|
509
|
+
raise BuildError(str(exc)) from exc
|
|
510
|
+
|
|
511
|
+
def _install_ios_site_packages(self, build_dir: Path) -> Optional[Path]:
|
|
512
|
+
import shutil
|
|
513
|
+
|
|
514
|
+
site_dir = build_dir / "platform-site"
|
|
515
|
+
if site_dir.exists():
|
|
516
|
+
shutil.rmtree(site_dir)
|
|
517
|
+
site_dir.mkdir(parents=True, exist_ok=True)
|
|
518
|
+
|
|
519
|
+
# rubicon-objc supplies the iOS Objective-C bridge.
|
|
520
|
+
self.runner.run(
|
|
521
|
+
[sys.executable, "-m", "pip", "install", "--no-deps", "--upgrade", "rubicon-objc", "-t", str(site_dir)],
|
|
522
|
+
capture=True,
|
|
523
|
+
)
|
|
524
|
+
if self.config.requirements:
|
|
525
|
+
self.runner.run(
|
|
526
|
+
[sys.executable, "-m", "pip", "install", "-t", str(site_dir), *self.config.requirements],
|
|
527
|
+
capture=True,
|
|
528
|
+
)
|
|
529
|
+
return site_dir
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ======================================================================
|
|
533
|
+
# Helpers
|
|
534
|
+
# ======================================================================
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def config_has_android_signing(config: AppConfig) -> bool:
|
|
538
|
+
"""Return whether the config defines an Android release signing key.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
config: The app configuration.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
``True`` if a keystore and alias are configured.
|
|
545
|
+
"""
|
|
546
|
+
return config.android.signing.is_configured
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _existing(paths: Union[Sequence[Path], object]) -> List[Path]:
|
|
550
|
+
result: List[Path] = []
|
|
551
|
+
for path in paths: # type: ignore[union-attr]
|
|
552
|
+
candidate = Path(path)
|
|
553
|
+
if candidate.exists() and candidate not in result:
|
|
554
|
+
result.append(candidate)
|
|
555
|
+
return result
|