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

feat(cli): add --device flag to pn logs (#67) · pythonnative/pythonnative@6d3a171 · GitHub

Commit 6d3a171

Browse files
andauthored
feat(cli): add --device flag to pn logs (#67)
Refs: #38 Co-authored-by: Owen Carey <37121709+owenthcarey@users.noreply.github.com>
1 parent 9cb8f9b commit 6d3a171

3 files changed

Lines changed: 153 additions & 7 deletions

File tree

‎docs/api/cli.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ the documented behavior never drifts from the code.
2626
simulator. Flags: `--device` (target a specific device by identifier
2727
or name), `--prepare-only`, `--hot-reload`, `--no-logs`.
2828
- `pn logs android|ios`: stream logs from the running app without
29-
rebuilding.
29+
rebuilding. Flag: `--device` (target a specific device by identifier
30+
or name, same as `pn run`). Physical iOS devices aren't supported for
31+
log streaming; use Console.app or Xcode > Devices and Simulators.
3032
- `pn build android|ios`: build distributable artifacts (release by
3133
default). Flags: `--debug` for the debug variant, `--upload` to send
3234
an iOS release build to App Store Connect. See

‎src/pythonnative/cli/pn.py‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
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.
15-
- `pn logs android|ios`: stream logs from the running app without
16-
rebuilding.
15+
- `pn logs android|ios [--device D]`: stream logs from the running app
16+
without rebuilding.
1717
- `pn build android|ios`: produce standalone artifacts (signed APK/AAB,
1818
or an iOS archive/IPA, optionally uploaded to App Store Connect).
1919
- `pn app-id android|ios`: print the resolved application/bundle id
@@ -626,10 +626,15 @@ def logs_command(args: argparse.Namespace) -> None:
626626
"""Stream logs from the running app without rebuilding.
627627
628628
Args:
629-
args: Parsed namespace (``platform``).
629+
args: Parsed namespace (``platform``, ``device``).
630630
"""
631631
platform: str = args.platform
632+
device = _resolve_device(platform, getattr(args, "device", None))
632633
if platform == "android":
634+
if device is not None:
635+
# Both adb and logcat below honor ANDROID_SERIAL, so exporting
636+
# it targets the whole log stream at the chosen device.
637+
os.environ["ANDROID_SERIAL"] = device.identifier
633638
proc = _start_android_log_stream()
634639
if proc is None:
635640
sys.exit(1)
@@ -643,8 +648,12 @@ def logs_command(args: argparse.Namespace) -> None:
643648

644649
# iOS: relaunch the app on the booted simulator with a console PTY so
645650
# Python's stdout/stderr stream to this terminal.
651+
if device is not None and device.kind == "device":
652+
print("For a physical device, use Console.app or Xcode > Devices and Simulators.")
653+
sys.exit(1)
646654
config = _load_config_or_exit()
647-
proc = _start_ios_log_stream(config.bundle_id)
655+
udid = device.identifier if device is not None else None
656+
proc = _start_ios_log_stream(config.bundle_id, udid=udid)
648657
if proc is None:
649658
print("For a physical device, use Console.app or Xcode > Devices and Simulators.")
650659
sys.exit(1)
@@ -762,16 +771,19 @@ def _select_ios_simulator() -> Optional[str]:
762771
return None
763772

764773

765-
def _start_ios_log_stream(bundle_id: str) -> Optional[subprocess.Popen]:
774+
def _start_ios_log_stream(bundle_id: str, *, udid: Optional[str] = None) -> Optional[subprocess.Popen]:
766775
"""Re-launch the iOS app with a console PTY so its stdio streams here.
767776
768777
Args:
769778
bundle_id: The app's bundle identifier.
779+
udid: A specific simulator UDID to target. Falls back to the
780+
booted simulator when not given.
770781
771782
Returns:
772783
The launched process, or ``None`` when no simulator is booted.
773784
"""
774-
udid = _booted_ios_udid()
785+
if udid is None:
786+
udid = _booted_ios_udid()
775787
if udid is None:
776788
print("Note: no booted iOS Simulator found; skipping log streaming.")
777789
return None
@@ -1044,6 +1056,12 @@ def _build_parser() -> argparse.ArgumentParser:
10441056

10451057
parser_logs = subparsers.add_parser("logs", help="Stream logs from the running app")
10461058
parser_logs.add_argument("platform", choices=["android", "ios"])
1059+
parser_logs.add_argument(
1060+
"--device",
1061+
"-d",
1062+
help="Target device: an identifier or name from 'pn devices' "
1063+
"(physical iOS devices aren't supported for log streaming)",
1064+
)
10471065
parser_logs.set_defaults(func=logs_command)
10481066

10491067
parser_build = subparsers.add_parser("build", help="Build distributable artifacts")

‎tests/test_cli.py‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,132 @@ def test_devices_json_serializes_awkward_field_values(
620620
}
621621

622622

623+
class _FakeCompletedProc:
624+
"""Stand-in for subprocess.Popen that returns immediately from wait()."""
625+
626+
def wait(self) -> int:
627+
return 0
628+
629+
630+
def test_logs_command_android_sets_android_serial_for_device(
631+
monkeypatch: pytest.MonkeyPatch,
632+
) -> None:
633+
monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES))
634+
monkeypatch.delenv("ANDROID_SERIAL", raising=False)
635+
monkeypatch.setattr(pn_cli, "_start_android_log_stream", lambda: _FakeCompletedProc())
636+
637+
pn_cli.logs_command(argparse.Namespace(platform="android", device="Pixel"))
638+
639+
# pop() rather than a plain lookup: monkeypatch.delenv() on a variable that
640+
# was never set has nothing to restore, so the value the command exported
641+
# would otherwise leak into the rest of the session.
642+
assert os.environ.pop("ANDROID_SERIAL") == "R5CT12345XYZ"
643+
644+
645+
def test_logs_command_android_no_device_leaves_android_serial_unset(
646+
monkeypatch: pytest.MonkeyPatch,
647+
) -> None:
648+
monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES))
649+
monkeypatch.delenv("ANDROID_SERIAL", raising=False)
650+
monkeypatch.setattr(pn_cli, "_start_android_log_stream", lambda: _FakeCompletedProc())
651+
652+
pn_cli.logs_command(argparse.Namespace(platform="android", device=None))
653+
654+
assert "ANDROID_SERIAL" not in os.environ
655+
656+
657+
def test_logs_command_ios_passes_resolved_udid(monkeypatch: pytest.MonkeyPatch) -> None:
658+
monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES))
659+
monkeypatch.setattr(
660+
pn_cli, "_load_config_or_exit", lambda project_dir=None: argparse.Namespace(bundle_id="com.example.app")
661+
)
662+
captured: Dict[str, Optional[str]] = {}
663+
664+
def _fake_start_ios_log_stream(bundle_id: str, *, udid: Optional[str] = None) -> _FakeCompletedProc:
665+
captured["bundle_id"] = bundle_id
666+
captured["udid"] = udid
667+
return _FakeCompletedProc()
668+
669+
monkeypatch.setattr(pn_cli, "_start_ios_log_stream", _fake_start_ios_log_stream)
670+
671+
pn_cli.logs_command(argparse.Namespace(platform="ios", device="iPhone 17"))
672+
673+
assert captured == {"bundle_id": "com.example.app", "udid": "ABC-123"}
674+
675+
676+
def test_logs_command_ios_no_device_falls_back_to_booted(
677+
monkeypatch: pytest.MonkeyPatch,
678+
) -> None:
679+
monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES))
680+
monkeypatch.setattr(
681+
pn_cli, "_load_config_or_exit", lambda project_dir=None: argparse.Namespace(bundle_id="com.example.app")
682+
)
683+
captured: Dict[str, Optional[str]] = {}
684+
685+
def _fake_start_ios_log_stream(bundle_id: str, *, udid: Optional[str] = None) -> _FakeCompletedProc:
686+
captured["udid"] = udid
687+
return _FakeCompletedProc()
688+
689+
monkeypatch.setattr(pn_cli, "_start_ios_log_stream", _fake_start_ios_log_stream)
690+
691+
pn_cli.logs_command(argparse.Namespace(platform="ios", device=None))
692+
693+
assert captured["udid"] is None
694+
695+
696+
def test_logs_command_ios_physical_device_prints_console_hint(
697+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
698+
) -> None:
699+
physical = Device("ios", "device", "PHYS-1", "Owen's iPhone", "iOS 26.4", "connected")
700+
monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices([physical]))
701+
702+
with pytest.raises(SystemExit) as exc_info:
703+
pn_cli.logs_command(argparse.Namespace(platform="ios", device="Owen's iPhone"))
704+
705+
assert exc_info.value.code == 1
706+
assert "Console.app" in capsys.readouterr().out
707+
708+
709+
def test_logs_command_ios_no_simulator_prints_console_hint(
710+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
711+
) -> None:
712+
# Without --device and with no booted simulator, the user may well be
713+
# holding a physical device, so the Console.app pointer must still print.
714+
monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES))
715+
monkeypatch.setattr(
716+
pn_cli, "_load_config_or_exit", lambda project_dir=None: argparse.Namespace(bundle_id="com.example.app")
717+
)
718+
monkeypatch.setattr(pn_cli, "_start_ios_log_stream", lambda bundle_id, *, udid=None: None)
719+
720+
with pytest.raises(SystemExit) as exc_info:
721+
pn_cli.logs_command(argparse.Namespace(platform="ios", device=None))
722+
723+
assert exc_info.value.code == 1
724+
assert "Console.app" in capsys.readouterr().out
725+
726+
727+
def test_logs_command_bad_device_query_exits_with_hint(
728+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
729+
) -> None:
730+
monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES))
731+
732+
with pytest.raises(SystemExit) as exc_info:
733+
pn_cli.logs_command(argparse.Namespace(platform="android", device="nope"))
734+
735+
assert exc_info.value.code == 1
736+
assert "no android device matches 'nope'" in capsys.readouterr().out
737+
738+
739+
def test_logs_device_flag_is_wired_through_argparse(tmp_path: Path) -> None:
740+
# The tests above call logs_command() directly; this one proves the
741+
# subparser actually accepts --device and routes it through.
742+
env = {**os.environ, "PATH": str(tmp_path)}
743+
result = run_pn(["logs", "android", "--device", "nope"], str(tmp_path), env=env)
744+
745+
assert result.returncode == 1
746+
assert "no android device matches 'nope'" in result.stdout
747+
748+
623749
def test_hot_reload_manifest_payload_maps_files_to_modules(tmp_path: Path) -> None:
624750
app_dir = tmp_path / "app"
625751
app_dir.mkdir()

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL