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.
Files changed (33) hide show
  1. pythonnative/__init__.py +14 -3
  2. pythonnative/animated.py +420 -135
  3. pythonnative/cli/pn.py +450 -956
  4. pythonnative/components.py +519 -235
  5. pythonnative/events.py +210 -0
  6. pythonnative/gestures.py +875 -0
  7. pythonnative/layout.py +463 -149
  8. pythonnative/mutations.py +130 -0
  9. pythonnative/native_views/__init__.py +161 -97
  10. pythonnative/native_views/android.py +1050 -1124
  11. pythonnative/native_views/base.py +108 -18
  12. pythonnative/native_views/desktop.py +460 -417
  13. pythonnative/native_views/ios.py +1918 -1916
  14. pythonnative/project/__init__.py +68 -0
  15. pythonnative/project/android.py +504 -0
  16. pythonnative/project/builder.py +555 -0
  17. pythonnative/project/config.py +642 -0
  18. pythonnative/project/doctor.py +233 -0
  19. pythonnative/project/icons.py +247 -0
  20. pythonnative/project/ios.py +344 -0
  21. pythonnative/project/permissions.py +343 -0
  22. pythonnative/project/runtime_assets.py +272 -0
  23. pythonnative/reconciler.py +540 -470
  24. pythonnative/screen.py +5 -2
  25. pythonnative/sdk/_components.py +2 -2
  26. pythonnative/templates/android_template/app/build.gradle +2 -0
  27. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
  28. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
  29. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
  30. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
  31. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
  32. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
  33. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,233 @@
