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.
- pythonnative/__init__.py +1 -1
- pythonnative/cli/pn.py +450 -956
- pythonnative/hooks.py +30 -6
- pythonnative/native_views/android.py +22 -2
- 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 +285 -3
- pythonnative/screen.py +23 -27
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/METADATA +7 -2
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/RECORD +21 -12
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/top_level.txt +0 -0
pythonnative/cli/pn.py
CHANGED
|
@@ -1,80 +1,52 @@
|
|
|
1
|
-
"""`pn` CLI: scaffold, run, and
|
|
2
|
-
|
|
3
|
-
The console script `pn` (declared in `pyproject.toml`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
- `pn
|
|
7
|
-
- `pn preview [component]`: render the app in a desktop (Tkinter)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
"""`pn` CLI: scaffold, diagnose, preview, run, and build PythonNative apps.
|
|
2
|
+
|
|
3
|
+
The console script `pn` (declared in `pyproject.toml`) dispatches to:
|
|
4
|
+
|
|
5
|
+
- `pn init [name]`: scaffold a new project (``pythonnative.toml`` + ``app/``).
|
|
6
|
+
- `pn doctor [platform]`: diagnose the local toolchain and config.
|
|
7
|
+
- `pn preview [component]`: render the app in a desktop (Tkinter) window
|
|
8
|
+
with Fast Refresh — the fast inner dev loop, no device required.
|
|
9
|
+
- `pn run android|ios`: stage + build + install + launch on a device or
|
|
10
|
+
simulator, with optional on-device hot reload.
|
|
11
|
+
- `pn build android|ios`: produce standalone artifacts (signed APK/AAB,
|
|
12
|
+
or an iOS archive/IPA).
|
|
13
|
+
- `pn app-id android|ios`: print the resolved application/bundle id
|
|
14
|
+
(handy for scripts and CI).
|
|
12
15
|
- `pn clean`: remove the local `build/` directory.
|
|
13
16
|
|
|
14
|
-
The
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
The heavy lifting lives in the ``pythonnative.project`` package; this
|
|
18
|
+
module is a thin, side-effect-y shell that wires arguments to it and
|
|
19
|
+
handles the device-facing steps (simulator boot, log streaming, hot
|
|
20
|
+
reload) that can't be unit tested.
|
|
18
21
|
"""
|
|
19
22
|
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
20
25
|
import argparse
|
|
21
|
-
import hashlib
|
|
22
26
|
import json
|
|
23
27
|
import os
|
|
24
28
|
import re
|
|
25
29
|
import shutil
|
|
26
30
|
import subprocess
|
|
27
31
|
import sys
|
|
28
|
-
import sysconfig
|
|
29
32
|
import time
|
|
30
|
-
import
|
|
31
|
-
from importlib import resources
|
|
33
|
+
from pathlib import Path
|
|
32
34
|
from typing import Any, Dict, List, Optional
|
|
33
35
|
|
|
36
|
+
from ..project import builder as builder_mod
|
|
37
|
+
from ..project import doctor as doctor_mod
|
|
38
|
+
from ..project.android import collect_logcat_filters
|
|
39
|
+
from ..project.config import CONFIG_FILENAME, AppConfig, ConfigError, render_default_toml
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Creates `app/main.py`, `pythonnative.json`, `requirements.txt`,
|
|
39
|
-
and `.gitignore`. Refuses to overwrite existing files unless
|
|
40
|
-
`--force` is passed.
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
args: The parsed argparse namespace. Recognized attributes:
|
|
41
|
+
HOT_RELOAD_DEV_ROOT = "pythonnative_dev"
|
|
42
|
+
"""Subdirectory (under the app's writable storage) for hot-reload overlays."""
|
|
44
43
|
|
|
45
|
-
- `name` (`str`, optional): Project name (defaults to the
|
|
46
|
-
current directory name).
|
|
47
|
-
- `force` (`bool`): Overwrite existing files.
|
|
48
|
-
"""
|
|
49
|
-
project_name: str = getattr(args, "name", None) or os.path.basename(os.getcwd())
|
|
50
|
-
cwd: str = os.getcwd()
|
|
51
|
-
|
|
52
|
-
app_dir = os.path.join(cwd, "app")
|
|
53
|
-
config_path = os.path.join(cwd, "pythonnative.json")
|
|
54
|
-
requirements_path = os.path.join(cwd, "requirements.txt")
|
|
55
|
-
gitignore_path = os.path.join(cwd, ".gitignore")
|
|
56
|
-
|
|
57
|
-
# Prevent accidental overwrite unless --force is provided
|
|
58
|
-
if not getattr(args, "force", False):
|
|
59
|
-
exists = []
|
|
60
|
-
if os.path.exists(app_dir):
|
|
61
|
-
exists.append("app/")
|
|
62
|
-
if os.path.exists(config_path):
|
|
63
|
-
exists.append("pythonnative.json")
|
|
64
|
-
if os.path.exists(requirements_path):
|
|
65
|
-
exists.append("requirements.txt")
|
|
66
|
-
if os.path.exists(gitignore_path):
|
|
67
|
-
exists.append(".gitignore")
|
|
68
|
-
if exists:
|
|
69
|
-
print(f"Refusing to overwrite existing: {', '.join(exists)}. Use --force to overwrite.")
|
|
70
|
-
sys.exit(1)
|
|
71
44
|
|
|
72
|
-
|
|
45
|
+
# ======================================================================
|
|
46
|
+
# init
|
|
47
|
+
# ======================================================================
|
|
73
48
|
|
|
74
|
-
|
|
75
|
-
if not os.path.exists(main_py) or args.force:
|
|
76
|
-
with open(main_py, "w", encoding="utf-8") as f:
|
|
77
|
-
f.write("""import pythonnative as pn
|
|
49
|
+
_MAIN_TEMPLATE = """import pythonnative as pn
|
|
78
50
|
|
|
79
51
|
Stack = pn.create_stack_navigator()
|
|
80
52
|
|
|
@@ -113,261 +85,354 @@ def App():
|
|
|
113
85
|
Stack.Screen("Detail", component=DetailScreen, options={"title": "Detail"}),
|
|
114
86
|
)
|
|
115
87
|
)
|
|
116
|
-
"""
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
_GITIGNORE = "# PythonNative\n__pycache__/\n*.pyc\n.venv/\nbuild/\n.DS_Store\n"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _app_id_from_name(name: str) -> str:
|
|
94
|
+
slug = re.sub(r"[^a-z0-9_]", "", name.lower())
|
|
95
|
+
if not slug or not slug[0].isalpha():
|
|
96
|
+
slug = "app" + slug
|
|
97
|
+
return f"com.example.{slug}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def init_project(args: argparse.Namespace) -> None:
|
|
101
|
+
"""Scaffold a new PythonNative project in the current directory.
|
|
129
102
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
with open(requirements_path, "w", encoding="utf-8") as f:
|
|
133
|
-
f.write("")
|
|
103
|
+
Creates ``app/main.py``, ``pythonnative.toml``, and ``.gitignore``.
|
|
104
|
+
Refuses to overwrite existing files unless ``--force`` is passed.
|
|
134
105
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
106
|
+
Args:
|
|
107
|
+
args: Parsed namespace with ``name`` (optional) and ``force``.
|
|
108
|
+
"""
|
|
109
|
+
cwd = Path.cwd()
|
|
110
|
+
project_name: str = getattr(args, "name", None) or cwd.name
|
|
111
|
+
force: bool = getattr(args, "force", False)
|
|
112
|
+
|
|
113
|
+
app_dir = cwd / "app"
|
|
114
|
+
config_path = cwd / CONFIG_FILENAME
|
|
115
|
+
gitignore_path = cwd / ".gitignore"
|
|
116
|
+
|
|
117
|
+
if not force:
|
|
118
|
+
existing = [
|
|
119
|
+
label
|
|
120
|
+
for label, path in (("app/", app_dir), (CONFIG_FILENAME, config_path), (".gitignore", gitignore_path))
|
|
121
|
+
if path.exists()
|
|
122
|
+
]
|
|
123
|
+
if existing:
|
|
124
|
+
print(f"Refusing to overwrite existing: {', '.join(existing)}. Use --force to overwrite.")
|
|
125
|
+
sys.exit(1)
|
|
140
126
|
|
|
141
|
-
|
|
127
|
+
app_dir.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
main_py = app_dir / "main.py"
|
|
129
|
+
if force or not main_py.exists():
|
|
130
|
+
main_py.write_text(_MAIN_TEMPLATE, encoding="utf-8")
|
|
142
131
|
|
|
132
|
+
config_path.write_text(
|
|
133
|
+
render_default_toml(name=project_name, app_id=_app_id_from_name(project_name)),
|
|
134
|
+
encoding="utf-8",
|
|
135
|
+
)
|
|
136
|
+
if force or not gitignore_path.exists():
|
|
137
|
+
gitignore_path.write_text(_GITIGNORE, encoding="utf-8")
|
|
143
138
|
|
|
144
|
-
|
|
145
|
-
"
|
|
146
|
-
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
147
|
-
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
139
|
+
print(f"Initialized PythonNative project in {cwd}.")
|
|
140
|
+
print("Next: pn preview (desktop) | pn run android | pn run ios")
|
|
148
141
|
|
|
149
142
|
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
# ======================================================================
|
|
144
|
+
# doctor / app-id
|
|
145
|
+
# ======================================================================
|
|
152
146
|
|
|
153
|
-
Search order:
|
|
154
147
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
3. Installed package data via `importlib.resources`.
|
|
158
|
-
4. `sysconfig` data/site directories (last resort).
|
|
148
|
+
def doctor_command(args: argparse.Namespace) -> None:
|
|
149
|
+
"""Run toolchain/config diagnostics and exit non-zero on errors.
|
|
159
150
|
|
|
160
151
|
Args:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
152
|
+
args: Parsed namespace with optional ``platform``.
|
|
153
|
+
"""
|
|
154
|
+
platform: Optional[str] = getattr(args, "platform", None)
|
|
155
|
+
results = doctor_mod.run_doctor(Path.cwd(), platform=platform)
|
|
156
|
+
print("PythonNative doctor\n")
|
|
157
|
+
for result in results:
|
|
158
|
+
print(result.format())
|
|
159
|
+
level = doctor_mod.worst_level(results)
|
|
160
|
+
print()
|
|
161
|
+
if level == doctor_mod.ERROR:
|
|
162
|
+
print("Found problems that will block builds. Address the [x] items above.")
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
if level == doctor_mod.WARN:
|
|
165
|
+
print("Ready, with warnings. Review the [!] items above.")
|
|
166
|
+
else:
|
|
167
|
+
print("Everything looks good.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def app_id_command(args: argparse.Namespace) -> None:
|
|
171
|
+
"""Print the resolved application id (Android) or bundle id (iOS).
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
args: Parsed namespace with ``platform``.
|
|
175
|
+
"""
|
|
176
|
+
config = _load_config_or_exit()
|
|
177
|
+
print(config.application_id if args.platform == "android" else config.bundle_id)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ======================================================================
|
|
181
|
+
# preview
|
|
182
|
+
# ======================================================================
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def preview_project(args: argparse.Namespace) -> None:
|
|
186
|
+
"""Render the project in a desktop preview window (Tkinter).
|
|
165
187
|
|
|
166
|
-
|
|
167
|
-
|
|
188
|
+
Re-execs under ``PN_PLATFORM=desktop`` so every module binds to the
|
|
189
|
+
Tkinter backend, then hands off to ``pythonnative.preview.run_preview``.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
args: Parsed namespace (``component``, ``width``, ``height``,
|
|
193
|
+
``title``, ``no_hot_reload``).
|
|
168
194
|
"""
|
|
169
|
-
|
|
195
|
+
if os.environ.get("PN_PLATFORM") != "desktop":
|
|
196
|
+
try:
|
|
197
|
+
completed = subprocess.run(
|
|
198
|
+
[sys.executable, "-m", "pythonnative.cli.pn", *sys.argv[1:]],
|
|
199
|
+
env={**os.environ, "PN_PLATFORM": "desktop"},
|
|
200
|
+
)
|
|
201
|
+
except KeyboardInterrupt:
|
|
202
|
+
sys.exit(130)
|
|
203
|
+
sys.exit(completed.returncode)
|
|
204
|
+
|
|
205
|
+
project_dir = Path.cwd()
|
|
206
|
+
component: Optional[str] = getattr(args, "component", None)
|
|
207
|
+
if not component:
|
|
208
|
+
component = _preview_entry(project_dir)
|
|
170
209
|
|
|
171
|
-
# Dev-first: prefer local source templates if running from a checkout (avoid stale packaged data)
|
|
172
210
|
try:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if os.path.isdir(candidate_dir):
|
|
184
|
-
_copy_dir(candidate_dir, dest_path)
|
|
185
|
-
return
|
|
186
|
-
except Exception:
|
|
187
|
-
pass
|
|
211
|
+
from pythonnative.preview import run_preview
|
|
212
|
+
except Exception as exc: # pragma: no cover - environment dependent
|
|
213
|
+
print(f"Error: could not start the desktop preview: {exc}")
|
|
214
|
+
print(
|
|
215
|
+
"The desktop preview needs Tkinter (Python's standard GUI toolkit).\n"
|
|
216
|
+
"On macOS: brew install python-tk\n"
|
|
217
|
+
"On Debian/Ubuntu: sudo apt-get install python3-tk\n"
|
|
218
|
+
"On Windows: reinstall Python with the 'tcl/tk' option checked."
|
|
219
|
+
)
|
|
220
|
+
sys.exit(1)
|
|
188
221
|
|
|
189
|
-
|
|
222
|
+
print(f"Starting PythonNative preview for {component} (Ctrl+C or close the window to stop).")
|
|
190
223
|
try:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
224
|
+
run_preview(
|
|
225
|
+
component,
|
|
226
|
+
project_root=str(project_dir),
|
|
227
|
+
width=getattr(args, "width", 390),
|
|
228
|
+
height=getattr(args, "height", 844),
|
|
229
|
+
title=getattr(args, "title", "PythonNative Preview"),
|
|
230
|
+
hot_reload=not getattr(args, "no_hot_reload", False),
|
|
231
|
+
)
|
|
232
|
+
except RuntimeError as exc:
|
|
233
|
+
print(f"Error: {exc}")
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
199
236
|
|
|
200
|
-
|
|
237
|
+
def _preview_entry(project_dir: Path) -> str:
|
|
201
238
|
try:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
data_paths.get("purelib"),
|
|
206
|
-
data_paths.get("platlib"),
|
|
207
|
-
]
|
|
208
|
-
for base in filter(None, search_bases):
|
|
209
|
-
candidate_dir = os.path.join(base, "pythonnative", "templates", template_dir)
|
|
210
|
-
if os.path.isdir(candidate_dir):
|
|
211
|
-
_copy_dir(candidate_dir, dest_path)
|
|
212
|
-
return
|
|
213
|
-
except Exception:
|
|
214
|
-
pass
|
|
239
|
+
return AppConfig.load(project_dir).entry_module
|
|
240
|
+
except ConfigError:
|
|
241
|
+
return "app.main"
|
|
215
242
|
|
|
216
|
-
raise FileNotFoundError(f"Could not find bundled template directory {template_dir}. Ensure templates are packaged.")
|
|
217
243
|
|
|
244
|
+
# ======================================================================
|
|
245
|
+
# run
|
|
246
|
+
# ======================================================================
|
|
218
247
|
|
|
219
|
-
def _github_json(url: str) -> Any:
|
|
220
|
-
"""Fetch a GitHub JSON endpoint, optionally authenticated.
|
|
221
248
|
|
|
222
|
-
|
|
223
|
-
the
|
|
224
|
-
"""
|
|
225
|
-
headers: dict[str, str] = {"User-Agent": "pythonnative-cli"}
|
|
226
|
-
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
227
|
-
if token:
|
|
228
|
-
headers["Authorization"] = f"Bearer {token}"
|
|
229
|
-
req = urllib.request.Request(url, headers=headers)
|
|
230
|
-
with urllib.request.urlopen(req) as r:
|
|
231
|
-
return json.loads(r.read().decode("utf-8"))
|
|
249
|
+
def run_project(args: argparse.Namespace) -> None:
|
|
250
|
+
"""Stage, build, install, and launch the app on a device/simulator.
|
|
232
251
|
|
|
252
|
+
Args:
|
|
253
|
+
args: Parsed namespace (``platform``, ``prepare_only``,
|
|
254
|
+
``hot_reload``, ``no_logs``).
|
|
255
|
+
"""
|
|
256
|
+
platform: str = args.platform
|
|
257
|
+
prepare_only: bool = getattr(args, "prepare_only", False)
|
|
258
|
+
hot_reload: bool = getattr(args, "hot_reload", False)
|
|
259
|
+
show_logs: bool = not getattr(args, "no_logs", False)
|
|
233
260
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
) -> Optional[str]:
|
|
237
|
-
"""Resolve a download URL for a `Python-Apple-support` release asset.
|
|
261
|
+
config = _load_config_or_exit()
|
|
262
|
+
builder = builder_mod.Builder(config, log=print)
|
|
238
263
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
264
|
+
try:
|
|
265
|
+
prepared = builder.prepare(platform)
|
|
266
|
+
except builder_mod.BuildError as exc:
|
|
267
|
+
print(f"Error: {exc}")
|
|
268
|
+
sys.exit(1)
|
|
242
269
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
preferred_name: Exact filename to prefer when multiple matching
|
|
247
|
-
assets exist.
|
|
270
|
+
if prepare_only:
|
|
271
|
+
print(f"Prepared {platform} project in {prepared.project_dir} (prepare-only).")
|
|
272
|
+
return
|
|
248
273
|
|
|
249
|
-
Returns:
|
|
250
|
-
A `browser_download_url` string, or `None` if the GitHub API
|
|
251
|
-
call fails or no matching asset is found.
|
|
252
|
-
"""
|
|
253
274
|
try:
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
# Fallback: any matching Python-{version}-iOS-support*.tar.gz (take first encountered)
|
|
262
|
-
needle = f"Python-{py_major_minor}-iOS-support"
|
|
263
|
-
for rel in releases:
|
|
264
|
-
for a in rel.get("assets", []) or []:
|
|
265
|
-
name = a.get("name") or ""
|
|
266
|
-
if needle in name and name.endswith(".tar.gz"):
|
|
267
|
-
return a.get("browser_download_url")
|
|
268
|
-
except Exception:
|
|
269
|
-
pass
|
|
270
|
-
return None
|
|
275
|
+
if platform == "android":
|
|
276
|
+
_run_android(builder, prepared, hot_reload=hot_reload, show_logs=show_logs)
|
|
277
|
+
else:
|
|
278
|
+
_run_ios(builder, prepared, hot_reload=hot_reload, show_logs=show_logs)
|
|
279
|
+
except builder_mod.BuildError as exc:
|
|
280
|
+
print(f"Error: {exc}")
|
|
281
|
+
sys.exit(1)
|
|
271
282
|
|
|
283
|
+
if hot_reload:
|
|
284
|
+
_run_hot_reload(
|
|
285
|
+
platform,
|
|
286
|
+
str(config.project_root),
|
|
287
|
+
str(prepared.build_dir),
|
|
288
|
+
app_id=config.application_id,
|
|
289
|
+
bundle_id=config.bundle_id,
|
|
290
|
+
show_logs=show_logs,
|
|
291
|
+
)
|
|
272
292
|
|
|
273
|
-
def create_android_project(project_name: str, destination: str) -> None:
|
|
274
|
-
"""Stage the bundled Android template into `destination`.
|
|
275
293
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
294
|
+
def _run_android(
|
|
295
|
+
builder: builder_mod.Builder,
|
|
296
|
+
prepared: builder_mod.PreparedProject,
|
|
297
|
+
*,
|
|
298
|
+
hot_reload: bool,
|
|
299
|
+
show_logs: bool,
|
|
300
|
+
) -> None:
|
|
301
|
+
builder.install_android_debug(prepared)
|
|
302
|
+
_clear_android_hot_reload_overlay(prepared.app_id)
|
|
303
|
+
subprocess.run(
|
|
304
|
+
["adb", "shell", "am", "start", "-n", f"{prepared.app_id}/.MainActivity"],
|
|
305
|
+
check=True,
|
|
306
|
+
)
|
|
307
|
+
if show_logs and not hot_reload:
|
|
308
|
+
proc = _start_android_log_stream()
|
|
309
|
+
if proc is not None:
|
|
310
|
+
try:
|
|
311
|
+
proc.wait()
|
|
312
|
+
except KeyboardInterrupt:
|
|
313
|
+
print()
|
|
314
|
+
_terminate_subprocess(proc)
|
|
315
|
+
print("Stopped log streaming.")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _run_ios(
|
|
319
|
+
builder: builder_mod.Builder,
|
|
320
|
+
prepared: builder_mod.PreparedProject,
|
|
321
|
+
*,
|
|
322
|
+
hot_reload: bool,
|
|
323
|
+
show_logs: bool,
|
|
324
|
+
) -> None:
|
|
325
|
+
app_path = builder.build_ios_simulator(prepared)
|
|
326
|
+
udid = _select_ios_simulator()
|
|
327
|
+
if udid is None:
|
|
328
|
+
print("No available iOS Simulators found; open the project in Xcode to run.")
|
|
329
|
+
return
|
|
330
|
+
subprocess.run(["xcrun", "simctl", "boot", udid], check=False, capture_output=True)
|
|
331
|
+
subprocess.run(["xcrun", "simctl", "install", udid, str(app_path)], check=False)
|
|
332
|
+
_clear_ios_hot_reload_overlay(prepared.app_id)
|
|
333
|
+
|
|
334
|
+
if hot_reload:
|
|
335
|
+
# The hot-reload loop launches the app with a console PTY itself.
|
|
336
|
+
return
|
|
337
|
+
if show_logs:
|
|
338
|
+
env = {**os.environ, "SIMCTL_CHILD_PYTHONUNBUFFERED": "1"}
|
|
339
|
+
print("Launched iOS app on Simulator. Streaming logs (Ctrl+C to stop)...")
|
|
340
|
+
try:
|
|
341
|
+
subprocess.run(
|
|
342
|
+
["xcrun", "simctl", "launch", "--console-pty", "--terminate-running-process", udid, prepared.app_id],
|
|
343
|
+
env=env,
|
|
344
|
+
check=False,
|
|
345
|
+
)
|
|
346
|
+
except KeyboardInterrupt:
|
|
347
|
+
print()
|
|
348
|
+
subprocess.run(["xcrun", "simctl", "terminate", udid, prepared.app_id], check=False, capture_output=True)
|
|
349
|
+
print("Stopped log streaming.")
|
|
350
|
+
else:
|
|
351
|
+
subprocess.run(["xcrun", "simctl", "launch", udid, prepared.app_id], check=False)
|
|
352
|
+
print("Launched iOS app on Simulator.")
|
|
282
353
|
|
|
283
354
|
|
|
284
|
-
|
|
285
|
-
|
|
355
|
+
# ======================================================================
|
|
356
|
+
# build
|
|
357
|
+
# ======================================================================
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def build_project(args: argparse.Namespace) -> None:
|
|
361
|
+
"""Build standalone, distributable artifacts for ``platform``.
|
|
286
362
|
|
|
287
363
|
Args:
|
|
288
|
-
|
|
289
|
-
template uses fixed bundle IDs).
|
|
290
|
-
destination: Directory to receive the staged project.
|
|
364
|
+
args: Parsed namespace (``platform``, ``debug``).
|
|
291
365
|
"""
|
|
292
|
-
|
|
366
|
+
platform: str = args.platform
|
|
367
|
+
debug: bool = getattr(args, "debug", False)
|
|
293
368
|
|
|
369
|
+
config = _load_config_or_exit()
|
|
370
|
+
builder = builder_mod.Builder(config, log=print)
|
|
294
371
|
|
|
295
|
-
|
|
296
|
-
|
|
372
|
+
try:
|
|
373
|
+
prepared = builder.prepare(platform)
|
|
374
|
+
if platform == "android":
|
|
375
|
+
artifacts = builder.build_android(prepared, debug=debug)
|
|
376
|
+
else:
|
|
377
|
+
if debug:
|
|
378
|
+
app_path = builder.build_ios_simulator(prepared)
|
|
379
|
+
artifacts = builder_mod.BuildArtifacts(paths=[app_path])
|
|
380
|
+
else:
|
|
381
|
+
artifacts = builder.build_ios_archive(prepared)
|
|
382
|
+
except builder_mod.BuildError as exc:
|
|
383
|
+
print(f"Error: {exc}")
|
|
384
|
+
sys.exit(1)
|
|
297
385
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return json.load(f)
|
|
305
|
-
return {}
|
|
386
|
+
if not artifacts.paths:
|
|
387
|
+
print("Build completed, but no artifacts were found. Check the build output above.")
|
|
388
|
+
return
|
|
389
|
+
print("\nBuilt artifacts:")
|
|
390
|
+
for path in artifacts.paths:
|
|
391
|
+
print(f" {path}")
|
|
306
392
|
|
|
307
393
|
|
|
308
|
-
|
|
309
|
-
|
|
394
|
+
# ======================================================================
|
|
395
|
+
# clean
|
|
396
|
+
# ======================================================================
|
|
310
397
|
|
|
311
|
-
Exits with an error if `pythonnative` is listed: the CLI bundles
|
|
312
|
-
it directly, so it must not be installed separately via pip or
|
|
313
|
-
Chaquopy.
|
|
314
398
|
|
|
315
|
-
|
|
316
|
-
|
|
399
|
+
def clean_project(args: argparse.Namespace) -> None:
|
|
400
|
+
"""Remove the local ``build/`` directory.
|
|
317
401
|
|
|
318
|
-
|
|
319
|
-
|
|
402
|
+
Args:
|
|
403
|
+
args: Parsed namespace (unused).
|
|
320
404
|
"""
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
stripped = line.strip()
|
|
328
|
-
if not stripped or stripped.startswith("#") or stripped.startswith("-"):
|
|
329
|
-
continue
|
|
330
|
-
pkg_name = re.split(r"[\[><=!;]", stripped)[0].strip()
|
|
331
|
-
if pkg_name.lower().replace("-", "_") == "pythonnative":
|
|
332
|
-
print(
|
|
333
|
-
"Error: 'pythonnative' must not be in requirements.txt.\n"
|
|
334
|
-
"The pn CLI automatically bundles the installed pythonnative into your app.\n"
|
|
335
|
-
"requirements.txt is for third-party packages only (e.g. humanize, requests).\n"
|
|
336
|
-
"Remove the pythonnative line from requirements.txt and try again."
|
|
337
|
-
)
|
|
338
|
-
sys.exit(1)
|
|
339
|
-
result.append(stripped)
|
|
340
|
-
return result
|
|
405
|
+
build_dir = Path.cwd() / "build"
|
|
406
|
+
if build_dir.exists():
|
|
407
|
+
shutil.rmtree(build_dir)
|
|
408
|
+
print("Removed build/ directory.")
|
|
409
|
+
else:
|
|
410
|
+
print("No build/ directory to remove.")
|
|
341
411
|
|
|
342
412
|
|
|
343
|
-
|
|
344
|
-
|
|
413
|
+
# ======================================================================
|
|
414
|
+
# Config helpers
|
|
415
|
+
# ======================================================================
|
|
345
416
|
|
|
346
|
-
ANDROID_LOGCAT_FILTERS: list[str] = [
|
|
347
|
-
"python.stdout:V",
|
|
348
|
-
"python.stderr:V",
|
|
349
|
-
"MainActivity:V",
|
|
350
|
-
"ScreenFragment:V",
|
|
351
|
-
"Navigator:V",
|
|
352
|
-
"PythonNative:V",
|
|
353
|
-
"AndroidRuntime:E",
|
|
354
|
-
"System.err:W",
|
|
355
|
-
"*:S",
|
|
356
|
-
]
|
|
357
417
|
|
|
358
|
-
|
|
418
|
+
def _load_config_or_exit(project_dir: Optional[Path] = None) -> AppConfig:
|
|
419
|
+
try:
|
|
420
|
+
return AppConfig.load(project_dir or Path.cwd())
|
|
421
|
+
except ConfigError as exc:
|
|
422
|
+
print(f"Error: {exc}")
|
|
423
|
+
sys.exit(1)
|
|
359
424
|
|
|
360
425
|
|
|
361
|
-
|
|
362
|
-
|
|
426
|
+
# ======================================================================
|
|
427
|
+
# Device log streaming
|
|
428
|
+
# ======================================================================
|
|
363
429
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
430
|
+
|
|
431
|
+
def _start_android_log_stream() -> Optional[subprocess.Popen]:
|
|
432
|
+
"""Clear logcat and stream Python-relevant tags to the terminal.
|
|
367
433
|
|
|
368
434
|
Returns:
|
|
369
|
-
The
|
|
370
|
-
unavailable on `PATH`.
|
|
435
|
+
The ``adb logcat`` process, or ``None`` if ``adb`` is missing.
|
|
371
436
|
"""
|
|
372
437
|
try:
|
|
373
438
|
subprocess.run(["adb", "logcat", "-c"], check=False, capture_output=True)
|
|
@@ -375,7 +440,7 @@ def _start_android_log_stream() -> Optional[subprocess.Popen]:
|
|
|
375
440
|
print("Note: 'adb' not found on PATH; skipping log streaming.")
|
|
376
441
|
return None
|
|
377
442
|
try:
|
|
378
|
-
proc = subprocess.Popen(["adb", "logcat", *
|
|
443
|
+
proc = subprocess.Popen(["adb", "logcat", *collect_logcat_filters()])
|
|
379
444
|
except FileNotFoundError:
|
|
380
445
|
return None
|
|
381
446
|
print("Streaming Python logs from device (Ctrl+C to stop)...")
|
|
@@ -383,11 +448,7 @@ def _start_android_log_stream() -> Optional[subprocess.Popen]:
|
|
|
383
448
|
|
|
384
449
|
|
|
385
450
|
def _booted_ios_udid() -> Optional[str]:
|
|
386
|
-
"""Return a booted iOS Simulator's UDID, or
|
|
387
|
-
|
|
388
|
-
Used by `_start_ios_log_stream` so the hot-reload path doesn't
|
|
389
|
-
need to thread the UDID through from the install step.
|
|
390
|
-
"""
|
|
451
|
+
"""Return a booted iOS Simulator's UDID, or ``None`` if none is booted."""
|
|
391
452
|
try:
|
|
392
453
|
result = subprocess.run(
|
|
393
454
|
["xcrun", "simctl", "list", "devices", "booted", "--json"],
|
|
@@ -403,47 +464,57 @@ def _booted_ios_udid() -> Optional[str]:
|
|
|
403
464
|
return None
|
|
404
465
|
for _runtime, devices in (data.get("devices") or {}).items():
|
|
405
466
|
for device in devices or []:
|
|
406
|
-
if device.get("state") == "Booted":
|
|
407
|
-
|
|
408
|
-
if udid:
|
|
409
|
-
return str(udid)
|
|
467
|
+
if device.get("state") == "Booted" and device.get("udid"):
|
|
468
|
+
return str(device["udid"])
|
|
410
469
|
return None
|
|
411
470
|
|
|
412
471
|
|
|
413
|
-
def
|
|
472
|
+
def _select_ios_simulator() -> Optional[str]:
|
|
473
|
+
"""Return a simulator UDID to target (booted first, else an iPhone)."""
|
|
474
|
+
booted = _booted_ios_udid()
|
|
475
|
+
if booted:
|
|
476
|
+
return booted
|
|
477
|
+
try:
|
|
478
|
+
result = subprocess.run(
|
|
479
|
+
["xcrun", "simctl", "list", "devices", "available", "--json"],
|
|
480
|
+
check=False,
|
|
481
|
+
capture_output=True,
|
|
482
|
+
text=True,
|
|
483
|
+
)
|
|
484
|
+
except FileNotFoundError:
|
|
485
|
+
return None
|
|
486
|
+
try:
|
|
487
|
+
data = json.loads(result.stdout or "{}")
|
|
488
|
+
except json.JSONDecodeError:
|
|
489
|
+
return None
|
|
490
|
+
devices: List[Dict[str, Any]] = [d for lst in (data.get("devices") or {}).values() for d in (lst or [])]
|
|
491
|
+
for device in devices:
|
|
492
|
+
if "iphone 15" in (device.get("name") or "").lower() and device.get("isAvailable"):
|
|
493
|
+
return device.get("udid")
|
|
494
|
+
for device in devices:
|
|
495
|
+
if device.get("isAvailable") and (device.get("name") or "").lower().startswith("iphone"):
|
|
496
|
+
return device.get("udid")
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _start_ios_log_stream(bundle_id: str) -> Optional[subprocess.Popen]:
|
|
414
501
|
"""Re-launch the iOS app with a console PTY so its stdio streams here.
|
|
415
502
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
terminal to the app's stderr, which is where Python ``print()``
|
|
419
|
-
output is routed (see `pythonnative._ios_log`) and where Swift
|
|
420
|
-
``NSLog`` calls land. Unlike ``log stream``, this *only*
|
|
421
|
-
surfaces what the app writes itself — none of UIKit's verbose
|
|
422
|
-
``os_log`` chatter.
|
|
503
|
+
Args:
|
|
504
|
+
bundle_id: The app's bundle identifier.
|
|
423
505
|
|
|
424
506
|
Returns:
|
|
425
|
-
The launched
|
|
426
|
-
terminal), or `None` when no simulator is booted or
|
|
427
|
-
`xcrun` is unavailable.
|
|
507
|
+
The launched process, or ``None`` when no simulator is booted.
|
|
428
508
|
"""
|
|
429
509
|
udid = _booted_ios_udid()
|
|
430
510
|
if udid is None:
|
|
431
511
|
print("Note: no booted iOS Simulator found; skipping log streaming.")
|
|
432
512
|
return None
|
|
433
|
-
|
|
434
|
-
sim_env["SIMCTL_CHILD_PYTHONUNBUFFERED"] = "1"
|
|
513
|
+
env = {**os.environ, "SIMCTL_CHILD_PYTHONUNBUFFERED": "1"}
|
|
435
514
|
try:
|
|
436
515
|
proc = subprocess.Popen(
|
|
437
|
-
[
|
|
438
|
-
|
|
439
|
-
"simctl",
|
|
440
|
-
"launch",
|
|
441
|
-
"--console-pty",
|
|
442
|
-
"--terminate-running-process",
|
|
443
|
-
udid,
|
|
444
|
-
IOS_BUNDLE_ID,
|
|
445
|
-
],
|
|
446
|
-
env=sim_env,
|
|
516
|
+
["xcrun", "simctl", "launch", "--console-pty", "--terminate-running-process", udid, bundle_id],
|
|
517
|
+
env=env,
|
|
447
518
|
)
|
|
448
519
|
except FileNotFoundError:
|
|
449
520
|
print("Note: 'xcrun' not found on PATH; skipping iOS log streaming.")
|
|
@@ -453,13 +524,8 @@ def _start_ios_log_stream() -> Optional[subprocess.Popen]:
|
|
|
453
524
|
|
|
454
525
|
|
|
455
526
|
def _terminate_subprocess(proc: Optional[subprocess.Popen]) -> None:
|
|
456
|
-
"""Politely stop a subprocess, escalating to
|
|
457
|
-
|
|
458
|
-
A no-op when `proc` is `None` or has already exited.
|
|
459
|
-
"""
|
|
460
|
-
if proc is None:
|
|
461
|
-
return
|
|
462
|
-
if proc.poll() is not None:
|
|
527
|
+
"""Politely stop a subprocess, escalating to ``SIGKILL`` if needed."""
|
|
528
|
+
if proc is None or proc.poll() is not None:
|
|
463
529
|
return
|
|
464
530
|
proc.terminate()
|
|
465
531
|
try:
|
|
@@ -468,13 +534,27 @@ def _terminate_subprocess(proc: Optional[subprocess.Popen]) -> None:
|
|
|
468
534
|
proc.kill()
|
|
469
535
|
|
|
470
536
|
|
|
537
|
+
# ======================================================================
|
|
538
|
+
# Hot reload
|
|
539
|
+
# ======================================================================
|
|
540
|
+
|
|
541
|
+
|
|
471
542
|
def _hot_reload_manifest_payload(
|
|
472
543
|
changed_files: List[str],
|
|
473
544
|
project_dir: str,
|
|
474
545
|
*,
|
|
475
546
|
version: Optional[str] = None,
|
|
476
547
|
) -> Dict[str, Any]:
|
|
477
|
-
"""Build the reload manifest consumed by the running app.
|
|
548
|
+
"""Build the reload manifest consumed by the running app.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
changed_files: Absolute paths to changed source files.
|
|
552
|
+
project_dir: The project root, used to relativize paths.
|
|
553
|
+
version: Optional explicit version stamp (defaults to a timestamp).
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
The manifest dict (``version``, ``files``, ``modules``).
|
|
557
|
+
"""
|
|
478
558
|
from pythonnative.hot_reload import ModuleReloader
|
|
479
559
|
|
|
480
560
|
rel_files = sorted(os.path.relpath(path, project_dir) for path in changed_files)
|
|
@@ -490,17 +570,17 @@ def _write_hot_reload_manifest(changed_files: List[str], project_dir: str, build
|
|
|
490
570
|
manifest_dir = os.path.join(build_dir, "hot_reload")
|
|
491
571
|
os.makedirs(manifest_dir, exist_ok=True)
|
|
492
572
|
manifest_path = os.path.join(manifest_dir, "reload.json")
|
|
493
|
-
with open(manifest_path, "w", encoding="utf-8") as
|
|
494
|
-
json.dump(_hot_reload_manifest_payload(changed_files, project_dir),
|
|
573
|
+
with open(manifest_path, "w", encoding="utf-8") as handle:
|
|
574
|
+
json.dump(_hot_reload_manifest_payload(changed_files, project_dir), handle)
|
|
495
575
|
return manifest_path
|
|
496
576
|
|
|
497
577
|
|
|
498
578
|
def _android_hot_reload_dest(rel_path: str) -> str:
|
|
499
|
-
"""Return a
|
|
579
|
+
"""Return a ``run-as`` relative destination for an app source file."""
|
|
500
580
|
return os.path.join("files", HOT_RELOAD_DEV_ROOT, rel_path)
|
|
501
581
|
|
|
502
582
|
|
|
503
|
-
def _push_android_hot_reload_file(local_path: str, rel_path: str) -> bool:
|
|
583
|
+
def _push_android_hot_reload_file(local_path: str, rel_path: str, app_id: str) -> bool:
|
|
504
584
|
"""Push one file into the Android app's writable hot-reload overlay."""
|
|
505
585
|
tmp_path = f"/data/local/tmp/pythonnative-hot-reload-{os.getpid()}-{os.path.basename(local_path)}"
|
|
506
586
|
dest_path = _android_hot_reload_dest(rel_path)
|
|
@@ -508,25 +588,19 @@ def _push_android_hot_reload_file(local_path: str, rel_path: str) -> bool:
|
|
|
508
588
|
push = subprocess.run(["adb", "push", local_path, tmp_path], check=False, capture_output=True)
|
|
509
589
|
if push.returncode != 0:
|
|
510
590
|
return False
|
|
511
|
-
subprocess.run(
|
|
512
|
-
["adb", "shell", "run-as", ANDROID_PACKAGE_ID, "mkdir", "-p", dest_dir],
|
|
513
|
-
check=False,
|
|
514
|
-
capture_output=True,
|
|
515
|
-
)
|
|
591
|
+
subprocess.run(["adb", "shell", "run-as", app_id, "mkdir", "-p", dest_dir], check=False, capture_output=True)
|
|
516
592
|
copy = subprocess.run(
|
|
517
|
-
["adb", "shell", "run-as",
|
|
518
|
-
check=False,
|
|
519
|
-
capture_output=True,
|
|
593
|
+
["adb", "shell", "run-as", app_id, "cp", tmp_path, dest_path], check=False, capture_output=True
|
|
520
594
|
)
|
|
521
595
|
subprocess.run(["adb", "shell", "rm", "-f", tmp_path], check=False, capture_output=True)
|
|
522
596
|
return copy.returncode == 0
|
|
523
597
|
|
|
524
598
|
|
|
525
|
-
def _ios_data_container() -> Optional[str]:
|
|
599
|
+
def _ios_data_container(bundle_id: str) -> Optional[str]:
|
|
526
600
|
"""Return the booted simulator's app data container, if available."""
|
|
527
601
|
try:
|
|
528
602
|
result = subprocess.run(
|
|
529
|
-
["xcrun", "simctl", "get_app_container", "booted",
|
|
603
|
+
["xcrun", "simctl", "get_app_container", "booted", bundle_id, "data"],
|
|
530
604
|
check=False,
|
|
531
605
|
capture_output=True,
|
|
532
606
|
text=True,
|
|
@@ -535,13 +609,12 @@ def _ios_data_container() -> Optional[str]:
|
|
|
535
609
|
return None
|
|
536
610
|
if result.returncode != 0:
|
|
537
611
|
return None
|
|
538
|
-
|
|
539
|
-
return container or None
|
|
612
|
+
return result.stdout.strip() or None
|
|
540
613
|
|
|
541
614
|
|
|
542
|
-
def _push_ios_hot_reload_file(local_path: str, rel_path: str) -> bool:
|
|
615
|
+
def _push_ios_hot_reload_file(local_path: str, rel_path: str, bundle_id: str) -> bool:
|
|
543
616
|
"""Copy one file into the booted iOS Simulator's hot-reload overlay."""
|
|
544
|
-
container = _ios_data_container()
|
|
617
|
+
container = _ios_data_container(bundle_id)
|
|
545
618
|
if container is None:
|
|
546
619
|
return False
|
|
547
620
|
dest_path = os.path.join(container, "Documents", HOT_RELOAD_DEV_ROOT, rel_path)
|
|
@@ -550,529 +623,51 @@ def _push_ios_hot_reload_file(local_path: str, rel_path: str) -> bool:
|
|
|
550
623
|
return True
|
|
551
624
|
|
|
552
625
|
|
|
553
|
-
def _clear_android_hot_reload_overlay() -> bool:
|
|
626
|
+
def _clear_android_hot_reload_overlay(app_id: str) -> bool:
|
|
554
627
|
"""Remove stale Android hot-reload files before launching."""
|
|
555
628
|
result = subprocess.run(
|
|
556
|
-
["adb", "shell", "run-as",
|
|
629
|
+
["adb", "shell", "run-as", app_id, "rm", "-rf", f"files/{HOT_RELOAD_DEV_ROOT}"],
|
|
557
630
|
check=False,
|
|
558
631
|
capture_output=True,
|
|
559
632
|
)
|
|
560
633
|
return result.returncode == 0
|
|
561
634
|
|
|
562
635
|
|
|
563
|
-
def _clear_ios_hot_reload_overlay() -> bool:
|
|
636
|
+
def _clear_ios_hot_reload_overlay(bundle_id: str) -> bool:
|
|
564
637
|
"""Remove stale iOS Simulator hot-reload files before launching."""
|
|
565
|
-
container = _ios_data_container()
|
|
638
|
+
container = _ios_data_container(bundle_id)
|
|
566
639
|
if container is None:
|
|
567
640
|
return False
|
|
568
641
|
shutil.rmtree(os.path.join(container, "Documents", HOT_RELOAD_DEV_ROOT), ignore_errors=True)
|
|
569
642
|
return True
|
|
570
643
|
|
|
571
644
|
|
|
572
|
-
def
|
|
573
|
-
"""Remove stale hot-reload overlay files for `platform`."""
|
|
574
|
-
if platform == "android":
|
|
575
|
-
return _clear_android_hot_reload_overlay()
|
|
576
|
-
if platform == "ios":
|
|
577
|
-
return _clear_ios_hot_reload_overlay()
|
|
578
|
-
return False
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
def _push_hot_reload_file(platform: str, local_path: str, rel_path: str) -> bool:
|
|
645
|
+
def _push_hot_reload_file(platform: str, local_path: str, rel_path: str, *, app_id: str, bundle_id: str) -> bool:
|
|
582
646
|
"""Push a changed source file to the running app."""
|
|
583
647
|
if platform == "android":
|
|
584
|
-
return _push_android_hot_reload_file(local_path, rel_path)
|
|
648
|
+
return _push_android_hot_reload_file(local_path, rel_path, app_id)
|
|
585
649
|
if platform == "ios":
|
|
586
|
-
return _push_ios_hot_reload_file(local_path, rel_path)
|
|
650
|
+
return _push_ios_hot_reload_file(local_path, rel_path, bundle_id)
|
|
587
651
|
return False
|
|
588
652
|
|
|
589
653
|
|
|
590
|
-
def
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
args: Parsed argparse namespace. Recognized attributes:
|
|
601
|
-
|
|
602
|
-
- `platform` (`"android"` | `"ios"`): Build target.
|
|
603
|
-
- `prepare_only` (`bool`): Stage files but skip the build.
|
|
604
|
-
- `hot_reload` (`bool`): Watch `app/` and push changes.
|
|
605
|
-
- `no_logs` (`bool`): Don't stream device logs after launch.
|
|
606
|
-
"""
|
|
607
|
-
# Determine the platform
|
|
608
|
-
platform: str = args.platform
|
|
609
|
-
prepare_only: bool = getattr(args, "prepare_only", False)
|
|
610
|
-
hot_reload: bool = getattr(args, "hot_reload", False)
|
|
611
|
-
show_logs: bool = not getattr(args, "no_logs", False)
|
|
612
|
-
|
|
613
|
-
# Read project configuration and save project root before any chdir
|
|
614
|
-
project_dir: str = os.getcwd()
|
|
615
|
-
config = _read_project_config()
|
|
616
|
-
python_version: str = config.get("pythonVersion", "3.11")
|
|
617
|
-
|
|
618
|
-
# Define the build directory
|
|
619
|
-
build_dir: str = os.path.join(project_dir, "build", platform)
|
|
620
|
-
|
|
621
|
-
# Create the build directory if it doesn't exist
|
|
622
|
-
os.makedirs(build_dir, exist_ok=True)
|
|
623
|
-
|
|
624
|
-
# Generate the required project files
|
|
625
|
-
if platform == "android":
|
|
626
|
-
create_android_project("MyApp", build_dir)
|
|
627
|
-
elif platform == "ios":
|
|
628
|
-
create_ios_project("MyApp", build_dir)
|
|
629
|
-
|
|
630
|
-
# Copy the user's Python code into the project
|
|
631
|
-
src_dir: str = os.path.join(os.getcwd(), "app")
|
|
632
|
-
|
|
633
|
-
# Adjust the destination directory for Android project
|
|
634
|
-
if platform == "android":
|
|
635
|
-
dest_dir: str = os.path.join(build_dir, "android_template", "app", "src", "main", "python", "app")
|
|
636
|
-
else:
|
|
637
|
-
# For iOS, stage the Python app in a top-level folder for later integration scripts
|
|
638
|
-
dest_dir = os.path.join(build_dir, "app")
|
|
639
|
-
|
|
640
|
-
# Create the destination directory if it doesn't exist
|
|
641
|
-
os.makedirs(dest_dir, exist_ok=True)
|
|
642
|
-
shutil.copytree(src_dir, dest_dir, dirs_exist_ok=True)
|
|
643
|
-
|
|
644
|
-
# During local development (running from repository), also bundle the
|
|
645
|
-
# local library sources so the app uses the in-repo version instead of
|
|
646
|
-
# the PyPI package. This provides faster inner-loop iteration and avoids
|
|
647
|
-
# version skew during development.
|
|
648
|
-
try:
|
|
649
|
-
# __file__ -> src/pythonnative/cli/pn.py, so repo root is one up from src/
|
|
650
|
-
src_root = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), ".."))
|
|
651
|
-
local_lib = os.path.join(src_root, "pythonnative")
|
|
652
|
-
if os.path.isdir(local_lib):
|
|
653
|
-
if platform == "android":
|
|
654
|
-
python_root = os.path.join(build_dir, "android_template", "app", "src", "main", "python")
|
|
655
|
-
else:
|
|
656
|
-
python_root = os.path.join(build_dir) # staged at build/ios/app for iOS below
|
|
657
|
-
os.makedirs(python_root, exist_ok=True)
|
|
658
|
-
shutil.copytree(local_lib, os.path.join(python_root, "pythonnative"), dirs_exist_ok=True)
|
|
659
|
-
except Exception:
|
|
660
|
-
# Non-fatal; fallback to the packaged PyPI dependency if present
|
|
661
|
-
pass
|
|
662
|
-
|
|
663
|
-
# Validate and read the user's requirements.txt
|
|
664
|
-
requirements_path = os.path.join(project_dir, "requirements.txt")
|
|
665
|
-
pip_reqs = _read_requirements(requirements_path)
|
|
666
|
-
|
|
667
|
-
if platform == "android":
|
|
668
|
-
# Patch the Android build.gradle with the configured Python version
|
|
669
|
-
app_build_gradle = os.path.join(build_dir, "android_template", "app", "build.gradle")
|
|
670
|
-
if os.path.exists(app_build_gradle):
|
|
671
|
-
with open(app_build_gradle, encoding="utf-8") as f:
|
|
672
|
-
content = f.read()
|
|
673
|
-
content = content.replace('version "3.11"', f'version "{python_version}"')
|
|
674
|
-
with open(app_build_gradle, "w", encoding="utf-8") as f:
|
|
675
|
-
f.write(content)
|
|
676
|
-
# Copy requirements.txt into the Android project for Chaquopy
|
|
677
|
-
android_reqs_path = os.path.join(build_dir, "android_template", "app", "requirements.txt")
|
|
678
|
-
if os.path.exists(requirements_path):
|
|
679
|
-
shutil.copy2(requirements_path, android_reqs_path)
|
|
680
|
-
else:
|
|
681
|
-
with open(android_reqs_path, "w", encoding="utf-8") as f:
|
|
682
|
-
f.write("")
|
|
683
|
-
|
|
684
|
-
# Install any necessary Python packages into the host environment
|
|
685
|
-
# Skip installation during prepare-only to avoid network access and speed up scaffolding
|
|
686
|
-
if not prepare_only:
|
|
687
|
-
if os.path.exists(requirements_path):
|
|
688
|
-
subprocess.run([sys.executable, "-m", "pip", "install", "-r", requirements_path], check=False)
|
|
689
|
-
|
|
690
|
-
# Run the project
|
|
691
|
-
if prepare_only:
|
|
692
|
-
print("Prepared project in build/ without building (prepare-only).")
|
|
693
|
-
return
|
|
694
|
-
|
|
695
|
-
if platform == "android":
|
|
696
|
-
# Change to the Android project directory
|
|
697
|
-
android_project_dir: str = os.path.join(build_dir, "android_template")
|
|
698
|
-
os.chdir(android_project_dir)
|
|
699
|
-
|
|
700
|
-
# Add executable permissions to the gradlew script
|
|
701
|
-
gradlew_path: str = os.path.join(android_project_dir, "gradlew")
|
|
702
|
-
os.chmod(gradlew_path, 0o755) # this makes the file executable for the user
|
|
703
|
-
|
|
704
|
-
# Build the Android project and install it on the device
|
|
705
|
-
env: dict[str, str] = os.environ.copy()
|
|
706
|
-
# Respect JAVA_HOME if set; otherwise, attempt a best-effort on macOS via Homebrew
|
|
707
|
-
if sys.platform == "darwin" and not env.get("JAVA_HOME"):
|
|
708
|
-
try:
|
|
709
|
-
jdk_path: str = subprocess.check_output(["brew", "--prefix", "openjdk@17"]).decode().strip()
|
|
710
|
-
env["JAVA_HOME"] = jdk_path
|
|
711
|
-
except Exception:
|
|
712
|
-
pass
|
|
713
|
-
subprocess.run(["./gradlew", "installDebug"], check=True, env=env)
|
|
714
|
-
|
|
715
|
-
_clear_hot_reload_overlay(platform)
|
|
716
|
-
|
|
717
|
-
# Run the Android app
|
|
718
|
-
# Assumes that the package name of your app is "com.example.myapp" and the main activity is "MainActivity"
|
|
719
|
-
# Replace "com.example.myapp" and ".MainActivity" with your actual package name and main activity
|
|
720
|
-
subprocess.run(
|
|
721
|
-
[
|
|
722
|
-
"adb",
|
|
723
|
-
"shell",
|
|
724
|
-
"am",
|
|
725
|
-
"start",
|
|
726
|
-
"-n",
|
|
727
|
-
f"{ANDROID_PACKAGE_ID}/.MainActivity",
|
|
728
|
-
],
|
|
729
|
-
check=True,
|
|
730
|
-
)
|
|
731
|
-
|
|
732
|
-
# Stream Python logs from logcat unless the user opted out or requested
|
|
733
|
-
# hot-reload (hot-reload handles its own log tailing below).
|
|
734
|
-
if show_logs and not hot_reload:
|
|
735
|
-
logcat_proc = _start_android_log_stream()
|
|
736
|
-
if logcat_proc is not None:
|
|
737
|
-
try:
|
|
738
|
-
logcat_proc.wait()
|
|
739
|
-
except KeyboardInterrupt:
|
|
740
|
-
print()
|
|
741
|
-
_terminate_subprocess(logcat_proc)
|
|
742
|
-
print("Stopped log streaming.")
|
|
743
|
-
elif platform == "ios":
|
|
744
|
-
# Attempt to build and run on iOS Simulator (best-effort)
|
|
745
|
-
ios_project_dir: str = os.path.join(build_dir, "ios_template")
|
|
746
|
-
if os.path.isdir(ios_project_dir):
|
|
747
|
-
# Stage embedded Python runtime inputs by downloading pinned assets
|
|
748
|
-
try:
|
|
749
|
-
assets_dir = os.path.join(build_dir, "ios_runtime")
|
|
750
|
-
os.makedirs(assets_dir, exist_ok=True)
|
|
751
|
-
# Pinned preferred asset name and checksum (b7)
|
|
752
|
-
preferred_name = "Python-3.11-iOS-support.b7.tar.gz"
|
|
753
|
-
sha256 = "2b7d8589715b9890e8dd7e1bce91c210bb5287417e17b9af120fc577675ed28e"
|
|
754
|
-
# Resolve a working download URL from GitHub Releases
|
|
755
|
-
url = _resolve_python_apple_support_asset("3.11", preferred_name=preferred_name)
|
|
756
|
-
if not url:
|
|
757
|
-
raise RuntimeError("Could not resolve Python-Apple-support asset URL from GitHub Releases.")
|
|
758
|
-
tar_path = os.path.join(assets_dir, os.path.basename(url))
|
|
759
|
-
if not os.path.exists(tar_path):
|
|
760
|
-
print("Downloading Python-Apple-support (3.11 iOS)")
|
|
761
|
-
req = urllib.request.Request(url, headers={"User-Agent": "pythonnative-cli"})
|
|
762
|
-
with urllib.request.urlopen(req) as r, open(tar_path, "wb") as f:
|
|
763
|
-
f.write(r.read())
|
|
764
|
-
# Verify checksum
|
|
765
|
-
h = hashlib.sha256()
|
|
766
|
-
with open(tar_path, "rb") as f:
|
|
767
|
-
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
768
|
-
h.update(chunk)
|
|
769
|
-
if h.hexdigest() != sha256:
|
|
770
|
-
raise RuntimeError("SHA256 mismatch for Python-Apple-support tarball")
|
|
771
|
-
# Extract only once
|
|
772
|
-
extract_root = os.path.join(assets_dir, "extracted")
|
|
773
|
-
if not os.path.isdir(extract_root):
|
|
774
|
-
os.makedirs(extract_root, exist_ok=True)
|
|
775
|
-
subprocess.run(["tar", "-xzf", tar_path, "-C", extract_root], check=True)
|
|
776
|
-
# Provide Python.xcframework to the Xcode project and stdlib for bundling
|
|
777
|
-
# Try both common layouts
|
|
778
|
-
cand_frameworks = [
|
|
779
|
-
os.path.join(extract_root, "Python.xcframework"),
|
|
780
|
-
os.path.join(extract_root, "support", "Python.xcframework"),
|
|
781
|
-
]
|
|
782
|
-
xc_src = next((p for p in cand_frameworks if os.path.isdir(p)), None)
|
|
783
|
-
if xc_src:
|
|
784
|
-
shutil.copytree(xc_src, os.path.join(ios_project_dir, "Python.xcframework"), dirs_exist_ok=True)
|
|
785
|
-
# Stdlib path
|
|
786
|
-
cand_stdlib = [
|
|
787
|
-
os.path.join(extract_root, "Python.xcframework", "ios-arm64_x86_64-simulator", "lib", "python3.11"),
|
|
788
|
-
os.path.join(
|
|
789
|
-
extract_root, "support", "Python.xcframework", "ios-arm64_x86_64-simulator", "lib", "python3.11"
|
|
790
|
-
),
|
|
791
|
-
]
|
|
792
|
-
stdlib_src = next((p for p in cand_stdlib if os.path.isdir(p)), None)
|
|
793
|
-
except Exception as e:
|
|
794
|
-
print(f"Warning: failed to prepare Python runtime: {e}")
|
|
795
|
-
|
|
796
|
-
os.chdir(ios_project_dir)
|
|
797
|
-
derived_data = os.path.join(ios_project_dir, "build")
|
|
798
|
-
try:
|
|
799
|
-
# Detect a simulator UDID to target: prefer Booted; else any iPhone
|
|
800
|
-
sim_udid: Optional[str] = None
|
|
801
|
-
try:
|
|
802
|
-
import json as _json
|
|
803
|
-
|
|
804
|
-
devices_out = subprocess.run(
|
|
805
|
-
["xcrun", "simctl", "list", "devices", "available", "--json"],
|
|
806
|
-
check=False,
|
|
807
|
-
capture_output=True,
|
|
808
|
-
text=True,
|
|
809
|
-
)
|
|
810
|
-
devs = _json.loads(devices_out.stdout or "{}").get("devices") or {}
|
|
811
|
-
all_devs = [d for lst in devs.values() for d in (lst or [])]
|
|
812
|
-
for d in all_devs:
|
|
813
|
-
if d.get("state") == "Booted":
|
|
814
|
-
sim_udid = d.get("udid")
|
|
815
|
-
break
|
|
816
|
-
if not sim_udid:
|
|
817
|
-
for d in all_devs:
|
|
818
|
-
if (d.get("isAvailable") or d.get("availability")) and (
|
|
819
|
-
d.get("name") or ""
|
|
820
|
-
).lower().startswith("iphone"):
|
|
821
|
-
sim_udid = d.get("udid")
|
|
822
|
-
break
|
|
823
|
-
except Exception:
|
|
824
|
-
pass
|
|
825
|
-
|
|
826
|
-
xcode_dest = (
|
|
827
|
-
["-destination", f"id={sim_udid}"] if sim_udid else ["-destination", "platform=iOS Simulator"]
|
|
828
|
-
)
|
|
829
|
-
|
|
830
|
-
# Provide header and lib paths for CPython (Simulator slice) ONLY if the
|
|
831
|
-
# XCFramework is not already added to the Xcode project. When the project
|
|
832
|
-
# contains `Python.xcframework`, Xcode manages headers and linking to avoid
|
|
833
|
-
# duplicate module.modulemap definitions.
|
|
834
|
-
extra_xcode_settings: list[str] = []
|
|
835
|
-
try:
|
|
836
|
-
xc_present = os.path.isdir(os.path.join(ios_project_dir, "Python.xcframework"))
|
|
837
|
-
if not xc_present and "extract_root" in locals():
|
|
838
|
-
sim_headers = os.path.join(
|
|
839
|
-
extract_root, "Python.xcframework", "ios-arm64_x86_64-simulator", "Headers"
|
|
840
|
-
)
|
|
841
|
-
sim_lib = os.path.join(
|
|
842
|
-
extract_root, "Python.xcframework", "ios-arm64_x86_64-simulator", "libPython3.11.a"
|
|
843
|
-
)
|
|
844
|
-
if os.path.isdir(sim_headers):
|
|
845
|
-
extra_xcode_settings.extend(
|
|
846
|
-
[
|
|
847
|
-
f"HEADER_SEARCH_PATHS={sim_headers}",
|
|
848
|
-
f"SWIFT_INCLUDE_PATHS={sim_headers}",
|
|
849
|
-
]
|
|
850
|
-
)
|
|
851
|
-
if os.path.exists(sim_lib):
|
|
852
|
-
extra_xcode_settings.append(f"OTHER_LDFLAGS=-force_load {sim_lib}")
|
|
853
|
-
except Exception:
|
|
854
|
-
pass
|
|
855
|
-
|
|
856
|
-
subprocess.run(
|
|
857
|
-
[
|
|
858
|
-
"xcodebuild",
|
|
859
|
-
"-project",
|
|
860
|
-
"ios_template.xcodeproj",
|
|
861
|
-
"-scheme",
|
|
862
|
-
"ios_template",
|
|
863
|
-
"-configuration",
|
|
864
|
-
"Debug",
|
|
865
|
-
*xcode_dest,
|
|
866
|
-
"-derivedDataPath",
|
|
867
|
-
derived_data,
|
|
868
|
-
"build",
|
|
869
|
-
*extra_xcode_settings,
|
|
870
|
-
],
|
|
871
|
-
check=False,
|
|
872
|
-
)
|
|
873
|
-
except FileNotFoundError:
|
|
874
|
-
print("xcodebuild not found. Skipping iOS build step.")
|
|
875
|
-
return
|
|
876
|
-
|
|
877
|
-
# Locate built app
|
|
878
|
-
app_path = os.path.join(derived_data, "Build", "Products", "Debug-iphonesimulator", "ios_template.app")
|
|
879
|
-
if not os.path.isdir(app_path):
|
|
880
|
-
print("Could not locate built .app; open the project in Xcode to run.")
|
|
881
|
-
return
|
|
882
|
-
|
|
883
|
-
# Copy staged Python app and optional embedded runtime into the .app bundle
|
|
884
|
-
try:
|
|
885
|
-
staged_app_src = os.path.join(build_dir, "app")
|
|
886
|
-
if os.path.isdir(staged_app_src):
|
|
887
|
-
shutil.copytree(staged_app_src, os.path.join(app_path, "app"), dirs_exist_ok=True)
|
|
888
|
-
# Also copy local library sources if present for dev flow
|
|
889
|
-
src_root = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), ".."))
|
|
890
|
-
local_lib = os.path.join(src_root, "pythonnative")
|
|
891
|
-
if os.path.isdir(local_lib):
|
|
892
|
-
shutil.copytree(local_lib, os.path.join(app_path, "pythonnative"), dirs_exist_ok=True)
|
|
893
|
-
# Copy stdlib from downloaded support if available
|
|
894
|
-
if "stdlib_src" in locals() and stdlib_src and os.path.isdir(stdlib_src):
|
|
895
|
-
shutil.copytree(stdlib_src, os.path.join(app_path, "python-stdlib"), dirs_exist_ok=True)
|
|
896
|
-
# Embed Python.framework for Simulator so PythonKit can dlopen it (from downloaded XCFramework)
|
|
897
|
-
sim_fw = None
|
|
898
|
-
if "extract_root" in locals():
|
|
899
|
-
cand_fw = [
|
|
900
|
-
os.path.join(
|
|
901
|
-
extract_root, "Python.xcframework", "ios-arm64_x86_64-simulator", "Python.framework"
|
|
902
|
-
),
|
|
903
|
-
os.path.join(
|
|
904
|
-
extract_root,
|
|
905
|
-
"support",
|
|
906
|
-
"Python.xcframework",
|
|
907
|
-
"ios-arm64_x86_64-simulator",
|
|
908
|
-
"Python.framework",
|
|
909
|
-
),
|
|
910
|
-
]
|
|
911
|
-
sim_fw = next((p for p in cand_fw if os.path.isdir(p)), None)
|
|
912
|
-
fw_dest_dir = os.path.join(app_path, "Frameworks")
|
|
913
|
-
os.makedirs(fw_dest_dir, exist_ok=True)
|
|
914
|
-
if sim_fw and os.path.isdir(sim_fw):
|
|
915
|
-
shutil.copytree(sim_fw, os.path.join(fw_dest_dir, "Python.framework"), dirs_exist_ok=True)
|
|
916
|
-
# Install rubicon-objc into platform-site
|
|
917
|
-
|
|
918
|
-
# Ensure importlib.metadata finds package metadata for rubicon-objc by
|
|
919
|
-
# installing it into a site-like dir that is on sys.path (platform-site).
|
|
920
|
-
try:
|
|
921
|
-
tmp_site = os.path.join(build_dir, "ios_site")
|
|
922
|
-
if os.path.isdir(tmp_site):
|
|
923
|
-
shutil.rmtree(tmp_site)
|
|
924
|
-
os.makedirs(tmp_site, exist_ok=True)
|
|
925
|
-
# Install pure-Python rubicon-objc distribution metadata and package
|
|
926
|
-
subprocess.run(
|
|
927
|
-
[
|
|
928
|
-
sys.executable,
|
|
929
|
-
"-m",
|
|
930
|
-
"pip",
|
|
931
|
-
"install",
|
|
932
|
-
"--no-deps",
|
|
933
|
-
"--upgrade",
|
|
934
|
-
"rubicon-objc",
|
|
935
|
-
"-t",
|
|
936
|
-
tmp_site,
|
|
937
|
-
],
|
|
938
|
-
check=False,
|
|
939
|
-
)
|
|
940
|
-
platform_site_dir = os.path.join(app_path, "platform-site")
|
|
941
|
-
os.makedirs(platform_site_dir, exist_ok=True)
|
|
942
|
-
for entry in os.listdir(tmp_site):
|
|
943
|
-
src_entry = os.path.join(tmp_site, entry)
|
|
944
|
-
dst_entry = os.path.join(platform_site_dir, entry)
|
|
945
|
-
if os.path.isdir(src_entry):
|
|
946
|
-
shutil.copytree(src_entry, dst_entry, dirs_exist_ok=True)
|
|
947
|
-
else:
|
|
948
|
-
shutil.copy2(src_entry, dst_entry)
|
|
949
|
-
except Exception:
|
|
950
|
-
# Non-fatal; if metadata isn't present, rubicon import may fail and fallback UI will appear
|
|
951
|
-
pass
|
|
952
|
-
# Install user's pip requirements (pure-Python packages) into the app bundle
|
|
953
|
-
if pip_reqs:
|
|
954
|
-
try:
|
|
955
|
-
reqs_tmp = os.path.join(build_dir, "ios_requirements.txt")
|
|
956
|
-
with open(reqs_tmp, "w", encoding="utf-8") as f:
|
|
957
|
-
f.write("\n".join(pip_reqs) + "\n")
|
|
958
|
-
tmp_reqs_dir = os.path.join(build_dir, "ios_user_packages")
|
|
959
|
-
if os.path.isdir(tmp_reqs_dir):
|
|
960
|
-
shutil.rmtree(tmp_reqs_dir)
|
|
961
|
-
os.makedirs(tmp_reqs_dir, exist_ok=True)
|
|
962
|
-
subprocess.run(
|
|
963
|
-
[sys.executable, "-m", "pip", "install", "-t", tmp_reqs_dir, "-r", reqs_tmp],
|
|
964
|
-
check=False,
|
|
965
|
-
)
|
|
966
|
-
for entry in os.listdir(tmp_reqs_dir):
|
|
967
|
-
src_entry = os.path.join(tmp_reqs_dir, entry)
|
|
968
|
-
dst_entry = os.path.join(platform_site_dir, entry)
|
|
969
|
-
if os.path.isdir(src_entry):
|
|
970
|
-
shutil.copytree(src_entry, dst_entry, dirs_exist_ok=True)
|
|
971
|
-
else:
|
|
972
|
-
shutil.copy2(src_entry, dst_entry)
|
|
973
|
-
except Exception:
|
|
974
|
-
pass
|
|
975
|
-
# Note: Python.xcframework provides a static library for Simulator; it must be linked at build time.
|
|
976
|
-
# We copy the XCFramework into the project directory above so Xcode can link it.
|
|
977
|
-
except Exception:
|
|
978
|
-
# Non-fatal; fallback UI will appear if import fails
|
|
979
|
-
pass
|
|
980
|
-
|
|
981
|
-
# Find an available simulator and boot it
|
|
982
|
-
try:
|
|
983
|
-
import json as _json
|
|
984
|
-
|
|
985
|
-
result = subprocess.run(
|
|
986
|
-
["xcrun", "simctl", "list", "devices", "available", "--json"],
|
|
987
|
-
check=False,
|
|
988
|
-
capture_output=True,
|
|
989
|
-
text=True,
|
|
990
|
-
)
|
|
991
|
-
devices_json = _json.loads(result.stdout or "{}")
|
|
992
|
-
all_devices: List[Dict[str, Any]] = []
|
|
993
|
-
for _runtime, devices in (devices_json.get("devices") or {}).items():
|
|
994
|
-
all_devices.extend(devices or [])
|
|
995
|
-
# Prefer iPhone 15/15 Pro names; else first available iPhone
|
|
996
|
-
preferred = None
|
|
997
|
-
for d in all_devices:
|
|
998
|
-
name = (d.get("name") or "").lower()
|
|
999
|
-
if "iphone 15" in name and d.get("isAvailable"):
|
|
1000
|
-
preferred = d
|
|
1001
|
-
break
|
|
1002
|
-
if not preferred:
|
|
1003
|
-
for d in all_devices:
|
|
1004
|
-
if d.get("isAvailable") and (d.get("name") or "").lower().startswith("iphone"):
|
|
1005
|
-
preferred = d
|
|
1006
|
-
break
|
|
1007
|
-
if not preferred:
|
|
1008
|
-
print("No available iOS Simulators found; open the project in Xcode to run.")
|
|
1009
|
-
return
|
|
1010
|
-
|
|
1011
|
-
udid = preferred.get("udid")
|
|
1012
|
-
# Boot (no-op if already booted). simctl returns non-zero and
|
|
1013
|
-
# prints to stderr when the device is already Booted; we
|
|
1014
|
-
# don't care about that case, so swallow its output.
|
|
1015
|
-
subprocess.run(["xcrun", "simctl", "boot", udid], check=False, capture_output=True)
|
|
1016
|
-
# Install
|
|
1017
|
-
subprocess.run(["xcrun", "simctl", "install", udid, app_path], check=False)
|
|
1018
|
-
_clear_hot_reload_overlay(platform)
|
|
1019
|
-
if show_logs and not hot_reload:
|
|
1020
|
-
# Attach the app's stdout/stderr to this terminal so Python
|
|
1021
|
-
# print() calls and exceptions are visible. SIMCTL_CHILD_*
|
|
1022
|
-
# env vars are forwarded to the launched process.
|
|
1023
|
-
sim_env = os.environ.copy()
|
|
1024
|
-
sim_env["SIMCTL_CHILD_PYTHONUNBUFFERED"] = "1"
|
|
1025
|
-
print("Launched iOS app on Simulator. Streaming logs (Ctrl+C to stop)...")
|
|
1026
|
-
try:
|
|
1027
|
-
subprocess.run(
|
|
1028
|
-
[
|
|
1029
|
-
"xcrun",
|
|
1030
|
-
"simctl",
|
|
1031
|
-
"launch",
|
|
1032
|
-
"--console-pty",
|
|
1033
|
-
"--terminate-running-process",
|
|
1034
|
-
udid,
|
|
1035
|
-
IOS_BUNDLE_ID,
|
|
1036
|
-
],
|
|
1037
|
-
env=sim_env,
|
|
1038
|
-
check=False,
|
|
1039
|
-
)
|
|
1040
|
-
except KeyboardInterrupt:
|
|
1041
|
-
print()
|
|
1042
|
-
subprocess.run(
|
|
1043
|
-
["xcrun", "simctl", "terminate", udid, IOS_BUNDLE_ID],
|
|
1044
|
-
check=False,
|
|
1045
|
-
capture_output=True,
|
|
1046
|
-
)
|
|
1047
|
-
print("Stopped log streaming.")
|
|
1048
|
-
elif hot_reload:
|
|
1049
|
-
# Skip launching here; ``_run_hot_reload`` will
|
|
1050
|
-
# spawn the app via ``simctl launch --console-pty``
|
|
1051
|
-
# so its ``print()`` / ``NSLog`` output streams to
|
|
1052
|
-
# the parent terminal alongside the file watcher.
|
|
1053
|
-
pass
|
|
1054
|
-
else:
|
|
1055
|
-
subprocess.run(["xcrun", "simctl", "launch", udid, IOS_BUNDLE_ID], check=False)
|
|
1056
|
-
print("Launched iOS app on Simulator (best-effort).")
|
|
1057
|
-
except Exception:
|
|
1058
|
-
print("Failed to auto-run on Simulator; open the project in Xcode to run.")
|
|
1059
|
-
|
|
1060
|
-
# Hot-reload file watcher
|
|
1061
|
-
if hot_reload and not prepare_only:
|
|
1062
|
-
_run_hot_reload(platform, project_dir, build_dir, show_logs=show_logs)
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
def _run_hot_reload(platform: str, project_dir: str, build_dir: str, show_logs: bool = True) -> None:
|
|
1066
|
-
"""Watch `app/` for changes and push updated files to the device.
|
|
1067
|
-
|
|
1068
|
-
When `show_logs` is true and targeting Android, `adb logcat` is
|
|
1069
|
-
streamed in parallel so Python print and exception output stays
|
|
1070
|
-
visible alongside hot-reload notifications.
|
|
654
|
+
def _run_hot_reload(
|
|
655
|
+
platform: str,
|
|
656
|
+
project_dir: str,
|
|
657
|
+
build_dir: str,
|
|
658
|
+
*,
|
|
659
|
+
app_id: str,
|
|
660
|
+
bundle_id: str,
|
|
661
|
+
show_logs: bool = True,
|
|
662
|
+
) -> None:
|
|
663
|
+
"""Watch ``app/`` for changes and push updated files to the device.
|
|
1071
664
|
|
|
1072
665
|
Args:
|
|
1073
|
-
platform:
|
|
666
|
+
platform: ``"android"`` or ``"ios"``.
|
|
1074
667
|
project_dir: Absolute path to the user's project root.
|
|
1075
668
|
build_dir: Absolute path to the staged build directory.
|
|
669
|
+
app_id: The Android application id (for ``run-as``).
|
|
670
|
+
bundle_id: The iOS bundle id (for the data container / launch).
|
|
1076
671
|
show_logs: Whether to stream device logs in parallel.
|
|
1077
672
|
"""
|
|
1078
673
|
from ..hot_reload import FileWatcher
|
|
@@ -1084,13 +679,13 @@ def _run_hot_reload(platform: str, project_dir: str, build_dir: str, show_logs:
|
|
|
1084
679
|
for fpath in changed_files:
|
|
1085
680
|
rel = os.path.relpath(fpath, project_dir)
|
|
1086
681
|
print(f"[hot-reload] Changed: {rel}")
|
|
1087
|
-
if _push_hot_reload_file(platform, fpath, rel):
|
|
682
|
+
if _push_hot_reload_file(platform, fpath, rel, app_id=app_id, bundle_id=bundle_id):
|
|
1088
683
|
pushed.append(fpath)
|
|
1089
684
|
else:
|
|
1090
685
|
print(f"[hot-reload] Failed to push {rel}")
|
|
1091
686
|
if pushed:
|
|
1092
687
|
manifest = _write_hot_reload_manifest(pushed, project_dir, build_dir)
|
|
1093
|
-
if _push_hot_reload_file(platform, manifest, "reload.json"):
|
|
688
|
+
if _push_hot_reload_file(platform, manifest, "reload.json", app_id=app_id, bundle_id=bundle_id):
|
|
1094
689
|
print(f"[hot-reload] Signaled reload for {len(pushed)} file(s).")
|
|
1095
690
|
else:
|
|
1096
691
|
print("[hot-reload] Failed to signal reload; app will not refresh automatically.")
|
|
@@ -1104,14 +699,12 @@ def _run_hot_reload(platform: str, project_dir: str, build_dir: str, show_logs:
|
|
|
1104
699
|
if platform == "android":
|
|
1105
700
|
log_proc = _start_android_log_stream()
|
|
1106
701
|
elif platform == "ios":
|
|
1107
|
-
log_proc = _start_ios_log_stream()
|
|
702
|
+
log_proc = _start_ios_log_stream(bundle_id)
|
|
1108
703
|
|
|
1109
704
|
try:
|
|
1110
705
|
if log_proc is not None:
|
|
1111
706
|
log_proc.wait()
|
|
1112
707
|
else:
|
|
1113
|
-
import time
|
|
1114
|
-
|
|
1115
708
|
while True:
|
|
1116
709
|
time.sleep(1)
|
|
1117
710
|
except KeyboardInterrupt:
|
|
@@ -1122,125 +715,25 @@ def _run_hot_reload(platform: str, project_dir: str, build_dir: str, show_logs:
|
|
|
1122
715
|
print("\n[hot-reload] Stopped.")
|
|
1123
716
|
|
|
1124
717
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
``"app/main.py"`` → ``"app.main"``. Returns ``"app.main"`` for
|
|
1129
|
-
empty / unusable input so ``pn preview`` always has a sane default.
|
|
1130
|
-
"""
|
|
1131
|
-
normalized = entry_point.strip().replace("\\", "/")
|
|
1132
|
-
if normalized.endswith(".py"):
|
|
1133
|
-
normalized = normalized[:-3]
|
|
1134
|
-
normalized = normalized.strip("/").replace("/", ".")
|
|
1135
|
-
return normalized or "app.main"
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
def preview_project(args: argparse.Namespace) -> None:
|
|
1139
|
-
"""Render the project in a desktop preview window (Tkinter).
|
|
1140
|
-
|
|
1141
|
-
Sets ``PN_PLATFORM=desktop`` (so PythonNative selects the Tkinter
|
|
1142
|
-
backend) and hands off to ``pythonnative.preview.run_preview``,
|
|
1143
|
-
which opens a window, mounts the app, and Fast Refreshes on every
|
|
1144
|
-
file save until the window is closed.
|
|
1145
|
-
|
|
1146
|
-
Args:
|
|
1147
|
-
args: Parsed argparse namespace. Recognized attributes:
|
|
1148
|
-
|
|
1149
|
-
- `component` (`str`, optional): Module path like
|
|
1150
|
-
``"app.main"`` (its ``App`` is used) or a dotted
|
|
1151
|
-
``module.Component`` path. Defaults to the project's
|
|
1152
|
-
configured ``entryPoint``.
|
|
1153
|
-
- `width` / `height` (`int`): Initial window size in points.
|
|
1154
|
-
- `title` (`str`): Window title.
|
|
1155
|
-
- `no_hot_reload` (`bool`): Disable file watching.
|
|
1156
|
-
"""
|
|
1157
|
-
# The desktop backend is selected at *import time* from the
|
|
1158
|
-
# ``PN_PLATFORM`` environment variable (see ``pythonnative.utils`` and
|
|
1159
|
-
# the host selection in ``pythonnative.screen``). Because the ``pn``
|
|
1160
|
-
# console entry point lives inside the ``pythonnative`` package,
|
|
1161
|
-
# importing it already loaded the package under the default,
|
|
1162
|
-
# non-desktop platform before this handler ever runs. Re-exec a fresh
|
|
1163
|
-
# interpreter with the variable set so every module binds to the
|
|
1164
|
-
# Tkinter backend; the re-execed child sees ``PN_PLATFORM=desktop`` and
|
|
1165
|
-
# skips this branch, so there is no exec loop.
|
|
1166
|
-
if os.environ.get("PN_PLATFORM") != "desktop":
|
|
1167
|
-
try:
|
|
1168
|
-
completed = subprocess.run(
|
|
1169
|
-
[sys.executable, "-m", "pythonnative.cli.pn", *sys.argv[1:]],
|
|
1170
|
-
env={**os.environ, "PN_PLATFORM": "desktop"},
|
|
1171
|
-
)
|
|
1172
|
-
except KeyboardInterrupt:
|
|
1173
|
-
sys.exit(130)
|
|
1174
|
-
sys.exit(completed.returncode)
|
|
1175
|
-
|
|
1176
|
-
project_dir = os.getcwd()
|
|
1177
|
-
component: Optional[str] = getattr(args, "component", None)
|
|
1178
|
-
if not component:
|
|
1179
|
-
config = _read_project_config()
|
|
1180
|
-
component = _entrypoint_to_module(config.get("entryPoint", "app/main.py"))
|
|
718
|
+
# ======================================================================
|
|
719
|
+
# Argument parsing
|
|
720
|
+
# ======================================================================
|
|
1181
721
|
|
|
1182
|
-
try:
|
|
1183
|
-
from pythonnative.preview import run_preview
|
|
1184
|
-
except Exception as exc: # pragma: no cover - environment dependent
|
|
1185
|
-
print(f"Error: could not start the desktop preview: {exc}")
|
|
1186
|
-
print(
|
|
1187
|
-
"The desktop preview needs Tkinter (Python's standard GUI toolkit).\n"
|
|
1188
|
-
"On macOS: brew install python-tk\n"
|
|
1189
|
-
"On Debian/Ubuntu: sudo apt-get install python3-tk\n"
|
|
1190
|
-
"On Windows: reinstall Python with the 'tcl/tk' option checked."
|
|
1191
|
-
)
|
|
1192
|
-
sys.exit(1)
|
|
1193
722
|
|
|
1194
|
-
|
|
1195
|
-
try:
|
|
1196
|
-
run_preview(
|
|
1197
|
-
component,
|
|
1198
|
-
project_root=project_dir,
|
|
1199
|
-
width=getattr(args, "width", 390),
|
|
1200
|
-
height=getattr(args, "height", 844),
|
|
1201
|
-
title=getattr(args, "title", "PythonNative Preview"),
|
|
1202
|
-
hot_reload=not getattr(args, "no_hot_reload", False),
|
|
1203
|
-
)
|
|
1204
|
-
except RuntimeError as exc:
|
|
1205
|
-
print(f"Error: {exc}")
|
|
1206
|
-
sys.exit(1)
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
def clean_project(args: argparse.Namespace) -> None:
|
|
1210
|
-
"""Remove the local `build/` directory.
|
|
1211
|
-
|
|
1212
|
-
Args:
|
|
1213
|
-
args: Parsed argparse namespace (unused; accepted for the
|
|
1214
|
-
`set_defaults(func=...)` dispatch shape).
|
|
1215
|
-
"""
|
|
1216
|
-
# Define the build directory
|
|
1217
|
-
build_dir: str = os.path.join(os.getcwd(), "build")
|
|
1218
|
-
|
|
1219
|
-
# Check if the build directory exists
|
|
1220
|
-
if os.path.exists(build_dir):
|
|
1221
|
-
shutil.rmtree(build_dir)
|
|
1222
|
-
print("Removed build/ directory.")
|
|
1223
|
-
else:
|
|
1224
|
-
print("No build/ directory to remove.")
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
def main() -> None:
|
|
1228
|
-
"""Entry point for the `pn` console script.
|
|
1229
|
-
|
|
1230
|
-
Wires up the `init`, `run`, and `clean` subcommands and dispatches
|
|
1231
|
-
to the corresponding handler.
|
|
1232
|
-
"""
|
|
723
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1233
724
|
parser = argparse.ArgumentParser(prog="pn", description="PythonNative CLI")
|
|
1234
725
|
subparsers = parser.add_subparsers()
|
|
1235
726
|
|
|
1236
|
-
|
|
1237
|
-
parser_init = subparsers.add_parser("init")
|
|
727
|
+
parser_init = subparsers.add_parser("init", help="Scaffold a new project")
|
|
1238
728
|
parser_init.add_argument("name", nargs="?", help="Project name (defaults to current directory name)")
|
|
1239
729
|
parser_init.add_argument("--force", action="store_true", help="Overwrite existing files if present")
|
|
1240
730
|
parser_init.set_defaults(func=init_project)
|
|
1241
731
|
|
|
1242
|
-
|
|
1243
|
-
|
|
732
|
+
parser_doctor = subparsers.add_parser("doctor", help="Diagnose the local toolchain and config")
|
|
733
|
+
parser_doctor.add_argument("platform", nargs="?", choices=["android", "ios"], help="Restrict checks to a platform")
|
|
734
|
+
parser_doctor.set_defaults(func=doctor_command)
|
|
735
|
+
|
|
736
|
+
parser_preview = subparsers.add_parser("preview", help="Render the app in a desktop window")
|
|
1244
737
|
parser_preview.add_argument(
|
|
1245
738
|
"component",
|
|
1246
739
|
nargs="?",
|
|
@@ -1251,39 +744,40 @@ def main() -> None:
|
|
|
1251
744
|
"--height", type=int, default=844, help="Initial window height in points (default: 844)"
|
|
1252
745
|
)
|
|
1253
746
|
parser_preview.add_argument("--title", default="PythonNative Preview", help="Preview window title")
|
|
1254
|
-
parser_preview.add_argument(
|
|
1255
|
-
"--no-hot-reload",
|
|
1256
|
-
action="store_true",
|
|
1257
|
-
help="Disable file watching / Fast Refresh",
|
|
1258
|
-
)
|
|
747
|
+
parser_preview.add_argument("--no-hot-reload", action="store_true", help="Disable file watching / Fast Refresh")
|
|
1259
748
|
parser_preview.set_defaults(func=preview_project)
|
|
1260
749
|
|
|
1261
|
-
|
|
1262
|
-
parser_run = subparsers.add_parser("run")
|
|
750
|
+
parser_run = subparsers.add_parser("run", help="Build, install, and launch on a device/simulator")
|
|
1263
751
|
parser_run.add_argument("platform", choices=["android", "ios"])
|
|
1264
|
-
parser_run.add_argument(
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
help="Extract templates and stage app without building",
|
|
1268
|
-
)
|
|
1269
|
-
parser_run.add_argument(
|
|
1270
|
-
"--hot-reload",
|
|
1271
|
-
action="store_true",
|
|
1272
|
-
help="Watch app/ for changes and push updates to the running app",
|
|
1273
|
-
)
|
|
1274
|
-
parser_run.add_argument(
|
|
1275
|
-
"--no-logs",
|
|
1276
|
-
action="store_true",
|
|
1277
|
-
help="Don't attach to the app's stdout/stderr after launching (default: stream logs)",
|
|
1278
|
-
)
|
|
752
|
+
parser_run.add_argument("--prepare-only", action="store_true", help="Stage + configure without building")
|
|
753
|
+
parser_run.add_argument("--hot-reload", action="store_true", help="Watch app/ and push updates to the running app")
|
|
754
|
+
parser_run.add_argument("--no-logs", action="store_true", help="Don't stream device logs after launch")
|
|
1279
755
|
parser_run.set_defaults(func=run_project)
|
|
1280
756
|
|
|
1281
|
-
|
|
1282
|
-
|
|
757
|
+
parser_build = subparsers.add_parser("build", help="Build distributable artifacts")
|
|
758
|
+
parser_build.add_argument("platform", choices=["android", "ios"])
|
|
759
|
+
parser_build.add_argument("--debug", action="store_true", help="Build the debug variant instead of release")
|
|
760
|
+
parser_build.set_defaults(func=build_project)
|
|
761
|
+
|
|
762
|
+
parser_app_id = subparsers.add_parser("app-id", help="Print the resolved application/bundle id")
|
|
763
|
+
parser_app_id.add_argument("platform", choices=["android", "ios"])
|
|
764
|
+
parser_app_id.set_defaults(func=app_id_command)
|
|
765
|
+
|
|
766
|
+
parser_clean = subparsers.add_parser("clean", help="Remove the local build/ directory")
|
|
1283
767
|
parser_clean.set_defaults(func=clean_project)
|
|
1284
768
|
|
|
769
|
+
return parser
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def main() -> None:
|
|
773
|
+
"""Entry point for the ``pn`` console script."""
|
|
774
|
+
parser = _build_parser()
|
|
1285
775
|
args = parser.parse_args()
|
|
1286
|
-
|
|
776
|
+
func = getattr(args, "func", None)
|
|
777
|
+
if func is None:
|
|
778
|
+
parser.print_help()
|
|
779
|
+
sys.exit(1)
|
|
780
|
+
func(args)
|
|
1287
781
|
|
|
1288
782
|
|
|
1289
783
|
if __name__ == "__main__":
|