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

GitHub Viewer

import argparse import json import os import shutil import subprocess import sys import tempfile from pathlib import Path from typing import Callable, Dict, List, Optional import pytest import pythonnative.cli.pn as pn_cli from pythonnative.project.devices import Device from pythonnative.project.doctor import CheckResult def run_pn(args: List[str], cwd: str, env: Optional[Dict[str, str]] = None) -> "subprocess.CompletedProcess[str]": cmd = [sys.executable, "-m", "pythonnative.cli.pn"] + args return subprocess.run(cmd, cwd=cwd, check=False, capture_output=True, text=True, env=env) def test_cli_version(tmp_path: Path) -> None: result = run_pn(["--version"], str(tmp_path)) assert result.returncode == 0 assert result.stdout.strip().startswith("pn ") def test_cli_short_version_flag(tmp_path: Path) -> None: result = run_pn(["-V"], str(tmp_path)) assert result.returncode == 0 assert result.stdout.strip().startswith("pn ") def test_cli_init_and_clean() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: result = run_pn(["init", "my_app"], tmpdir) assert result.returncode == 0, result.stderr project_dir = os.path.join(tmpdir, "my_app") assert os.path.isdir(os.path.join(project_dir, "app")) main_path = os.path.join(project_dir, "app", "main.py") assert os.path.isfile(main_path) content = Path(main_path).read_text(encoding="utf-8") assert "def App(" in content assert "Stack.Navigator" in content config_path = os.path.join(project_dir, "pythonnative.toml") assert os.path.isfile(config_path) toml_text = Path(config_path).read_text(encoding="utf-8") assert 'id = "com.example.my_app"' in toml_text assert os.path.isfile(os.path.join(project_dir, ".gitignore")) # The legacy JSON config and requirements.txt are no longer scaffolded. assert not os.path.exists(os.path.join(project_dir, "pythonnative.json")) # clean on empty build is a no-op result = run_pn(["clean"], tmpdir) assert result.returncode == 0, result.stderr os.makedirs(os.path.join(tmpdir, "build", "android"), exist_ok=True) result = run_pn(["clean"], tmpdir) assert result.returncode == 0, result.stderr assert not os.path.exists(os.path.join(tmpdir, "build")) finally: shutil.rmtree(tmpdir, ignore_errors=True) def test_cli_init_scaffold_renders_and_navigates() -> None: """The generated app must actually run: mount, tap, open Detail, go back.""" import importlib.util from pythonnative.testing import render tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: result = run_pn(["init", "my_app"], tmpdir) assert result.returncode == 0, result.stderr main_path = Path(tmpdir, "my_app", "app", "main.py") spec = importlib.util.spec_from_file_location("pn_scaffold_main", main_path) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) screen = render(module.App()) screen.press(screen.get_by_text("Tap me")) assert screen.get_by_text("Tapped 1 times") screen.press(screen.get_by_text("Open detail")) assert screen.get_by_text("Detail: count was 1") screen.press(screen.get_by_text("Back")) assert screen.get_by_text("Tapped 1 times") finally: shutil.rmtree(tmpdir, ignore_errors=True) def test_cli_init_refuses_overwrite() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: assert run_pn(["init", "my_app"], tmpdir).returncode == 0 result = run_pn(["init", "my_app"], tmpdir) assert result.returncode != 0 assert "Refusing to overwrite" in result.stdout assert run_pn(["init", "my_app", "--force"], tmpdir).returncode == 0 finally: shutil.rmtree(tmpdir, ignore_errors=True) def test_cli_init_creates_named_directory(tmp_path: Path) -> None: result = run_pn(["init", "my_app"], str(tmp_path)) assert result.returncode == 0, result.stderr assert "cd my_app" in result.stdout project_dir = os.path.join(str(tmp_path), "my_app") assert os.path.isfile(os.path.join(project_dir, "app", "main.py")) assert os.path.isfile(os.path.join(project_dir, "pythonnative.toml")) assert os.path.isfile(os.path.join(project_dir, ".gitignore")) toml_text = Path(os.path.join(project_dir, "pythonnative.toml")).read_text(encoding="utf-8") assert 'id = "com.example.my_app"' in toml_text # Nothing is scaffolded beside the project directory. assert os.listdir(str(tmp_path)) == ["my_app"] def test_cli_init_without_name_uses_cwd(tmp_path: Path) -> None: project_dir = tmp_path / "widgets" project_dir.mkdir() result = run_pn(["init"], str(project_dir)) assert result.returncode == 0, result.stderr assert "cd " not in result.stdout assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py")) assert os.path.isfile(os.path.join(str(project_dir), ".gitignore")) toml_text = Path(os.path.join(str(project_dir), "pythonnative.toml")).read_text(encoding="utf-8") assert 'id = "com.example.widgets"' in toml_text assert 'name = "widgets"' in toml_text def test_cli_init_refuses_non_empty_directory(tmp_path: Path) -> None: project_dir = tmp_path / "my_app" project_dir.mkdir() keeper = project_dir / "README.md" keeper.write_text("keep me\n", encoding="utf-8") result = run_pn(["init", "my_app"], str(tmp_path)) assert result.returncode != 0 assert "Refusing to overwrite" in result.stdout assert "non-empty directory" in result.stdout assert not os.path.exists(os.path.join(str(project_dir), "app")) assert keeper.read_text(encoding="utf-8") == "keep me\n" result = run_pn(["init", "my_app", "--force"], str(tmp_path)) assert result.returncode == 0, result.stderr assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py")) # --force scaffolds over the directory; it doesn't empty it first. assert keeper.read_text(encoding="utf-8") == "keep me\n" def test_cli_init_accepts_existing_empty_directory(tmp_path: Path) -> None: project_dir = tmp_path / "my_app" project_dir.mkdir() result = run_pn(["init", "my_app"], str(tmp_path)) assert result.returncode == 0, result.stderr assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py")) assert os.path.isfile(os.path.join(str(project_dir), "pythonnative.toml")) def test_cli_init_refuses_existing_file(tmp_path: Path) -> None: blocker = tmp_path / "my_app" blocker.write_text("not a project\n", encoding="utf-8") result = run_pn(["init", "my_app"], str(tmp_path)) assert result.returncode != 0 assert "Refusing to overwrite existing file" in result.stdout # --force can't turn a file into a directory, so it is refused too. result = run_pn(["init", "my_app", "--force"], str(tmp_path)) assert result.returncode != 0 assert "Refusing to overwrite existing file" in result.stdout assert blocker.read_text(encoding="utf-8") == "not a project\n" @pytest.mark.parametrize("name", ["{absolute}", "nested/my_app", "my_app/", ".", "..", "../", "a/.."]) def test_cli_init_rejects_path_like_names(tmp_path: Path, name: str) -> None: work_dir = tmp_path / "work" work_dir.mkdir() result = run_pn(["init", name.format(absolute=str(tmp_path / "elsewhere"))], str(work_dir)) assert result.returncode != 0 assert "Refusing to" in result.stdout assert "project name" in result.stdout # Nothing was created in the working directory or anywhere above it. assert os.listdir(str(work_dir)) == [] assert os.listdir(str(tmp_path)) == ["work"] def test_cli_init_force_does_not_escape_to_parent(tmp_path: Path) -> None: parent_config = tmp_path / "pythonnative.toml" parent_config.write_text("# hand-written\n", encoding="utf-8") work_dir = tmp_path / "work" work_dir.mkdir() result = run_pn(["init", "..", "--force"], str(work_dir)) assert result.returncode != 0 assert "Refusing to" in result.stdout # --force does not lift the single-name rule, so the parent is untouched. assert parent_config.read_text(encoding="utf-8") == "# hand-written\n" assert not os.path.exists(os.path.join(str(tmp_path), "app")) assert not os.path.exists(os.path.join(str(tmp_path), ".gitignore")) assert os.listdir(str(work_dir)) == [] @pytest.mark.parametrize("extra_args", [[], ["--force"]]) @pytest.mark.parametrize("populated", [True, False]) def test_cli_init_rejects_symlinked_target(tmp_path: Path, populated: bool, extra_args: List[str]) -> None: outside = tmp_path / "outside" outside.mkdir() outside_config = outside / "pythonnative.toml" if populated: outside_config.write_text("# hand-written\n", encoding="utf-8") before = sorted(os.listdir(str(outside))) work_dir = tmp_path / "work" work_dir.mkdir() os.symlink(str(outside), os.path.join(str(work_dir), "link")) result = run_pn(["init", "link"] + extra_args, str(work_dir)) assert result.returncode != 0 assert "Refusing to" in result.stdout # exists() and is_dir() follow symlinks, so the destination must stay untouched: # no new entries, and no rewrite of a config that was already there. assert sorted(os.listdir(str(outside))) == before if populated: assert outside_config.read_text(encoding="utf-8") == "# hand-written\n" assert os.listdir(str(work_dir)) == ["link"] _ILLEGAL_NAMES = [ pytest.param("MyApp", id="uppercase"), pytest.param("my app", id="space"), pytest.param('bad"name', id="quote"), pytest.param("9lives", id="leading-digit"), pytest.param("_leading", id="leading-underscore"), pytest.param("café", id="non-ascii"), pytest.param("", id="empty"), # `$` matches before a trailing newline, so `match` would let this through. pytest.param("app\n", id="trailing-newline"), ] @pytest.mark.parametrize("name", _ILLEGAL_NAMES) def test_cli_init_rejects_illegal_names(tmp_path: Path, name: str) -> None: result = run_pn(["init", name], str(tmp_path)) assert result.returncode != 0 assert "Invalid project name" in result.stdout assert "Try: " in result.stdout # Nothing is written, not even into the current directory. assert os.listdir(str(tmp_path)) == [] @pytest.mark.parametrize("name", _ILLEGAL_NAMES) def test_cli_init_force_does_not_lift_name_validation(tmp_path: Path, name: str) -> None: result = run_pn(["init", name, "--force"], str(tmp_path)) assert result.returncode != 0 assert "Invalid project name" in result.stdout assert os.listdir(str(tmp_path)) == [] def test_cli_init_suggestion_is_itself_a_legal_name() -> None: # The suggestion is only useful if the user can paste it straight back. awkward = [ "MyApp", "my app", 'bad"name', "9lives", "_leading", "café", "", "___", "---", "123", "..", "a/b", "ÄÖÜ", "\t", "app\n", "app\n\n", "\napp", "\n", "\x00", "\x7f", "sp ace", "trailing_", "-lead", "mixed_-CASE-99", "🙂", "a" * 200, ] for name in awkward: assert pn_cli._NAME_RE.fullmatch(pn_cli._sanitize_name(name)), name def test_cli_init_suggestion_is_stable_for_legal_names() -> None: for name in ["my_app", "my-app", "a", "a1", "hello-world_2"]: assert pn_cli._sanitize_name(name) == name def test_cli_init_without_name_accepts_a_cwd_that_fails_the_pattern(tmp_path: Path) -> None: # Validation covers the typed name only. A directory named MyProject is # extremely ordinary, and `pn init` there must keep working. project_dir = tmp_path / "MyProject" project_dir.mkdir() result = run_pn(["init"], str(project_dir)) assert result.returncode == 0, result.stdout toml_text = (project_dir / "pythonnative.toml").read_text(encoding="utf-8") assert 'name = "MyProject"' in toml_text assert 'id = "com.example.myproject"' in toml_text def test_cli_run_help_lists_flags() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: result = run_pn(["run", "--help"], tmpdir) assert result.returncode == 0, result.stderr assert "--no-logs" in result.stdout assert "--dev-server" in result.stdout assert "--prepare-only" in result.stdout finally: shutil.rmtree(tmpdir, ignore_errors=True) def test_cli_build_help_lists_debug() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: result = run_pn(["build", "--help"], tmpdir) assert result.returncode == 0, result.stderr assert "--debug" in result.stdout finally: shutil.rmtree(tmpdir, ignore_errors=True) def test_cli_run_rejects_unknown_flag() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: result = run_pn(["run", "android", "--does-not-exist"], tmpdir) assert result.returncode != 0 finally: shutil.rmtree(tmpdir, ignore_errors=True) def test_cli_run_without_config_errors() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: result = run_pn(["run", "android"], tmpdir) assert result.returncode != 0 assert "No pythonnative.toml" in (result.stdout + result.stderr) finally: shutil.rmtree(tmpdir, ignore_errors=True) def test_cli_app_id_resolves(tmp_path: Path) -> None: assert run_pn(["init", "my_app"], str(tmp_path)).returncode == 0 project_dir = str(tmp_path / "my_app") result = run_pn(["app-id", "android"], project_dir) assert result.returncode == 0, result.stderr assert result.stdout.strip() == "com.example.my_app" assert run_pn(["app-id", "ios"], project_dir).stdout.strip() == "com.example.my_app" def test_cli_doctor_runs(tmp_path: Path) -> None: assert run_pn(["init", "my_app"], str(tmp_path)).returncode == 0 result = run_pn(["doctor", "android"], str(tmp_path / "my_app")) assert "PythonNative doctor" in result.stdout # android-only doctor on a CI box without adb still produces warnings, not errors. assert result.returncode in (0, 1) def test_cli_run_prepare_only_android_and_ios() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: assert run_pn(["init", "my_app"], tmpdir).returncode == 0 project_dir = os.path.join(tmpdir, "my_app") result = run_pn(["run", "android", "--prepare-only", "--no-logs"], project_dir) assert result.returncode == 0, result.stderr android_root = os.path.join(project_dir, "build", "android", "android_template") assert os.path.isdir(android_root) # Package relocated to the configured application id. relocated = os.path.join( android_root, "app", "src", "main", "java", "com", "example", "my_app", "ScreenFragment.kt" ) assert os.path.isfile(relocated) assert not os.path.exists( os.path.join(android_root, "app", "src", "main", "java", "com", "pythonnative", "android_template") ) # App identity written into the Gradle config. gradle = Path(os.path.join(android_root, "app", "build.gradle")).read_text(encoding="utf-8") assert "com.example.my_app" in gradle result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], project_dir) assert result.returncode == 0, result.stderr ios_root = os.path.join(project_dir, "build", "ios", "ios_template") assert os.path.isdir(ios_root) info_plist = Path(os.path.join(ios_root, "ios_template", "Info.plist")).read_bytes() assert b"CFBundleDisplayName" in info_plist # Both package slices are staged; the build phase picks one per SDK. assert os.path.isdir(os.path.join(ios_root, "app_packages.iphoneos", "pythonnative")) assert os.path.isdir(os.path.join(ios_root, "app_packages.iphonesimulator", "pythonnative")) finally: shutil.rmtree(tmpdir, ignore_errors=True) # --------------------------------------------------------------------------- # pn deps # --------------------------------------------------------------------------- def _deps_project(tmp_path: Path, packages: str = '["httpx", "numpy"]') -> Path: (tmp_path / "app").mkdir() (tmp_path / "app" / "main.py").write_text("import pythonnative as pn\n", encoding="utf-8") (tmp_path / "pythonnative.toml").write_text( '[app]\nid = "com.acme.deps"\nname = "deps"\npython_version = "3.13"\n' f"[requirements]\npackages = {packages}\n", encoding="utf-8", ) return tmp_path _NUMPY_IOS_WHEEL = "https://pypi.anaconda.org/beeware/simple/numpy/numpy-2.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl" _HTTPX_WHEEL = "https://files.pythonhosted.org/packages/x/httpx-0.27.0-py3-none-any.whl" class _FakePipRunner: """Answers pip dry-run reports per target, failing where ``fail_when`` matches.""" def __init__(self, fail_when: Callable[[List[str]], bool] = lambda _args: False) -> None: self.fail_when = fail_when self.commands: List[List[str]] = [] def run(self, args, *, cwd=None, env=None, capture=False): # type: ignore[no-untyped-def] from pythonnative.project.builder import CommandResult self.commands.append(list(args)) if self.fail_when(list(args)): return CommandResult(1, "", "ERROR: No matching distribution found for numpy") report = { "install": [ {"metadata": {"name": "httpx", "version": "0.27.0"}, "download_info": {"url": _HTTPX_WHEEL}}, {"metadata": {"name": "numpy", "version": "2.2.1"}, "download_info": {"url": _NUMPY_IOS_WHEEL}}, ] } for package in report["install"]: package["download_info"]["archive_info"] = {"hashes": {"sha256": "a" * 64}} return CommandResult(0, json.dumps(report), "") def test_deps_command_reports_every_target( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.chdir(_deps_project(tmp_path)) runner = _FakePipRunner() monkeypatch.setattr(pn_cli.builder_mod, "SubprocessRunner", lambda: runner) monkeypatch.setattr(pn_cli.deps_mod, "host_simulator_arch", lambda: "arm64") pn_cli.deps_command(argparse.Namespace(platform=None, json=False, python=None)) out = capsys.readouterr().out assert "iOS device (arm64, iOS 13.0+)" in out assert "iOS Simulator (arm64, iOS 13.0+)" in out assert "Android arm64-v8a (API 24+)" in out and "Android x86_64 (API 24+)" in out assert "[ok] numpy 2.2.1" in out and "(BeeWare)" in out assert "All 4 targets resolved." in out # One pip dry-run per target, each pinned to the target's tags, never # the host's, plus one unconstrained reference run for downgrade detection. target_cmds = [c for c in runner.commands if "--platform" in c] assert len(target_cmds) == 4 and len(runner.commands) == 5 for cmd in target_cmds: assert "--dry-run" in cmd and "--only-binary=:all:" in cmd assert cmd[cmd.index("--python-version") + 1] == "3.13" android_cmds = [c for c in target_cmds if any(a.startswith("android_") for a in c)] assert len(android_cmds) == 2 @pytest.mark.parametrize("host_arch", ["arm64", "x86_64"]) @pytest.mark.parametrize("platform", [None, "ios", "android"]) def test_deps_lock_is_usable_on_both_mac_architectures( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, host_arch: str, platform: str | None ) -> None: from pythonnative.project import deps, lockfile from pythonnative.project.config import AppConfig monkeypatch.chdir(_deps_project(tmp_path)) monkeypatch.setattr(pn_cli.builder_mod, "SubprocessRunner", _FakePipRunner) monkeypatch.setattr(deps, "host_simulator_arch", lambda: host_arch) pn_cli.deps_command(argparse.Namespace(platform=platform, json=False, python=None, lock=True)) config = AppConfig.load(tmp_path) document = lockfile.read(config) assert document is not None assert len(document["targets"]) == {None: 5, "ios": 3, "android": 2}[platform] for arch in ("arm64", "x86_64"): monkeypatch.setattr(deps, "host_simulator_arch", lambda: arch) pinned = lockfile.requirements(config, deps.targets_for(config, platform)) assert "numpy==2.2.1" in pinned assert "--hash=sha256:" + "a" * 64 in pinned def test_deps_command_platform_filter_and_json( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.chdir(_deps_project(tmp_path)) runner = _FakePipRunner() monkeypatch.setattr(pn_cli.builder_mod, "SubprocessRunner", lambda: runner) pn_cli.deps_command(argparse.Namespace(platform="android", json=True, python="/opt/python3.13")) data = json.loads(capsys.readouterr().out) assert data["python_version"] == "3.13" assert data["requirements"] == ["httpx", "numpy"] assert [t["platform"] for t in data["targets"]] == ["android", "android"] assert all(t["ok"] for t in data["targets"]) assert data["targets"][0]["packages"][0]["name"] == "httpx" assert all(cmd[0] == "/opt/python3.13" for cmd in runner.commands) def test_deps_command_exits_nonzero_when_a_target_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.chdir(_deps_project(tmp_path)) runner = _FakePipRunner(fail_when=lambda args: any("iphonesimulator" in a for a in args)) monkeypatch.setattr(pn_cli.builder_mod, "SubprocessRunner", lambda: runner) with pytest.raises(SystemExit) as info: pn_cli.deps_command(argparse.Namespace(platform="ios", json=False, python=None)) assert info.value.code == 1 out = capsys.readouterr().out assert "[x] ERROR: No matching distribution found for numpy" in out assert "1 of 2 targets cannot be satisfied" in out def test_deps_command_without_requirements( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.chdir(_deps_project(tmp_path, packages="[]")) runner = _FakePipRunner() monkeypatch.setattr(pn_cli.builder_mod, "SubprocessRunner", lambda: runner) pn_cli.deps_command(argparse.Namespace(platform=None, json=False, python=None)) assert "nothing to resolve" in capsys.readouterr().out assert runner.commands == [] def test_cli_deps_help_lists_flags() -> None: result = run_pn(["deps", "--help"], os.getcwd()) assert result.returncode == 0 assert "--json" in result.stdout assert "--python" in result.stdout assert "android" in result.stdout and "ios" in result.stdout # --------------------------------------------------------------------------- # Pure helpers (no device required) # --------------------------------------------------------------------------- def test_booted_ios_udid_picks_first_booted_device(monkeypatch: pytest.MonkeyPatch) -> None: sample_json = ( '{"devices": {' '"com.apple.CoreSimulator.SimRuntime.iOS-26-4": [' '{"name": "iPhone 17 Pro", "state": "Booted", "udid": "abc-123"}' "]}}" ) class _StubResult: def __init__(self, stdout: str) -> None: self.stdout = stdout def _fake_run(cmd: List[str], **kwargs: object) -> _StubResult: assert cmd[:2] == ["xcrun", "simctl"] assert "booted" in cmd return _StubResult(sample_json) monkeypatch.setattr(pn_cli.subprocess, "run", _fake_run) assert pn_cli._booted_ios_udid() == "abc-123" def test_booted_ios_udid_returns_none_when_no_devices(monkeypatch: pytest.MonkeyPatch) -> None: class _StubResult: stdout = '{"devices": {}}' monkeypatch.setattr(pn_cli.subprocess, "run", lambda *a, **kw: _StubResult()) assert pn_cli._booted_ios_udid() is None def test_booted_ios_udid_handles_xcrun_missing(monkeypatch: pytest.MonkeyPatch) -> None: def _raise(*args: object, **kwargs: object) -> None: raise FileNotFoundError("xcrun missing") monkeypatch.setattr(pn_cli.subprocess, "run", _raise) assert pn_cli._booted_ios_udid() is None # `pn devices` tests run devices_command() in-process rather than through run_pn, # because run_pn spawns a child interpreter that monkeypatch can't reach: it would # re-import the real list_devices and shell out to adb/xcrun, making the result # depend on whatever hardware the test machine has attached. The one run_pn test # below empties PATH instead, so every list_* call hits FileNotFoundError. # A naive `state == "booted"` readiness check disagrees with is_ready on two of # these: the connected Android device (ready, not booted) and the shutdown # simulator (ready, because simulators boot on demand). _FAKE_DEVICES = [ Device("android", "device", "R5CT12345XYZ", "Pixel 8 Pro", "", "connected"), Device("android", "device", "OFFLINE99", "Galaxy S24", "", "offline"), Device("ios", "simulator", "ABC-123", "iPhone 17 Pro", "iOS 26.4", "booted"), Device("ios", "simulator", "DEF-456", "iPad Pro 13-inch (M4)", "iOS 26.4", "shutdown"), ] _NO_DEVICES_HINTS = ( "No devices found.\n" "Android: start an emulator or connect a device with USB debugging enabled.\n" "iOS: open Xcode once to install Simulators, or plug in a device.\n" ) _DEVICE_TABLE = ( " IDENTIFIER KIND STATE NAME\n" " R5CT12345XYZ device connected Pixel 8 Pro\n" " OFFLINE99 device offline Galaxy S24\n" " ABC-123 simulator booted iPhone 17 Pro (iOS 26.4)\n" " DEF-456 simulator shutdown iPad Pro 13-inch (M4) (iOS 26.4)\n" "\n" "Target one with: pn run --device \n" ) def _fake_list_devices( devices: List[Device], calls: Optional[List[Optional[str]]] = None ) -> Callable[..., List[Device]]: """Stand in for list_devices, filtering by platform and recording the argument.""" def _list(platform: Optional[str] = None) -> List[Device]: if calls is not None: calls.append(platform) return [device for device in devices if platform in (None, device.platform)] return _list def test_devices_json_emits_parseable_array( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) # Returns rather than exiting, so a populated listing stays exit 0. pn_cli.devices_command(argparse.Namespace(platform=None, json=True)) payload = json.loads(capsys.readouterr().out) assert [entry["identifier"] for entry in payload] == [ "R5CT12345XYZ", "OFFLINE99", "ABC-123", "DEF-456", ] for entry in payload: assert set(entry) == { "platform", "kind", "identifier", "name", "os_version", "state", "is_ready", } ready = {entry["identifier"]: entry["is_ready"] for entry in payload} assert ready == {"R5CT12345XYZ": True, "OFFLINE99": False, "ABC-123": True, "DEF-456": True} # The two entries a naive `state == "booted"` check would get wrong. by_id = {entry["identifier"]: entry for entry in payload} assert by_id["DEF-456"]["state"] == "shutdown" and by_id["DEF-456"]["is_ready"] is True assert by_id["R5CT12345XYZ"]["state"] == "connected" and by_id["R5CT12345XYZ"]["is_ready"] is True assert by_id["OFFLINE99"]["kind"] == "device" and by_id["OFFLINE99"]["is_ready"] is False def test_devices_json_empty_prints_array_and_hints_to_stderr( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices([])) # No SystemExit: an empty result is exit 0 under --json, unlike the table. pn_cli.devices_command(argparse.Namespace(platform=None, json=True)) captured = capsys.readouterr() assert captured.out == "[]\n" assert json.loads(captured.out) == [] assert captured.err == _NO_DEVICES_HINTS @pytest.mark.parametrize( ("platform", "expected"), [("android", ["R5CT12345XYZ", "OFFLINE99"]), ("ios", ["ABC-123", "DEF-456"])], ) def test_devices_json_respects_platform_filter( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], platform: str, expected: List[str], ) -> None: calls: List[Optional[str]] = [] monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES, calls)) pn_cli.devices_command(argparse.Namespace(platform=platform, json=True)) assert calls == [platform] payload = json.loads(capsys.readouterr().out) assert {entry["platform"] for entry in payload} == {platform} assert [entry["identifier"] for entry in payload] == expected @pytest.mark.parametrize("devices", [[], _FAKE_DEVICES], ids=["empty", "populated"]) def test_devices_json_keeps_human_text_off_stdout( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], devices: List[Device] ) -> None: monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(devices)) pn_cli.devices_command(argparse.Namespace(platform=None, json=True)) out = capsys.readouterr().out json.loads(out) # Every non-JSON source the table mode prints: the three hints, the column # header, each aligned row, and the trailer. for hint in _NO_DEVICES_HINTS.splitlines(): assert hint not in out assert "IDENTIFIER" not in out assert "Target one with:" not in out for device in devices: assert device.format() not in out def test_devices_human_output_unchanged_when_empty(tmp_path: Path) -> None: # An empty PATH makes adb/xcrun unresolvable, so every list_* call returns []. env = {**os.environ, "PATH": str(tmp_path)} result = run_pn(["devices"], str(tmp_path), env=env) assert result.returncode == 1 assert result.stdout == _NO_DEVICES_HINTS assert result.stderr == "" def test_devices_human_output_unchanged_when_populated( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) pn_cli.devices_command(argparse.Namespace(platform=None, json=False)) captured = capsys.readouterr() assert captured.out == _DEVICE_TABLE assert captured.err == "" def test_devices_json_flag_is_wired_through_argparse(tmp_path: Path) -> None: # The other JSON tests call devices_command() directly, so only this one # proves the subparser actually accepts --json and routes it through. env = {**os.environ, "PATH": str(tmp_path)} result = run_pn(["devices", "--json"], str(tmp_path), env=env) assert result.returncode == 0 assert json.loads(result.stdout) == [] assert result.stdout == "[]\n" assert result.stderr == _NO_DEVICES_HINTS def test_devices_json_serializes_awkward_field_values( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: # Quotes, a newline, non-ASCII, and the two empty-string defaults all survive # the round trip today; nothing pinned them before. awkward = Device("android", "device", "SER!@#123", 'M\u00e1laga "Pixel"\nLab', "", "") monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices([awkward])) pn_cli.devices_command(argparse.Namespace(platform=None, json=True)) (entry,) = json.loads(capsys.readouterr().out) assert entry == { "platform": "android", "kind": "device", "identifier": "SER!@#123", "name": 'M\u00e1laga "Pixel"\nLab', "os_version": "", "state": "", "is_ready": False, } # `pn doctor` tests mirror the `pn devices --json` ones: run doctor_command() # in-process with run_doctor stubbed, so the result doesn't depend on whatever # toolchain the test machine has. The one run_pn test drives a real project. _FAKE_CHECKS = [ CheckResult("pythonnative.toml", "ok", "com.example.demo (v0.1.0)"), CheckResult("Host Python", "ok", "3.13.0"), CheckResult("adb (Android platform-tools)", "warn", "not found on PATH"), ] _FAKE_CHECKS_WITH_ERROR = _FAKE_CHECKS + [CheckResult("Xcode (xcodebuild)", "error", "not found on PATH")] def _fake_run_doctor( results: List[CheckResult], calls: Optional[List[Optional[str]]] = None ) -> Callable[..., List[CheckResult]]: def _run(project_root: Path, *, platform: Optional[str] = None) -> List[CheckResult]: if calls is not None: calls.append(platform) return list(results) return _run def test_doctor_json_emits_parseable_array(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(pn_cli.doctor_mod, "run_doctor", _fake_run_doctor(_FAKE_CHECKS)) pn_cli.doctor_command(argparse.Namespace(platform=None, json=True)) payload = json.loads(capsys.readouterr().out) assert [entry["name"] for entry in payload] == [ "pythonnative.toml", "Host Python", "adb (Android platform-tools)", ] for entry in payload: assert set(entry) == {"name", "level", "message"} assert payload[2] == {"name": "adb (Android platform-tools)", "level": "warn", "message": "not found on PATH"} def test_doctor_json_keeps_human_text_off_stdout( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: calls: List[Optional[str]] = [] monkeypatch.setattr(pn_cli.doctor_mod, "run_doctor", _fake_run_doctor(_FAKE_CHECKS, calls)) pn_cli.doctor_command(argparse.Namespace(platform="android", json=True)) captured = capsys.readouterr() json.loads(captured.out) assert calls == ["android"] assert "PythonNative doctor" not in captured.out for check in _FAKE_CHECKS: assert check.format() not in captured.out assert "Ready, with warnings" not in captured.out assert "Ready, with warnings" in captured.err def test_doctor_json_exit_code_and_summary_track_worst_level( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setattr(pn_cli.doctor_mod, "run_doctor", _fake_run_doctor(_FAKE_CHECKS_WITH_ERROR)) with pytest.raises(SystemExit) as info: pn_cli.doctor_command(argparse.Namespace(platform=None, json=True)) assert info.value.code == 1 captured = capsys.readouterr() assert [entry["level"] for entry in json.loads(captured.out)][-1] == "error" assert "Found problems that will block builds" in captured.err assert "Found problems" not in captured.out def test_doctor_json_flag_is_wired_through_argparse(tmp_path: Path) -> None: assert run_pn(["init", "my_app"], str(tmp_path)).returncode == 0 result = run_pn(["doctor", "android", "--json"], str(tmp_path / "my_app")) assert result.returncode in (0, 1) payload = json.loads(result.stdout) assert {entry["level"] for entry in payload} int: return 0 def test_logs_command_android_sets_android_serial_for_device( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) monkeypatch.delenv("ANDROID_SERIAL", raising=False) monkeypatch.setattr(pn_cli, "_start_android_log_stream", lambda: _FakeCompletedProc()) pn_cli.logs_command(argparse.Namespace(platform="android", device="Pixel")) # pop() rather than a plain lookup: monkeypatch.delenv() on a variable that # was never set has nothing to restore, so the value the command exported # would otherwise leak into the rest of the session. assert os.environ.pop("ANDROID_SERIAL") == "R5CT12345XYZ" def test_logs_command_android_no_device_leaves_android_serial_unset( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) monkeypatch.delenv("ANDROID_SERIAL", raising=False) monkeypatch.setattr(pn_cli, "_start_android_log_stream", lambda: _FakeCompletedProc()) pn_cli.logs_command(argparse.Namespace(platform="android", device=None)) assert "ANDROID_SERIAL" not in os.environ def test_logs_command_ios_passes_resolved_udid(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) monkeypatch.setattr( pn_cli, "_load_config_or_exit", lambda project_dir=None: argparse.Namespace(bundle_id="com.example.app") ) captured: Dict[str, Optional[str]] = {} def _fake_start_ios_log_stream(bundle_id: str, *, udid: Optional[str] = None) -> _FakeCompletedProc: captured["bundle_id"] = bundle_id captured["udid"] = udid return _FakeCompletedProc() monkeypatch.setattr(pn_cli, "_start_ios_log_stream", _fake_start_ios_log_stream) pn_cli.logs_command(argparse.Namespace(platform="ios", device="iPhone 17")) assert captured == {"bundle_id": "com.example.app", "udid": "ABC-123"} def test_logs_command_ios_no_device_falls_back_to_booted( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) monkeypatch.setattr( pn_cli, "_load_config_or_exit", lambda project_dir=None: argparse.Namespace(bundle_id="com.example.app") ) captured: Dict[str, Optional[str]] = {} def _fake_start_ios_log_stream(bundle_id: str, *, udid: Optional[str] = None) -> _FakeCompletedProc: captured["udid"] = udid return _FakeCompletedProc() monkeypatch.setattr(pn_cli, "_start_ios_log_stream", _fake_start_ios_log_stream) pn_cli.logs_command(argparse.Namespace(platform="ios", device=None)) assert captured["udid"] is None def test_logs_command_ios_physical_device_prints_console_hint( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: physical = Device("ios", "device", "PHYS-1", "Owen's iPhone", "iOS 26.4", "connected") monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices([physical])) with pytest.raises(SystemExit) as exc_info: pn_cli.logs_command(argparse.Namespace(platform="ios", device="Owen's iPhone")) assert exc_info.value.code == 1 assert "Console.app" in capsys.readouterr().out def test_logs_command_ios_no_simulator_prints_console_hint( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: # Without --device and with no booted simulator, the user may well be # holding a physical device, so the Console.app pointer must still print. monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) monkeypatch.setattr( pn_cli, "_load_config_or_exit", lambda project_dir=None: argparse.Namespace(bundle_id="com.example.app") ) monkeypatch.setattr(pn_cli, "_start_ios_log_stream", lambda bundle_id, *, udid=None: None) with pytest.raises(SystemExit) as exc_info: pn_cli.logs_command(argparse.Namespace(platform="ios", device=None)) assert exc_info.value.code == 1 assert "Console.app" in capsys.readouterr().out def test_logs_command_bad_device_query_exits_with_hint( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setattr(pn_cli.devices_mod, "list_devices", _fake_list_devices(_FAKE_DEVICES)) with pytest.raises(SystemExit) as exc_info: pn_cli.logs_command(argparse.Namespace(platform="android", device="nope")) assert exc_info.value.code == 1 assert "no android device matches 'nope'" in capsys.readouterr().out def test_logs_device_flag_is_wired_through_argparse(tmp_path: Path) -> None: # The tests above call logs_command() directly; this one proves the # subparser actually accepts --device and routes it through. env = {**os.environ, "PATH": str(tmp_path)} result = run_pn(["logs", "android", "--device", "nope"], str(tmp_path), env=env) assert result.returncode == 1 assert "no android device matches 'nope'" in result.stdout # ====================================================================== # run: dev server discovery and native fingerprints # ====================================================================== def test_dev_server_url_for_targets(monkeypatch: pytest.MonkeyPatch) -> None: import pythonnative.devserver as devserver monkeypatch.setattr(devserver, "lan_addresses", lambda: ["192.168.1.20"]) sim = Device(platform="ios", identifier="SIM", name="iPhone", kind="simulator", state="Booted") phone = Device(platform="ios", identifier="PHONE", name="iPhone", kind="device", state="connected") assert pn_cli._dev_server_url_for("ios", None, 8765) == "ws://localhost:8765/ws?role=client" assert pn_cli._dev_server_url_for("ios", sim, 8765) == "ws://localhost:8765/ws?role=client" assert pn_cli._dev_server_url_for("android", None, 9000) == "ws://localhost:9000/ws?role=client" assert pn_cli._dev_server_url_for("ios", phone, 8765) == "ws://192.168.1.20:8765/ws?role=client" def test_running_dev_server_returns_none_when_nothing_listens() -> None: import socket with socket.socket() as probe: probe.bind(("127.0.0.1", 0)) free_port = probe.getsockname()[1] assert pn_cli._running_dev_server(free_port) is None def test_run_reuses_artifact_when_fingerprint_matches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An unchanged native fingerprint plus a live dev server skips the toolchain.""" from pythonnative.project import builder as builder_mod from pythonnative.project import fingerprint as fingerprint_mod from pythonnative.project.config import AppConfig, render_default_toml (tmp_path / "pythonnative.toml").write_text(render_default_toml(name="demo", app_id="com.example.demo")) (tmp_path / "app").mkdir() (tmp_path / "app" / "main.py").write_text("App = None\n") monkeypatch.chdir(tmp_path) config = AppConfig.load(tmp_path) builder = builder_mod.Builder(config, log=lambda *_: None) fingerprint = pn_cli._native_fingerprint( builder.config, "ios", builder, ios_sdks=("iphonesimulator",), dev_client=False ) artifact = tmp_path / "Demo.app" artifact.mkdir() fingerprint_mod.write_stamp(tmp_path / "build" / "ios", fingerprint, artifact=artifact) prepared_calls: List[str] = [] def fake_prepare(self: object, platform: str, **kw: object) -> None: prepared_calls.append(platform) monkeypatch.setattr(builder_mod.Builder, "prepare", fake_prepare) monkeypatch.setattr(pn_cli, "_running_dev_server", lambda port: {"port": port}) launched: Dict[str, object] = {} def fake_sim(builder: object, prepared: object, *, artifact: object, **kw: object) -> object: launched.update(prepared=prepared, artifact=artifact, server_url=kw["server_url"]) return artifact monkeypatch.setattr(pn_cli, "_run_ios_simulator", fake_sim) args = argparse.Namespace(platform="ios", device=None, no_logs=True, rebuild=False, dev_client=False) pn_cli.run_project(args) assert prepared_calls == [], "native toolchain must not run for an unchanged fingerprint" assert launched["prepared"] is None assert launched["artifact"] == artifact assert launched["server_url"] == "ws://localhost:8765/ws?role=client" def test_run_rebuilds_without_a_dev_server(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Without `pn start` the bundled sources are all the app has, so it must be rebuilt.""" from pythonnative.project import builder as builder_mod from pythonnative.project import fingerprint as fingerprint_mod from pythonnative.project.config import AppConfig, render_default_toml (tmp_path / "pythonnative.toml").write_text(render_default_toml(name="demo", app_id="com.example.demo")) (tmp_path / "app").mkdir() (tmp_path / "app" / "main.py").write_text("App = None\n") monkeypatch.chdir(tmp_path) config = AppConfig.load(tmp_path) builder = builder_mod.Builder(config, log=lambda *_: None) fingerprint = pn_cli._native_fingerprint( builder.config, "ios", builder, ios_sdks=("iphonesimulator",), dev_client=False ) artifact = tmp_path / "Demo.app" artifact.mkdir() fingerprint_mod.write_stamp(tmp_path / "build" / "ios", fingerprint, artifact=artifact) prepared = builder_mod.PreparedProject( platform="ios", build_dir=tmp_path / "build" / "ios", project_dir=tmp_path, app_id="x" ) monkeypatch.setattr(builder_mod.Builder, "prepare", lambda self, platform, **kw: prepared) monkeypatch.setattr(pn_cli, "_running_dev_server", lambda port: None) seen: Dict[str, object] = {} def fake_sim(builder: object, prepared_arg: object, *, artifact: object, **kw: object) -> object: seen.update(prepared=prepared_arg, server_url=kw["server_url"]) return artifact monkeypatch.setattr(pn_cli, "_run_ios_simulator", fake_sim) pn_cli.run_project(argparse.Namespace(platform="ios", device=None, no_logs=True, rebuild=False, dev_client=False)) assert seen["prepared"] is prepared assert seen["server_url"] is None def test_start_help_lists_dev_server_flags(tmp_path: Path) -> None: result = run_pn(["start", "--help"], str(tmp_path)) assert result.returncode == 0 assert "--port" in result.stdout assert "--open" in result.stdout result = run_pn(["run", "--help"], str(tmp_path)) assert "--dev-client" in result.stdout assert "--rebuild" in result.stdout assert "--hot-reload" not in result.stdout

Back | FazBrowse Home | New Git URL