1
+ """Environment diagnostics for ``pn doctor``.
2
+
3
+ Inspects the local toolchain and the project's ``pythonnative.toml`` and
4
+ reports what's ready and what's missing for building on each platform —
5
+ analogous to ``flutter doctor`` / ``npx react-native doctor``. The checks
6
+ are deliberately read-only and fast; they shell out only to ask tools for
7
+ their versions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import List, Optional
18
+
19
+ from . import icons
20
+ from .config import IOS_SUPPORTED_PYTHON_VERSION, SUPPORTED_PYTHON_VERSIONS, AppConfig, ConfigError
21
+
22
+ OK = "ok"
23
+ WARN = "warn"
24
+ ERROR = "error"
25
+ INFO = "info"
26
+
27
+ _SYMBOLS = {OK: "[ok]", WARN: "[!]", ERROR: "[x]", INFO: "[i]"}
28
+
29
+
30
+ @dataclass
31
+ class CheckResult:
32
+ """The outcome of a single diagnostic check.
33
+
34
+ Attributes:
35
+ name: Short label for the thing checked.
36
+ level: One of ``"ok"``, ``"warn"``, ``"error"``, ``"info"``.
37
+ detail: Human-readable detail / remediation hint.
38
+ """
39
+
40
+ name: str
41
+ level: str
42
+ detail: str = ""
43
+
44
+ def format(self) -> str:
45
+ """Return a single aligned line for terminal output."""
46
+ symbol = _SYMBOLS.get(self.level, "[?]")
47
+ suffix = f" — {self.detail}" if self.detail else ""
48
+ return f" {symbol} {self.name}{suffix}"
49
+
50
+
51
+ def _which_version(tool: str, version_args: List[str]) -> Optional[str]:
52
+ path = shutil.which(tool)
53
+ if not path:
54
+ return None
55
+ try:
56
+ out = subprocess.run([tool, *version_args], capture_output=True, text=True, timeout=20)
57
+ except Exception:
58
+ return path
59
+ text = (out.stdout or out.stderr or "").strip().splitlines()
60
+ return text[0] if text else path
61
+
62
+
63
+ def check_common() -> List[CheckResult]:
64
+ """Run platform-agnostic checks (interpreter, Pillow).
65
+
66
+ Returns:
67
+ Check results for the host Python and optional dependencies.
68
+ """
69
+ results: List[CheckResult] = []
70
+ py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
71
+ if py_version in SUPPORTED_PYTHON_VERSIONS:
72
+ results.append(CheckResult("Host Python", OK, f"{sys.version.split()[0]}"))
73
+ else:
74
+ results.append(
75
+ CheckResult(
76
+ "Host Python",
77
+ WARN,
78
+ f"{py_version} (PythonNative targets {', '.join(SUPPORTED_PYTHON_VERSIONS)})",
79
+ )
80
+ )
81
+ if icons.pillow_available():
82
+ results.append(CheckResult("Pillow (icon/splash generation)", OK))
83
+ else:
84
+ results.append(
85
+ CheckResult(
86
+ "Pillow (icon/splash generation)",
87
+ WARN,
88
+ "not installed; run: pip install 'pythonnative[build]'",
89
+ )
90
+ )
91
+ return results
92
+
93
+
94
+ def check_android(config: Optional[AppConfig]) -> List[CheckResult]:
95
+ """Run Android toolchain and signing checks.
96
+
97
+ Args:
98
+ config: The loaded app config, or ``None`` if unavailable.
99
+
100
+ Returns:
101
+ Android-specific check results.
102
+ """
103
+ results: List[CheckResult] = []
104
+ adb = _which_version("adb", ["--version"])
105
+ results.append(CheckResult("adb (Android platform-tools)", OK if adb else WARN, adb or "not found on PATH"))
106
+
107
+ java_home = shutil.which("java")
108
+ import os
109
+
110
+ if os.environ.get("JAVA_HOME") or java_home:
111
+ results.append(CheckResult("Java (JDK 17 recommended)", OK, os.environ.get("JAVA_HOME") or java_home or ""))
112
+ else:
113
+ results.append(CheckResult("Java (JDK 17 recommended)", WARN, "JAVA_HOME not set and 'java' not on PATH"))
114
+
115
+ if config is not None:
116
+ signing = config.android.signing
117
+ if signing.is_configured:
118
+ keystore = config.resolve_path(signing.keystore) if signing.keystore else None
119
+ if keystore and keystore.is_file():
120
+ results.append(CheckResult("Android release keystore", OK, str(keystore)))
121
+ else:
122
+ results.append(CheckResult("Android release keystore", ERROR, f"not found: {keystore}"))
123
+ missing = [env for env in (signing.store_password_env, signing.key_password_env) if not os.environ.get(env)]
124
+ if missing:
125
+ results.append(CheckResult("Android signing passwords", WARN, f"unset env: {', '.join(missing)}"))
126
+ else:
127
+ results.append(
128
+ CheckResult(
129
+ "Android release signing",
130
+ INFO,
131
+ "not configured; release builds will be unsigned (set [android.signing])",
132
+ )
133
+ )
134
+ return results
135
+
136
+
137
+ def check_ios(config: Optional[AppConfig]) -> List[CheckResult]:
138
+ """Run iOS toolchain and signing checks.
139
+
140
+ Args:
141
+ config: The loaded app config, or ``None`` if unavailable.
142
+
143
+ Returns:
144
+ iOS-specific check results.
145
+ """
146
+ results: List[CheckResult] = []
147
+ if sys.platform != "darwin":
148
+ results.append(CheckResult("macOS (required for iOS)", ERROR, f"this is {sys.platform}; iOS builds need macOS"))
149
+ return results
150
+
151
+ xcodebuild = _which_version("xcodebuild", ["-version"])
152
+ results.append(CheckResult("Xcode (xcodebuild)", OK if xcodebuild else ERROR, xcodebuild or "not found on PATH"))
153
+ simctl = shutil.which("xcrun")
154
+ results.append(CheckResult("xcrun simctl (Simulators)", OK if simctl else WARN, simctl or "not found on PATH"))
155
+
156
+ if config is not None:
157
+ if config.python_version != IOS_SUPPORTED_PYTHON_VERSION:
158
+ results.append(
159
+ CheckResult(
160
+ "iOS embedded Python",
161
+ WARN,
162
+ f"app.python_version={config.python_version}; only "
163
+ f"{IOS_SUPPORTED_PYTHON_VERSION} has a pinned iOS build",
164
+ )
165
+ )
166
+ if config.ios.development_team:
167
+ results.append(CheckResult("iOS development team", OK, config.ios.development_team))
168
+ else:
169
+ results.append(
170
+ CheckResult(
171
+ "iOS development team",
172
+ INFO,
173
+ "not set; required for device builds (set [ios].development_team)",
174
+ )
175
+ )
176
+ return results
177
+
178
+
179
+ def check_config(project_root: Path) -> tuple[Optional[AppConfig], List[CheckResult]]:
180
+ """Load and validate the project config, returning it with a result.
181
+
182
+ Args:
183
+ project_root: Directory expected to contain ``pythonnative.toml``.
184
+
185
+ Returns:
186
+ A tuple of the loaded config (or ``None``) and the check results.
187
+ """
188
+ results: List[CheckResult] = []
189
+ try:
190
+ config = AppConfig.load(project_root)
191
+ except ConfigError as exc:
192
+ results.append(CheckResult("pythonnative.toml", ERROR, str(exc).splitlines()[0]))
193
+ return None, results
194
+ results.append(CheckResult("pythonnative.toml", OK, f"{config.app_id} (v{config.version})"))
195
+ return config, results
196
+
197
+
198
+ def run_doctor(project_root: Path, *, platform: Optional[str] = None) -> List[CheckResult]:
199
+ """Run all diagnostics for ``project_root``.
200
+
201
+ Args:
202
+ project_root: The project directory.
203
+ platform: Restrict checks to ``"android"`` or ``"ios"``; ``None``
204
+ checks both.
205
+
206
+ Returns:
207
+ All check results in display order.
208
+ """
209
+ config, results = check_config(project_root)
210
+ results.extend(check_common())
211
+ if platform in (None, "android"):
212
+ results.extend(check_android(config))
213
+ if platform in (None, "ios"):
214
+ results.extend(check_ios(config))
215
+ return results
216
+
217
+
218
+ def worst_level(results: List[CheckResult]) -> str:
219
+ """Return the most severe level among ``results``.
220
+
221
+ Args:
222
+ results: The diagnostic results.
223
+
224
+ Returns:
225
+ ``"error"`` if any error, else ``"warn"`` if any warning, else
226
+ ``"ok"``.
227
+ """
228
+ levels = {result.level for result in results}
229
+ if ERROR in levels:
230
+ return ERROR
231
+ if WARN in levels:
232
+ return WARN
233
+ return OK
@@ -0,0 +1,247 @@
1
+ """App icon and splash image generation.
2
+
3
+ Given a single high-resolution source image per asset (a 1024x1024 icon
4
+ and an optional splash image), this module renders the per-platform,
5
+ per-density variants each toolchain expects:
6
+
7
+ - iOS: a single-size ``AppIcon.appiconset`` (Xcode resizes at build time)
8
+ and a ``Splash`` image set referenced by the generated launch screen.
9
+ - Android: ``mipmap-*`` launcher PNGs at every density (mdpi…xxxhdpi),
10
+ a circular round-icon variant, and a centered splash icon used by the
11
+ Android 12+ splash screen.
12
+
13
+ Image resizing uses [Pillow](https://python-pillow.org/), declared as the
14
+ ``[build]`` optional dependency. When Pillow isn't installed every
15
+ function degrades gracefully: it returns ``False`` (and the caller keeps
16
+ the template's default assets) so a missing optional dependency never
17
+ breaks a build. ``pn doctor`` reports whether Pillow is available.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import shutil
25
+ from pathlib import Path
26
+ from typing import Dict, List, Optional, Tuple
27
+
28
+ # Android launcher icon sizes (px) per density bucket.
29
+ ANDROID_LAUNCHER_DENSITIES: Dict[str, int] = {
30
+ "mdpi": 48,
31
+ "hdpi": 72,
32
+ "xhdpi": 96,
33
+ "xxhdpi": 144,
34
+ "xxxhdpi": 192,
35
+ }
36
+
37
+
38
+ def pillow_available() -> bool:
39
+ """Return whether Pillow can be imported.
40
+
41
+ Returns:
42
+ ``True`` if ``PIL.Image`` imports, else ``False``.
43
+ """
44
+ try:
45
+ import PIL.Image # noqa: F401
46
+ except Exception:
47
+ return False
48
+ return True
49
+
50
+
51
+ def _open_rgba(source: Path) -> "object":
52
+ from PIL import Image
53
+
54
+ img = Image.open(source).convert("RGBA")
55
+ return img
56
+
57
+
58
+ def _resized(img: "object", size: int) -> "object":
59
+ from PIL import Image
60
+
61
+ return img.resize((size, size), Image.LANCZOS)
62
+
63
+
64
+ def _circular(img: "object") -> "object":
65
+ """Return a copy of a square image masked to a circle."""
66
+ from PIL import Image, ImageDraw
67
+
68
+ size = img.size[0]
69
+ mask = Image.new("L", (size, size), 0)
70
+ draw = ImageDraw.Draw(mask)
71
+ draw.ellipse((0, 0, size, size), fill=255)
72
+ out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
73
+ out.paste(img, (0, 0), mask) # type: ignore[arg-type]
74
+ return out
75
+
76
+
77
+ def generate_ios_icons(source: Path, appiconset_dir: Path) -> bool:
78
+ """Generate a single-size iOS ``AppIcon.appiconset``.
79
+
80
+ Writes ``icon-1024.png`` (a flattened, opaque 1024x1024 image — the
81
+ App Store rejects icons with alpha) and a ``Contents.json`` that
82
+ declares it as the universal iOS app icon. Xcode derives every other
83
+ size at build time.
84
+
85
+ Args:
86
+ source: Path to the source icon image.
87
+ appiconset_dir: The ``AppIcon.appiconset`` directory to populate.
88
+
89
+ Returns:
90
+ ``True`` if icons were written, ``False`` if Pillow is missing.
91
+ """
92
+ if not pillow_available():
93
+ return False
94
+ from PIL import Image
95
+
96
+ appiconset_dir.mkdir(parents=True, exist_ok=True)
97
+ img = _open_rgba(source)
98
+ icon = _resized(img, 1024)
99
+ flattened = Image.new("RGB", (1024, 1024), (255, 255, 255))
100
+ flattened.paste(icon, (0, 0), icon) # type: ignore[arg-type]
101
+ flattened.save(appiconset_dir / "icon-1024.png", format="PNG")
102
+
103
+ contents = {
104
+ "images": [
105
+ {
106
+ "idiom": "universal",
107
+ "platform": "ios",
108
+ "size": "1024x1024",
109
+ "filename": "icon-1024.png",
110
+ }
111
+ ],
112
+ "info": {"author": "pythonnative", "version": 1},
113
+ }
114
+ (appiconset_dir / "Contents.json").write_text(json.dumps(contents, indent=2) + "\n", encoding="utf-8")
115
+ return True
116
+
117
+
118
+ def generate_android_icons(source: Path, res_dir: Path) -> bool:
119
+ """Generate Android launcher icons at every density.
120
+
121
+ Writes ``mipmap-<density>/ic_launcher.png`` and a circular
122
+ ``ic_launcher_round.png`` for each density bucket, and removes the
123
+ adaptive ``mipmap-anydpi-v26`` definitions so the generated PNGs are
124
+ used directly (otherwise the template's vector adaptive icon would
125
+ win on API 26+).
126
+
127
+ Args:
128
+ source: Path to the source icon image.
129
+ res_dir: The Android ``res`` directory.
130
+
131
+ Returns:
132
+ ``True`` if icons were written, ``False`` if Pillow is missing.
133
+ """
134
+ if not pillow_available():
135
+ return False
136
+
137
+ img = _open_rgba(source)
138
+ for density, size in ANDROID_LAUNCHER_DENSITIES.items():
139
+ mip_dir = res_dir / f"mipmap-{density}"
140
+ mip_dir.mkdir(parents=True, exist_ok=True)
141
+ square = _resized(img, size)
142
+ square.save(mip_dir / "ic_launcher.png", format="PNG")
143
+ round_icon = _circular(square)
144
+ round_icon.save(mip_dir / "ic_launcher_round.png", format="PNG")
145
+
146
+ # Drop adaptive XML icons so the raster mipmaps above are authoritative.
147
+ anydpi = res_dir / "mipmap-anydpi-v26"
148
+ if anydpi.is_dir():
149
+ shutil.rmtree(anydpi, ignore_errors=True)
150
+ return True
151
+
152
+
153
+ def generate_ios_splash(source: Path, imageset_dir: Path) -> bool:
154
+ """Generate an iOS ``Splash`` image set from a source image.
155
+
156
+ Args:
157
+ source: Path to the splash image.
158
+ imageset_dir: The ``Splash.imageset`` directory to populate.
159
+
160
+ Returns:
161
+ ``True`` if the image set was written, ``False`` if Pillow is
162
+ missing.
163
+ """
164
+ if not pillow_available():
165
+ return False
166
+
167
+ imageset_dir.mkdir(parents=True, exist_ok=True)
168
+ img = _open_rgba(source)
169
+ img.save(imageset_dir / "splash.png", format="PNG")
170
+ contents = {
171
+ "images": [{"idiom": "universal", "filename": "splash.png"}],
172
+ "info": {"author": "pythonnative", "version": 1},
173
+ }
174
+ (imageset_dir / "Contents.json").write_text(json.dumps(contents, indent=2) + "\n", encoding="utf-8")
175
+ return True
176
+
177
+
178
+ def generate_android_splash_icon(source: Path, dest: Path, size: int = 288) -> bool:
179
+ """Render the centered icon used by the Android 12+ splash screen.
180
+
181
+ The Android splash screen draws this image centered on the splash
182
+ background color. A transparent square is recommended; the default
183
+ size (288 dp at xxxhdpi → ~864 px) matches Google's guidance.
184
+
185
+ Args:
186
+ source: Path to the source splash (or icon) image.
187
+ dest: Destination PNG path.
188
+ size: Output edge length in pixels.
189
+
190
+ Returns:
191
+ ``True`` if the image was written, ``False`` if Pillow is missing.
192
+ """
193
+ if not pillow_available():
194
+ return False
195
+
196
+ dest.parent.mkdir(parents=True, exist_ok=True)
197
+ img = _open_rgba(source)
198
+ _resized(img, size).save(dest, format="PNG")
199
+ return True
200
+
201
+
202
+ def dominant_background_color(source: Path) -> Optional[str]:
203
+ """Best-effort estimate of a splash background color from an image.
204
+
205
+ Samples the image's corner pixels and returns the most common one as
206
+ a ``#RRGGBB`` hex string. Used as a default splash background when the
207
+ config doesn't specify one.
208
+
209
+ Args:
210
+ source: Path to the splash image.
211
+
212
+ Returns:
213
+ A hex color string, or ``None`` if Pillow is missing or the
214
+ sampling fails.
215
+ """
216
+ if not pillow_available():
217
+ return None
218
+ try:
219
+ img = _open_rgba(source)
220
+ width, height = img.size
221
+ corners: List[Tuple[int, int]] = [
222
+ (0, 0),
223
+ (width - 1, 0),
224
+ (0, height - 1),
225
+ (width - 1, height - 1),
226
+ ]
227
+ counts: Dict[Tuple[int, int, int], int] = {}
228
+ for x, y in corners:
229
+ r, g, b, _a = img.getpixel((x, y))
230
+ key = (r, g, b)
231
+ counts[key] = counts.get(key, 0) + 1
232
+ r, g, b = max(counts, key=lambda k: counts[k])
233
+ return f"#{r:02X}{g:02X}{b:02X}"
234
+ except Exception:
235
+ return None
236
+
237
+
238
+ def has_source(path: Optional[Path]) -> bool:
239
+ """Return whether ``path`` is a readable existing file.
240
+
241
+ Args:
242
+ path: A candidate asset path, or ``None``.
243
+
244
+ Returns:
245
+ ``True`` when the path is a file that exists on disk.
246
+ """
247
+ return bool(path and os.path.isfile(path))