pythonnative 0.19.0__py3-none-any.whl → 0.21.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.
@@ -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