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/cli/pn.py CHANGED
@@ -1,80 +1,52 @@
1
- """`pn` CLI: scaffold, run, and clean PythonNative projects.
2
-
3
- The console script `pn` (declared in `pyproject.toml` under
4
- `[project.scripts]`) dispatches to one of four subcommands:
5
-
6
- - `pn init [name]`: scaffold a new project in the current directory.
7
- - `pn preview [component]`: render the app in a desktop (Tkinter)
8
- window with instant Fast Refresh — the fast inner dev loop, no
9
- device or simulator required.
10
- - `pn run android|ios`: stage code into a native template, build it,
11
- install it, and stream logs back to the terminal.
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 implementation here is intentionally side-effect heavy: it shells
15
- out to `gradle`, `xcodebuild`, `adb`, and `xcrun simctl`. Errors from
16
- those tools are usually surfaced inline so the developer sees the
17
- underlying message.
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 urllib.request
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
- def init_project(args: argparse.Namespace) -> None:
36
- """Scaffold a new PythonNative project in the current directory.
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
- os.makedirs(app_dir, exist_ok=True)
45
+ # ======================================================================
46
+ # init
47
+ # ======================================================================
73
48
 
74
- main_py = os.path.join(app_dir, "main.py")
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
- # Create config
119
- config = {
120
- "name": project_name,
121
- "appId": "com.example." + project_name.replace(" ", "").lower(),
122
- "entryPoint": "app/main.py",
123
- "pythonVersion": "3.11",
124
- "ios": {},
125
- "android": {},
126
- }
127
- with open(config_path, "w", encoding="utf-8") as f:
128
- json.dump(config, f, indent=2)
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
- # Requirements (third-party packages only; pythonnative itself is bundled by the CLI)
131
- if not os.path.exists(requirements_path) or args.force:
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
- # .gitignore
136
- default_gitignore = "# PythonNative\n" "__pycache__/\n" "*.pyc\n" ".venv/\n" "build/\n" ".DS_Store\n"
137
- if not os.path.exists(gitignore_path) or args.force:
138
- with open(gitignore_path, "w", encoding="utf-8") as f:
139
- f.write(default_gitignore)
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
- print("Initialized PythonNative project.")
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
- def _copy_dir(src: str, dst: str) -> None:
145
- """Recursively copy `src` into `dst`, creating parents as needed."""
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
- def _copy_bundled_template_dir(template_dir: str, destination: str) -> None:
151
- """Copy a bundled template directory into `destination`.
143
+ # ======================================================================
144
+ # doctor / app-id
145
+ # ======================================================================
152
146
 
153
- Search order:
154
147
 
155
- 1. Local source checkout (`src/pythonnative/templates/<name>`).
156
- 2. Repository `templates/<name>` (used when running from a clone).
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
- template_dir: The bundled template subdirectory to copy
162
- (e.g., `"android_template"`).
163
- destination: Parent directory; the template lands at
164
- `<destination>/<template_dir>`.
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
- Raises:
167
- FileNotFoundError: If no bundled copy can be located.
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
- dest_path = os.path.join(destination, template_dir)
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
- # __file__ -> src/pythonnative/cli/pn.py, so go up to src/, then to repo root
174
- src_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
175
- # Check templates located inside the source package tree
176
- local_pkg_templates = os.path.join(src_dir, "pythonnative", "templates", template_dir)
177
- if os.path.isdir(local_pkg_templates):
178
- _copy_dir(local_pkg_templates, dest_path)
179
- return
180
- repo_root = os.path.abspath(os.path.join(src_dir, ".."))
181
- repo_templates = os.path.join(repo_root, "templates")
182
- candidate_dir = os.path.join(repo_templates, template_dir)
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
- # Try to load from installed package resources (templates packaged inside the module)
222
+ print(f"Starting PythonNative preview for {component} (Ctrl+C or close the window to stop).")
190
223
  try:
191
- cand = resources.files("pythonnative").joinpath("templates").joinpath(template_dir)
192
- with resources.as_file(cand) as p:
193
- resource_path = str(p)
194
- if os.path.isdir(resource_path):
195
- _copy_dir(resource_path, dest_path)
196
- return
197
- except Exception:
198
- pass
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
- # Last resort: check typical data-file locations
237
+ def _preview_entry(project_dir: Path) -> str:
201
238
  try:
202
- data_paths = sysconfig.get_paths()
203
- search_bases = [
204
- data_paths.get("data"),
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
- Reads `GITHUB_TOKEN` or `GH_TOKEN` from the environment to raise
223
- the unauthenticated rate limit.
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
- def _resolve_python_apple_support_asset(
235
- py_major_minor: str = "3.11", preferred_name: str = "Python-3.11-iOS-support.b7.tar.gz"
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
- Prefers an exact name match for `preferred_name`; otherwise falls
240
- back to the newest asset whose name contains
241
- `Python-{py_major_minor}-iOS-support` and ends with `.tar.gz`.
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
- Args:
244
- py_major_minor: Python version string used in the asset name
245
- (e.g., `"3.11"`).
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
- releases = _github_json("https://api.github.com/repos/beeware/Python-Apple-support/releases?per_page=100")
255
- # Search all releases for preferred_name first
256
- for rel in releases:
257
- for a in rel.get("assets", []) or []:
258
- name = a.get("name") or ""
259
- if name == preferred_name:
260
- return a.get("browser_download_url")
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
- Args:
277
- project_name: Project name (currently informational; the
278
- template uses fixed package IDs).
279
- destination: Directory to receive the staged project.
280
- """
281
- _copy_bundled_template_dir("android_template", destination)
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
- def create_ios_project(project_name: str, destination: str) -> None:
285
- """Stage the bundled iOS template into `destination`.
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
- project_name: Project name (currently informational; the
289
- template uses fixed bundle IDs).
290
- destination: Directory to receive the staged project.
364
+ args: Parsed namespace (``platform``, ``debug``).
291
365
  """
