| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -15,7 +15,9 @@ the documented behavior never drifts from the code. | |||
| 15 | 15 | - `pn doctor [android|ios]`: diagnose the local toolchain and validate | |
| 16 | 16 | `pythonnative.toml`, including that a `python3.X` matching | |
| 17 | 17 | `[app].python_version` is available for package resolution. Exits | |
| 18 | - non-zero when something will block a build. | ||
| 18 | + non-zero when something will block a build. Flag: `--json` to print a | ||
| 19 | + JSON array of check results to stdout for scripting; the verdict line | ||
| 20 | + goes to stderr instead, and the exit status is unchanged. | ||
| 19 | 21 | - `pn deps [android|ios]`: resolve `[requirements].packages` for every | |
| 20 | 22 | device target (iOS device, iOS Simulator, and each Android ABI) | |
| 21 | 23 | without installing anything, and report the wheel each package would | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -246,6 +246,8 @@ See [Testing async code](async.md#testing-async-code). | |||
| 246 | 246 | ||
| 247 | 247 | ## Going lower level | |
| 248 | 248 | ||
| 249 | + ### A minimal fake backend | ||
| 250 | + | ||
| 249 | 251 | `render` is a thin layer over | |
| 250 | 252 | [`Reconciler`][pythonnative.reconciler.Reconciler] and | |
| 251 | 253 | [`FakeBackend`][pythonnative.testing.FakeBackend]. Tests that need to | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -5,7 +5,8 @@ | |||
| 5 | 5 | - `pn init [name]`: scaffold a new project (``pythonnative.toml`` + | |
| 6 | 6 | ``app/``) into ``./name/``, or into the current directory when no name | |
| 7 | 7 | is given. | |
| 8 | - - `pn doctor [platform]`: diagnose the local toolchain and config. | ||
| 8 | + - `pn doctor [platform]`: diagnose the local toolchain and config, as a | ||
| 9 | + report or as JSON with `--json`. | ||
| 9 | 10 | - `pn deps [platform]`: resolve ``[requirements].packages`` for every | |
| 10 | 11 | device target and report which wheels would be used (or why a package | |
| 11 | 12 | can't be installed), without building anything. | |
@@ -264,26 +265,48 @@ def init_project(args: argparse.Namespace) -> None: | |||
| 264 | 265 | # ====================================================================== | |
| 265 | 266 | ||
| 266 | 267 | ||
| 268 | + def _print_doctor_summary(level: str, stream: TextIO) -> None: | ||
| 269 | + """Print the one-line verdict for ``level`` to ``stream``. | ||
| 270 | + | ||
| 271 | + Shared by both output modes so the wording can't drift between the | ||
| 272 | + report (stdout) and ``--json`` (stderr). | ||
| 273 | + """ | ||
| 274 | + if level == doctor_mod.ERROR: | ||
| 275 | + print("Found problems that will block builds. Address the [x] items above.", file=stream) | ||
| 276 | + elif level == doctor_mod.WARN: | ||
| 277 | + print("Ready, with warnings. Review the [!] items above.", file=stream) | ||
| 278 | + else: | ||
| 279 | + print("Everything looks good.", file=stream) | ||
| 280 | + | ||
| 281 | + | ||
| 267 | 282 | def doctor_command(args: argparse.Namespace) -> None: | |
| 268 | 283 | """Run toolchain/config diagnostics and exit non-zero on errors. | |
| 269 | 284 | ||
| 285 | + With ``--json``, stdout carries a JSON array and nothing else, one | ||
| 286 | + object per check (see ``CheckResult.to_dict``), and the verdict line | ||
| 287 | + goes to stderr. The exit status stays keyed to the worst check level | ||
| 288 | + either way. | ||
| 289 | + | ||
| 270 | 290 | Args: | |
| 271 | - args: Parsed namespace with optional ``platform``. | ||
| 291 | + args: Parsed namespace with optional ``platform`` and ``json``. | ||
| 272 | 292 | """ | |
| 273 | 293 | platform: Optional[str] = getattr(args, "platform", None) | |
| 294 | + as_json: bool = getattr(args, "json", False) | ||
| 274 | 295 | results = doctor_mod.run_doctor(Path.cwd(), platform=platform) | |
| 275 | - print("PythonNative doctor\n") | ||
| 276 | - for result in results: | ||
| 277 | - print(result.format()) | ||
| 278 | 296 | level = doctor_mod.worst_level(results) | |
| 279 | - print() | ||
| 297 | + | ||
| 298 | + if as_json: | ||
| 299 | + print(json.dumps([result.to_dict() for result in results], indent=2)) | ||
| 300 | + _print_doctor_summary(level, sys.stderr) | ||
| 301 | + else: | ||
| 302 | + print("PythonNative doctor\n") | ||
| 303 | + for result in results: | ||
| 304 | + print(result.format()) | ||
| 305 | + print() | ||
| 306 | + _print_doctor_summary(level, sys.stdout) | ||
| 307 | + | ||
| 280 | 308 | if level == doctor_mod.ERROR: | |
| 281 | - print("Found problems that will block builds. Address the [x] items above.") | ||
| 282 | 309 | sys.exit(1) | |
| 283 | - if level == doctor_mod.WARN: | ||
| 284 | - print("Ready, with warnings. Review the [!] items above.") | ||
| 285 | - else: | ||
| 286 | - print("Everything looks good.") | ||
| 287 | 310 | ||
| 288 | 311 | ||
| 289 | 312 | def app_id_command(args: argparse.Namespace) -> None: | |
@@ -1094,6 +1117,9 @@ def _build_parser() -> argparse.ArgumentParser: | |||
| 1094 | 1117 | ||
| 1095 | 1118 | parser_doctor = subparsers.add_parser("doctor", help="Diagnose the local toolchain and config") | |
| 1096 | 1119 | parser_doctor.add_argument("platform", nargs="?", choices=["android", "ios"], help="Restrict checks to a platform") | |
| 1120 | + parser_doctor.add_argument( | ||
| 1121 | + "--json", action="store_true", help="Print a JSON array to stdout for scripting (the verdict goes to stderr)" | ||
| 1122 | + ) | ||
| 1097 | 1123 | parser_doctor.set_defaults(func=doctor_command) | |
| 1098 | 1124 | ||
| 1099 | 1125 | parser_deps = subparsers.add_parser( | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -48,6 +48,10 @@ def format(self) -> str: | |||
| 48 | 48 | suffix = f": {self.detail}" if self.detail else "" | |
| 49 | 49 | return f" {symbol} {self.name}{suffix}" | |
| 50 | 50 | ||
| 51 | + def to_dict(self) -> dict[str, str]: | ||
| 52 | + """Return a JSON-serializable view of this check for ``--json``.""" | ||
| 53 | + return {"name": self.name, "level": self.level, "message": self.detail} | ||
| 54 | + | ||
| 51 | 55 | ||
| 52 | 56 | def _which_version(tool: str, version_args: List[str]) -> Optional[str]: | |
| 53 | 57 | path = shutil.which(tool) | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -112,3 +112,16 @@ def test_check_result_format() -> None: | |||
| 112 | 112 | line = doctor.CheckResult("Thing", doctor.OK, "all good").format() | |
| 113 | 113 | assert "Thing" in line | |
| 114 | 114 | assert "all good" in line | |
| 115 | + | ||
| 116 | + | ||
| 117 | + def test_check_result_to_dict() -> None: | ||
| 118 | + assert doctor.CheckResult("Thing", doctor.WARN, "heads up").to_dict() == { | ||
| 119 | + "name": "Thing", | ||
| 120 | + "level": doctor.WARN, | ||
| 121 | + "message": "heads up", | ||
| 122 | + } | ||
| 123 | + assert doctor.CheckResult("Bare", doctor.OK).to_dict() == { | ||
| 124 | + "name": "Bare", | ||
| 125 | + "level": doctor.OK, | ||
| 126 | + "message": "", | ||
| 127 | + } | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -12,6 +12,7 @@ | |||
| 12 | 12 | ||
| 13 | 13 | import pythonnative.cli.pn as pn_cli | |
| 14 | 14 | from pythonnative.project.devices import Device | |
| 15 | + from pythonnative.project.doctor import CheckResult | ||
| 15 | 16 | ||
| 16 | 17 | ||
| 17 | 18 | def run_pn(args: List[str], cwd: str, env: Optional[Dict[str, str]] = None) -> "subprocess.CompletedProcess[str]": | |
@@ -798,6 +799,87 @@ def test_devices_json_serializes_awkward_field_values( | |||
| 798 | 799 | } | |
| 799 | 800 | ||
| 800 | 801 | ||
| 802 | + # `pn doctor` tests mirror the `pn devices --json` ones: run doctor_command() | ||
| 803 | + # in-process with run_doctor stubbed, so the result doesn't depend on whatever | ||
| 804 | + # toolchain the test machine has. The one run_pn test drives a real project. | ||
| 805 | + _FAKE_CHECKS = [ | ||
| 806 | + CheckResult("pythonnative.toml", "ok", "com.example.demo (v0.1.0)"), | ||
| 807 | + CheckResult("Host Python", "ok", "3.13.0"), | ||
| 808 | + CheckResult("adb (Android platform-tools)", "warn", "not found on PATH"), | ||
| 809 | + ] | ||
| 810 | + | ||
| 811 | + _FAKE_CHECKS_WITH_ERROR = _FAKE_CHECKS + [CheckResult("Xcode (xcodebuild)", "error", "not found on PATH")] | ||
| 812 | + | ||
| 813 | + | ||
| 814 | + def _fake_run_doctor( | ||
| 815 | + results: List[CheckResult], calls: Optional[List[Optional[str]]] = None | ||
| 816 | + ) -> Callable[..., List[CheckResult]]: | ||
| 817 | + def _run(project_root: Path, *, platform: Optional[str] = None) -> List[CheckResult]: | ||
| 818 | + if calls is not None: | ||
| 819 | + calls.append(platform) | ||
| 820 | + return list(results) | ||
| 821 | + | ||
| 822 | + return _run | ||
| 823 | + | ||
| 824 | + | ||
| 825 | + def test_doctor_json_emits_parseable_array(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: | ||
| 826 | + monkeypatch.setattr(pn_cli.doctor_mod, "run_doctor", _fake_run_doctor(_FAKE_CHECKS)) | ||
| 827 | + | ||
| 828 | + pn_cli.doctor_command(argparse.Namespace(platform=None, json=True)) | ||
| 829 | + | ||
| 830 | + payload = json.loads(capsys.readouterr().out) | ||
| 831 | + assert [entry["name"] for entry in payload] == [ | ||
| 832 | + "pythonnative.toml", | ||
| 833 | + "Host Python", | ||
| 834 | + "adb (Android platform-tools)", | ||
| 835 | + ] | ||
| 836 | + for entry in payload: | ||
| 837 | + assert set(entry) == {"name", "level", "message"} | ||
| 838 | + assert payload[2] == {"name": "adb (Android platform-tools)", "level": "warn", "message": "not found on PATH"} | ||
| 839 | + | ||
| 840 | + | ||
| 841 | + def test_doctor_json_keeps_human_text_off_stdout( | ||
| 842 | + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] | ||
| 843 | + ) -> None: | ||
| 844 | + calls: List[Optional[str]] = [] | ||
| 845 | + monkeypatch.setattr(pn_cli.doctor_mod, "run_doctor", _fake_run_doctor(_FAKE_CHECKS, calls)) | ||
| 846 | + | ||
| 847 | + pn_cli.doctor_command(argparse.Namespace(platform="android", json=True)) | ||
| 848 | + | ||
| 849 | + captured = capsys.readouterr() | ||
| 850 | + json.loads(captured.out) | ||
| 851 | + assert calls == ["android"] | ||
| 852 | + assert "PythonNative doctor" not in captured.out | ||
| 853 | + for check in _FAKE_CHECKS: | ||
| 854 | + assert check.format() not in captured.out | ||
| 855 | + assert "Ready, with warnings" not in captured.out | ||
| 856 | + assert "Ready, with warnings" in captured.err | ||
| 857 | + | ||
| 858 | + | ||
| 859 | + def test_doctor_json_exit_code_and_summary_track_worst_level( | ||
| 860 | + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] | ||
| 861 | + ) -> None: | ||
| 862 | + monkeypatch.setattr(pn_cli.doctor_mod, "run_doctor", _fake_run_doctor(_FAKE_CHECKS_WITH_ERROR)) | ||
| 863 | + | ||
| 864 | + with pytest.raises(SystemExit) as info: | ||
| 865 | + pn_cli.doctor_command(argparse.Namespace(platform=None, json=True)) | ||
| 866 | + | ||
| 867 | + assert info.value.code == 1 | ||
| 868 | + captured = capsys.readouterr() | ||
| 869 | + assert [entry["level"] for entry in json.loads(captured.out)][-1] == "error" | ||
| 870 | + assert "Found problems that will block builds" in captured.err | ||
| 871 | + assert "Found problems" not in captured.out | ||
| 872 | + | ||
| 873 | + | ||
| 874 | + def test_doctor_json_flag_is_wired_through_argparse(tmp_path: Path) -> None: | ||
| 875 | + assert run_pn(["init", "my_app"], str(tmp_path)).returncode == 0 | ||
| 876 | + result = run_pn(["doctor", "android", "--json"], str(tmp_path / "my_app")) | ||
| 877 | + | ||
| 878 | + assert result.returncode in (0, 1) | ||
| 879 | + payload = json.loads(result.stdout) | ||
| 880 | + assert {entry["level"] for entry in payload} <= {"ok", "warn", "error", "info"} | ||
| 881 | + | ||
| 882 | + | ||
| 801 | 883 | class _FakeCompletedProc: | |
| 802 | 884 | """Stand-in for subprocess.Popen that returns immediately from wait().""" | |
| 803 | 885 | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments