| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -18,7 +18,9 @@ the documented behavior never drifts from the code. | |||
| 18 | 18 | `--width`, `--height`, `--title`, `--no-hot-reload`. See the | |
| 19 | 19 | [Desktop preview guide](../guides/desktop-preview.md). | |
| 20 | 20 | - `pn devices [android|ios]`: list connected devices, emulators, and | |
| 21 | - simulators with the identifiers `--device` accepts. | ||
| 21 | + simulators with the identifiers `--device` accepts. Flag: `--json` to | ||
| 22 | + print a JSON array to stdout for scripting; the "no devices" hints go | ||
| 23 | + to stderr instead, and an empty list prints `[]` and exits 0. | ||
| 22 | 24 | - `pn run android|ios`: build and run on a connected device or | |
| 23 | 25 | simulator. Flags: `--device` (target a specific device by identifier | |
| 24 | 26 | or name), `--prepare-only`, `--hot-reload`, `--no-logs`. | |
@@ -31,6 +33,7 @@ the documented behavior never drifts from the code. | |||
| 31 | 33 | - `pn app-id android|ios`: print the resolved application id (Android) | |
| 32 | 34 | or bundle id (iOS), handy for scripts and CI. | |
| 33 | 35 | - `pn clean`: remove the local `build/` directory. | |
| 36 | + - `pn --version` (`-V`): print the installed PythonNative version. | ||
| 34 | 37 | ||
| 35 | 38 | ::: pythonnative.cli.pn | |
| 36 | 39 | options: | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -9,7 +9,7 @@ | |||
| 9 | 9 | - `pn preview [component]`: render the app in a desktop (Tkinter) window | |
| 10 | 10 | with Fast Refresh, the fast inner dev loop, no device required. | |
| 11 | 11 | - `pn devices [platform]`: list connected devices, emulators, and | |
| 12 | - simulators. | ||
| 12 | + simulators, as a table or as JSON with `--json`. | ||
| 13 | 13 | - `pn run android|ios [--device D]`: stage + build + install + launch on | |
| 14 | 14 | a device, emulator, or simulator, with optional on-device hot reload. | |
| 15 | 15 | - `pn logs android|ios`: stream logs from the running app without | |
@@ -38,7 +38,7 @@ | |||
| 38 | 38 | import time | |
| 39 | 39 | from importlib.metadata import version as pkg_version | |
| 40 | 40 | from pathlib import Path | |
| 41 | - from typing import Any, Dict, List, Optional | ||
| 41 | + from typing import Any, Dict, List, Optional, TextIO | ||
| 42 | 42 | ||
| 43 | 43 | from ..project import builder as builder_mod | |
| 44 | 44 | from ..project import devices as devices_mod | |
@@ -298,18 +298,46 @@ def _preview_entry(project_dir: Path) -> str: | |||
| 298 | 298 | # ====================================================================== | |
| 299 | 299 | ||
| 300 | 300 | ||
| 301 | + def _print_no_devices_hints(stream: TextIO) -> None: | ||
| 302 | + """Print the "no devices" guidance to ``stream``. | ||
| 303 | + | ||
| 304 | + Shared by both output modes so the wording can't drift between the | ||
| 305 | + table (which sends it to stdout) and ``--json`` (stderr). | ||
| 306 | + | ||
| 307 | + Args: | ||
| 308 | + stream: Where to write, ``sys.stdout`` or ``sys.stderr``. | ||
| 309 | + """ | ||
| 310 | + print("No devices found.", file=stream) | ||
| 311 | + print("Android: start an emulator or connect a device with USB debugging enabled.", file=stream) | ||
| 312 | + print("iOS: open Xcode once to install Simulators, or plug in a device.", file=stream) | ||
| 313 | + | ||
| 314 | + | ||
| 301 | 315 | def devices_command(args: argparse.Namespace) -> None: | |
| 302 | 316 | """List connected devices, emulators, and simulators. | |
| 303 | 317 | ||
| 318 | + Prints an aligned table and exits 1 when nothing is connected. | ||
| 319 | + | ||
| 320 | + With ``--json``, stdout carries a JSON array and nothing else, one | ||
| 321 | + object per device (see ``Device.to_dict``), so it stays parseable. | ||
| 322 | + The "no devices" hints go to stderr instead, an empty result prints | ||
| 323 | + ``[]``, and the exit status is 0 either way, since "no devices" is a | ||
| 324 | + valid answer for a script rather than a failure. | ||
| 325 | + | ||
| 304 | 326 | Args: | |
| 305 | - args: Parsed namespace with optional ``platform``. | ||
| 327 | + args: Parsed namespace with optional ``platform`` and ``json``. | ||
| 306 | 328 | """ | |
| 307 | 329 | platform: Optional[str] = getattr(args, "platform", None) | |
| 330 | + as_json: bool = getattr(args, "json", False) | ||
| 308 | 331 | devices = devices_mod.list_devices(platform) | |
| 332 | + | ||
| 333 | + if as_json: | ||
| 334 | + if not devices: | ||
| 335 | + _print_no_devices_hints(sys.stderr) | ||
| 336 | + print(json.dumps([device.to_dict() for device in devices], indent=2)) | ||
| 337 | + return | ||
| 338 | + | ||
| 309 | 339 | if not devices: | |
| 310 | - print("No devices found.") | ||
| 311 | - print("Android: start an emulator or connect a device with USB debugging enabled.") | ||
| 312 | - print("iOS: open Xcode once to install Simulators, or plug in a device.") | ||
| 340 | + _print_no_devices_hints(sys.stdout) | ||
| 313 | 341 | sys.exit(1) | |
| 314 | 342 | print(f" {'IDENTIFIER':<40} {'KIND':<10} {'STATE':<10} NAME") | |
| 315 | 343 | for device in devices: | |
@@ -943,6 +971,9 @@ def _build_parser() -> argparse.ArgumentParser: | |||
| 943 | 971 | ||
| 944 | 972 | parser_devices = subparsers.add_parser("devices", help="List devices, emulators, and simulators") | |
| 945 | 973 | parser_devices.add_argument("platform", nargs="?", choices=["android", "ios"], help="Restrict to a platform") | |
| 974 | + parser_devices.add_argument( | ||
| 975 | + "--json", action="store_true", help="Print a JSON array to stdout for scripting (hints go to stderr)" | ||
| 976 | + ) | ||
| 946 | 977 | parser_devices.set_defaults(func=devices_command) | |
| 947 | 978 | ||
| 948 | 979 | parser_run = subparsers.add_parser("run", help="Build, install, and launch on a device/simulator") | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -18,7 +18,7 @@ | |||
| 18 | 18 | import re | |
| 19 | 19 | import subprocess | |
| 20 | 20 | import tempfile | |
| 21 | - from dataclasses import dataclass | ||
| 21 | + from dataclasses import asdict, dataclass | ||
| 22 | 22 | from pathlib import Path | |
| 23 | 23 | from typing import Any, Dict, List, Optional | |
| 24 | 24 | ||
@@ -64,6 +64,19 @@ def is_ready(self) -> bool: | |||
| 64 | 64 | return self.state in ("booted", "shutdown") | |
| 65 | 65 | return self.state in ("booted", "connected") | |
| 66 | 66 | ||
| 67 | + def to_dict(self) -> Dict[str, Any]: | ||
| 68 | + """Return a JSON-serializable view of this target. | ||
| 69 | + | ||
| 70 | + Derived from the dataclass fields so a field added later is | ||
| 71 | + carried into the payload automatically, plus the computed | ||
| 72 | + ``is_ready`` that consumers would otherwise have to re-derive | ||
| 73 | + from ``kind`` and ``state``. | ||
| 74 | + | ||
| 75 | + Returns: | ||
| 76 | + The declared fields in declaration order, then ``is_ready``. | ||
| 77 | + """ | ||
| 78 | + return {**asdict(self), "is_ready": self.is_ready} | ||
| 79 | + | ||
| 67 | 80 | def format(self) -> str: | |
| 68 | 81 | """Return one aligned listing row for the CLI.""" | |
| 69 | 82 | os_part = f" ({self.os_version})" if self.os_version else "" | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,20 +1,23 @@ | |||
| 1 | + import argparse | ||
| 2 | + import json | ||
| 1 | 3 | import os | |
| 2 | 4 | import shutil | |
| 3 | 5 | import subprocess | |
| 4 | 6 | import sys | |
| 5 | 7 | import tempfile | |
| 6 | 8 | from pathlib import Path | |
| 7 | - from typing import List | ||
| 9 | + from typing import Callable, Dict, List, Optional | ||
| 8 | 10 | ||
| 9 | 11 | import pytest | |
| 10 | 12 | ||
| 11 | 13 | import pythonnative.cli.pn as pn_cli | |
| 12 | 14 | import pythonnative.hot_reload as hot_reload_module | |
| 15 | + from pythonnative.project.devices import Device | ||
| 13 | 16 | ||
| 14 | 17 | ||
| 15 | - def run_pn(args: List[str], cwd: str) -> "subprocess.CompletedProcess[str]": | ||
| 18 | + def run_pn(args: List[str], cwd: str, env: Optional[Dict[str, str]] = None) -> "subprocess.CompletedProcess[str]": | ||
| 16 | 19 | cmd = [sys.executable, "-m", "pythonnative.cli.pn"] + args | |
| 17 | - return subprocess.run(cmd, cwd=cwd, check=False, capture_output=True, text=True) | ||
| 20 | + return subprocess.run(cmd, cwd=cwd, check=False, capture_output=True, text=True, env=env) | ||
| 18 | 21 | ||
| 19 | 22 | ||
| 20 | 23 | def test_cli_version(tmp_path: Path) -> None: | |
@@ -339,6 +342,198 @@ def _raise(*args: object, **kwargs: object) -> None: | |||
| 339 | 342 | assert pn_cli._booted_ios_udid() is None | |
| 340 | 343 | ||
| 341 | 344 | ||
| 345 | + # `pn devices` tests run devices_command() in-process rather than through run_pn, | ||
| 346 | + # because run_pn spawns a child interpreter that monkeypatch can't reach: it would | ||
| 347 | + # re-import the real list_devices and shell out to adb/xcrun, making the result | ||
| 348 | + # depend on whatever hardware the test machine has attached. The one run_pn test | ||
| 349 | + # below empties PATH instead, so every list_* call hits FileNotFoundError. | ||
| 350 | + | ||
| 351 | + # A naive `state == "booted"` readiness check disagrees with is_ready on two of | ||
| 352 | + # these: the connected Android device (ready, not booted) and the shutdown | ||
| 353 | + # simulator (ready, because simulators boot on demand). | ||
| 354 | + _FAKE_DEVICES = [ | ||
| 355 | + Device("android", "device", "R5CT12345XYZ", "Pixel 8 Pro", "", "connected"), | ||
| 356 | + Device("android", "device", "OFFLINE99", "Galaxy S24", "", "offline"), | ||
| 357 | + Device("ios", "simulator", "ABC-123", "iPhone 17 Pro", "iOS 26.4", "booted"), | ||
| 358 | + Device("ios", "simulator", "DEF-456", "iPad Pro 13-inch (M4)", "iOS 26.4", "shutdown"), | ||
| 359 | + ] | ||
| 360 | + | ||
| 361 | + _NO_DEVICES_HINTS = ( | ||
| 362 | + "No devices found.\n" | ||
| 363 | + "Android: start an emulator or connect a device with USB debugging enabled.\n" | ||
| 364 | + "iOS: open Xcode once to install Simulators, or plug in a device.\n" | ||
| 365 | + ) | ||
| 366 | + | ||
| 367 | + _DEVICE_TABLE = ( | ||
| 368 | + " IDENTIFIER KIND STATE NAME\n" | ||
| 369 | + " R5CT12345XYZ device connected Pixel 8 Pro\n" | ||
| 370 | + " OFFLINE99 device offline Galaxy S24\n" | ||
| 371 | + " ABC-123 simulator booted iPhone 17 Pro (iOS 26.4)\n" | ||
| 372 | + " DEF-456 simulator shutdown iPad Pro 13-inch (M4) (iOS 26.4)\n" | ||
| 373 | + "\n" | ||
| 374 | + "Target one with: pn run <platform> --device <identifier or name>\n" | ||
| 375 | + ) | ||
| 376 | + | ||
| 377 | + | ||
| 378 | + def _fake_list_devices( | ||
| 379 | + devices: List[Device], calls: Optional[List[Optional[str]]] = None | ||
| 380 | + ) -> Callable[..., List[Device]]: | ||
| 381 | + """Stand in for list_devices, filtering by platform and recording the argument.""" | ||
| 382 | + | ||
| 383 | + def _list(platform: Optional[str] = None) -> List[Device]: | ||
| 384 | + if calls is not None: | ||
| 385 | + calls.append(platform) | ||
| 386 | + return [device for device in devices if platform in (None, device.platform)] | ||
| 387 | + | ||
| 388 | + return _list | ||
| 389 | + | ||
| 390 | + | ||
| 391 | + def test_devices_json_emits_parseable_array( | ||
| 392 | + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] | ||
| 393 | + ) -> None: | ||
| 394 | + monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) | ||
| 395 | + | ||
| 396 | + # Returns rather than exiting, so a populated listing stays exit 0. | ||
| 397 | + pn_cli.devices_command(argparse.Namespace(platform=None, json=True)) | ||
| 398 | + | ||
| 399 | + payload = json.loads(capsys.readouterr().out) | ||
| 400 | + assert [entry["identifier"] for entry in payload] == [ | ||
| 401 | + "R5CT12345XYZ", | ||
| 402 | + "OFFLINE99", | ||
| 403 | + "ABC-123", | ||
| 404 | + "DEF-456", | ||
| 405 | + ] | ||
| 406 | + for entry in payload: | ||
| 407 | + assert set(entry) == { | ||
| 408 | + "platform", | ||
| 409 | + "kind", | ||
| 410 | + "identifier", | ||
| 411 | + "name", | ||
| 412 | + "os_version", | ||
| 413 | + "state", | ||
| 414 | + "is_ready", | ||
| 415 | + } | ||
| 416 | + ready = {entry["identifier"]: entry["is_ready"] for entry in payload} | ||
| 417 | + assert ready == {"R5CT12345XYZ": True, "OFFLINE99": False, "ABC-123": True, "DEF-456": True} | ||
| 418 | + | ||
| 419 | + # The two entries a naive `state == "booted"` check would get wrong. | ||
| 420 | + by_id = {entry["identifier"]: entry for entry in payload} | ||
| 421 | + assert by_id["DEF-456"]["state"] == "shutdown" and by_id["DEF-456"]["is_ready"] is True | ||
| 422 | + assert by_id["R5CT12345XYZ"]["state"] == "connected" and by_id["R5CT12345XYZ"]["is_ready"] is True | ||
| 423 | + assert by_id["OFFLINE99"]["kind"] == "device" and by_id["OFFLINE99"]["is_ready"] is False | ||
| 424 | + | ||
| 425 | + | ||
| 426 | + def test_devices_json_empty_prints_array_and_hints_to_stderr( | ||
| 427 | + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] | ||
| 428 | + ) -> None: | ||
| 429 | + monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices([])) | ||
| 430 | + | ||
| 431 | + # No SystemExit: an empty result is exit 0 under --json, unlike the table. | ||
| 432 | + pn_cli.devices_command(argparse.Namespace(platform=None, json=True)) | ||
| 433 | + | ||
| 434 | + captured = capsys.readouterr() | ||
| 435 | + assert captured.out == "[]\n" | ||
| 436 | + assert json.loads(captured.out) == [] | ||
| 437 | + assert captured.err == _NO_DEVICES_HINTS | ||
| 438 | + | ||
| 439 | + | ||
| 440 | + @pytest.mark.parametrize( | ||
| 441 | + ("platform", "expected"), | ||
| 442 | + [("android", ["R5CT12345XYZ", "OFFLINE99"]), ("ios", ["ABC-123", "DEF-456"])], | ||
| 443 | + ) | ||
| 444 | + def test_devices_json_respects_platform_filter( | ||
| 445 | + monkeypatch: pytest.MonkeyPatch, | ||
| 446 | + capsys: pytest.CaptureFixture[str], | ||
| 447 | + platform: str, | ||
| 448 | + expected: List[str], | ||
| 449 | + ) -> None: | ||
| 450 | + calls: List[Optional[str]] = [] | ||
| 451 | + monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES, calls)) | ||
| 452 | + | ||
| 453 | + pn_cli.devices_command(argparse.Namespace(platform=platform, json=True)) | ||
| 454 | + | ||
| 455 | + assert calls == [platform] | ||
| 456 | + payload = json.loads(capsys.readouterr().out) | ||
| 457 | + assert {entry["platform"] for entry in payload} == {platform} | ||
| 458 | + assert [entry["identifier"] for entry in payload] == expected | ||
| 459 | + | ||
| 460 | + | ||
| 461 | + @pytest.mark.parametrize("devices", [[], _FAKE_DEVICES], ids=["empty", "populated"]) | ||
| 462 | + def test_devices_json_keeps_human_text_off_stdout( | ||
| 463 | + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], devices: List[Device] | ||
| 464 | + ) -> None: | ||
| 465 | + monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(devices)) | ||
| 466 | + | ||
| 467 | + pn_cli.devices_command(argparse.Namespace(platform=None, json=True)) | ||
| 468 | + | ||
| 469 | + out = capsys.readouterr().out | ||
| 470 | + json.loads(out) | ||
| 471 | + # Every non-JSON source the table mode prints: the three hints, the column | ||
| 472 | + # header, each aligned row, and the trailer. | ||
| 473 | + for hint in _NO_DEVICES_HINTS.splitlines(): | ||
| 474 | + assert hint not in out | ||
| 475 | + assert "IDENTIFIER" not in out | ||
| 476 | + assert "Target one with:" not in out | ||
| 477 | + for device in devices: | ||
| 478 | + assert device.format() not in out | ||
| 479 | + | ||
| 480 | + | ||
| 481 | + def test_devices_human_output_unchanged_when_empty(tmp_path: Path) -> None: | ||
| 482 | + # An empty PATH makes adb/xcrun unresolvable, so every list_* call returns []. | ||
| 483 | + env = {**os.environ, "PATH": str(tmp_path)} | ||
| 484 | + result = run_pn(["devices"], str(tmp_path), env=env) | ||
| 485 | + | ||
| 486 | + assert result.returncode == 1 | ||
| 487 | + assert result.stdout == _NO_DEVICES_HINTS | ||
| 488 | + assert result.stderr == "" | ||
| 489 | + | ||
| 490 | + | ||
| 491 | + def test_devices_human_output_unchanged_when_populated( | ||
| 492 | + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] | ||
| 493 | + ) -> None: | ||
| 494 | + monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) | ||
| 495 | + | ||
| 496 | + pn_cli.devices_command(argparse.Namespace(platform=None, json=False)) | ||
| 497 | + | ||
| 498 | + captured = capsys.readouterr() | ||
| 499 | + assert captured.out == _DEVICE_TABLE | ||
| 500 | + assert captured.err == "" | ||
| 501 | + | ||
| 502 | + | ||
| 503 | + def test_devices_json_flag_is_wired_through_argparse(tmp_path: Path) -> None: | ||
| 504 | + # The other JSON tests call devices_command() directly, so only this one | ||
| 505 | + # proves the subparser actually accepts --json and routes it through. | ||
| 506 | + env = {**os.environ, "PATH": str(tmp_path)} | ||
| 507 | + result = run_pn(["devices", "--json"], str(tmp_path), env=env) | ||
| 508 | + | ||
| 509 | + assert result.returncode == 0 | ||
| 510 | + assert json.loads(result.stdout) == [] | ||
| 511 | + assert result.stdout == "[]\n" | ||
| 512 | + assert result.stderr == _NO_DEVICES_HINTS | ||
| 513 | + | ||
| 514 | + | ||
| 515 | + def test_devices_json_serializes_awkward_field_values( | ||
| 516 | + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] | ||
| 517 | + ) -> None: | ||
| 518 | + # Quotes, a newline, non-ASCII, and the two empty-string defaults all survive | ||
| 519 | + # the round trip today; nothing pinned them before. | ||
| 520 | + awkward = Device("android", "device", "SER!@#123", 'M\u00e1laga "Pixel"\nLab', "", "") | ||
| 521 | + monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices([awkward])) | ||
| 522 | + | ||
| 523 | + pn_cli.devices_command(argparse.Namespace(platform=None, json=True)) | ||
| 524 | + | ||
| 525 | + (entry,) = json.loads(capsys.readouterr().out) | ||
| 526 | + assert entry == { | ||
| 527 | + "platform": "android", | ||
| 528 | + "kind": "device", | ||
| 529 | + "identifier": "SER!@#123", | ||
| 530 | + "name": 'M\u00e1laga "Pixel"\nLab', | ||
| 531 | + "os_version": "", | ||
| 532 | + "state": "", | ||
| 533 | + "is_ready": False, | ||
| 534 | + } | ||
| 535 | + | ||
| 536 | + | ||
| 342 | 537 | def test_hot_reload_manifest_payload_maps_files_to_modules(tmp_path: Path) -> None: | |
| 343 | 538 | app_dir = tmp_path / "app" | |
| 344 | 539 | app_dir.mkdir() | |
| Back | FazBrowse Home | New Git URL |
0 commit comments