292
- _copy_bundled_template_dir("ios_template", destination)
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
- def _read_project_config() -> dict:
296
- """Read `pythonnative.json` from the current working directory.
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
- Returns:
299
- The parsed config dict, or `{}` if the file is missing.
300
- """
301
- config_path = os.path.join(os.getcwd(), "pythonnative.json")
302
- if os.path.exists(config_path):
303
- with open(config_path, encoding="utf-8") as f:
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
- def _read_requirements(requirements_path: str) -> list[str]:
309
- """Read a requirements file and return non-empty, non-comment lines.
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
- Args:
316
- requirements_path: Path to a `requirements.txt` file.
399
+ def clean_project(args: argparse.Namespace) -> None:
400
+ """Remove the local ``build/`` directory.
317
401
 
318
- Returns:
319
- A list of requirement specifier strings, in file order.
402
+ Args:
403
+ args: Parsed namespace (unused).
320
404
  """
321
- if not os.path.exists(requirements_path):
322
- return []
323
- with open(requirements_path, encoding="utf-8") as f:
324
- lines = f.readlines()
325
- result: list[str] = []
326
- for line in lines:
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
- ANDROID_PACKAGE_ID: str = "com.pythonnative.android_template"
344
- HOT_RELOAD_DEV_ROOT: str = "pythonnative_dev"
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
- IOS_BUNDLE_ID: str = "com.pythonnative.ios-template"
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
- def _start_android_log_stream() -> Optional[subprocess.Popen]:
362
- """Clear logcat and stream Python-relevant log tags to the terminal.
426
+ # ======================================================================
427
+ # Device log streaming
428
+ # ======================================================================
363
429
 
364
- Python's `print()` output reaches logcat via Chaquopy, which
365
- redirects `sys.stdout`/`sys.stderr` to the `python.stdout` and
366
- `python.stderr` tags.
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 `adb logcat` subprocess, or `None` when `adb` is
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", *ANDROID_LOGCAT_FILTERS])
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 `None` if none is booted.
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
- udid = device.get("udid")
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 _start_ios_log_stream() -> Optional[subprocess.Popen]:
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
- Mirrors the approach `pn run ios` (without `--hot-reload`) takes:
417
- ``xcrun simctl launch --console-pty`` attaches the parent
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 subprocess (output inherits the parent
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
- sim_env = os.environ.copy()
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
- "xcrun",
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 `SIGKILL` if needed.
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 f:
494
- json.dump(_hot_reload_manifest_payload(changed_files, project_dir), f)
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 `run-as` relative destination for an app source file."""
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", ANDROID_PACKAGE_ID, "cp", tmp_path, dest_path],
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", IOS_BUNDLE_ID, "data"],
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
- container = result.stdout.strip()
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", ANDROID_PACKAGE_ID, "rm", "-rf", f"files/{HOT_RELOAD_DEV_ROOT}"],
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 _clear_hot_reload_overlay(platform: str) -> bool:
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 run_project(args: argparse.Namespace) -> None:
591
- """Build and run the project on the requested platform.
592
-
593
- Stages templates, copies the user's `app/` into the platform
594
- project, optionally installs Python requirements, and (unless
595
- `--prepare-only` is set) builds and launches the app on a
596
- connected device or simulator. With `--hot-reload`, also watches
597
- `app/` for changes and pushes updates to the device.
598
-
599
- Args:
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: Either `"android"` or `"ios"`.
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
- def _entrypoint_to_module(entry_point: str) -> str:
1126
- """Convert a config ``entryPoint`` path into an importable module path.
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
- print(f"Starting PythonNative preview for {component} (Ctrl+C or close the window to stop).")
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
- # Create a new command 'init' that calls init_project
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
- # Create a new command 'preview' that calls preview_project
1243
- parser_preview = subparsers.add_parser("preview")
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
- # Create a new command 'run' that calls run_project
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
- "--prepare-only",
1266
- action="store_true",
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
- # Create a new command 'clean' that calls clean_project
1282
- parser_clean = subparsers.add_parser("clean")
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
- args.func(args)
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__":