FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(cli): add --json output to pn devices (#30) · pythonnative/pythonnative@4efea4d · GitHub

Commit 4efea4d

Browse files
feat(cli): add --json output to pn devices (#30)
Refs: #22
1 parent de1713d commit 4efea4d

4 files changed

Lines changed: 253 additions & 11 deletions

File tree

‎docs/api/cli.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ the documented behavior never drifts from the code.
1818
`--width`, `--height`, `--title`, `--no-hot-reload`. See the
1919
[Desktop preview guide](../guides/desktop-preview.md).
2020
- `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.
2224
- `pn run android|ios`: build and run on a connected device or
2325
simulator. Flags: `--device` (target a specific device by identifier
2426
or name), `--prepare-only`, `--hot-reload`, `--no-logs`.
@@ -31,6 +33,7 @@ the documented behavior never drifts from the code.
3133
- `pn app-id android|ios`: print the resolved application id (Android)
3234
or bundle id (iOS), handy for scripts and CI.
3335
- `pn clean`: remove the local `build/` directory.
36+
- `pn --version` (`-V`): print the installed PythonNative version.
3437

3538
::: pythonnative.cli.pn
3639
options:

‎src/pythonnative/cli/pn.py‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
- `pn preview [component]`: render the app in a desktop (Tkinter) window
1010
with Fast Refresh, the fast inner dev loop, no device required.
1111
- `pn devices [platform]`: list connected devices, emulators, and
12-
simulators.
12+
simulators, as a table or as JSON with `--json`.
1313
- `pn run android|ios [--device D]`: stage + build + install + launch on
1414
a device, emulator, or simulator, with optional on-device hot reload.
1515
- `pn logs android|ios`: stream logs from the running app without
@@ -38,7 +38,7 @@
3838
import time
3939
from importlib.metadata import version as pkg_version
4040
from pathlib import Path
41-
from typing import Any, Dict, List, Optional
41+
from typing import Any, Dict, List, Optional, TextIO
4242

4343
from ..project import builder as builder_mod
4444
from ..project import devices as devices_mod
@@ -298,18 +298,46 @@ def _preview_entry(project_dir: Path) -> str:
298298
# ======================================================================
299299

300300

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+
301315
def devices_command(args: argparse.Namespace) -> None:
302316
"""List connected devices, emulators, and simulators.
303317
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+
304326
Args:
305-
args: Parsed namespace with optional ``platform``.
327+
args: Parsed namespace with optional ``platform`` and ``json``.
306328
"""
307329
platform: Optional[str] = getattr(args, "platform", None)
330+
as_json: bool = getattr(args, "json", False)
308331
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+
309339
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)
313341
sys.exit(1)
314342
print(f" {'IDENTIFIER':<40} {'KIND':<10} {'STATE':<10} NAME")
315343
for device in devices:
@@ -943,6 +971,9 @@ def _build_parser() -> argparse.ArgumentParser:
943971

944972
parser_devices = subparsers.add_parser("devices", help="List devices, emulators, and simulators")
945973
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+
)
946977
parser_devices.set_defaults(func=devices_command)
947978

948979
parser_run = subparsers.add_parser("run", help="Build, install, and launch on a device/simulator")

‎src/pythonnative/project/devices.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import re
1919
import subprocess
2020
import tempfile
21-
from dataclasses import dataclass
21+
from dataclasses import asdict, dataclass
2222
from pathlib import Path
2323
from typing import Any, Dict, List, Optional
2424

@@ -64,6 +64,19 @@ def is_ready(self) -> bool:
6464
return self.state in ("booted", "shutdown")
6565
return self.state in ("booted", "connected")
6666

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+
6780
def format(self) -> str:
6881
"""Return one aligned listing row for the CLI."""
6982
os_part = f" ({self.os_version})" if self.os_version else ""

‎tests/test_cli.py‎

Lines changed: 198 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1+
import argparse
2+
import json
13
import os
24
import shutil
35
import subprocess
46
import sys
57
import tempfile
68
from pathlib import Path
7-
from typing import List
9+
from typing import Callable, Dict, List, Optional
810

911
import pytest
1012

1113
import pythonnative.cli.pn as pn_cli
1214
import pythonnative.hot_reload as hot_reload_module
15+
from pythonnative.project.devices import Device
1316

1417

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]":
1619
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)
1821

1922

2023
def test_cli_version(tmp_path: Path) -> None:
@@ -339,6 +342,198 @@ def _raise(*args: object, **kwargs: object) -> None:
339342
assert pn_cli._booted_ios_udid() is None
340343

341344

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+
342537
def test_hot_reload_manifest_payload_maps_files_to_modules(tmp_path: Path) -> None:
343538
app_dir = tmp_path / "app"
344539
app_dir.mkdir()

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL