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

feat(cli): add --json output to pn doctor (#76) · pythonnative/pythonnative@375c3a2 · GitHub

Commit 375c3a2

Browse files
authored
feat(cli): add --json output to pn doctor (#76)
Refs: #37
1 parent fc61648 commit 375c3a2

6 files changed

Lines changed: 141 additions & 12 deletions

File tree

‎docs/api/cli.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ the documented behavior never drifts from the code.
1515
- `pn doctor [android|ios]`: diagnose the local toolchain and validate
1616
`pythonnative.toml`, including that a `python3.X` matching
1717
`[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.
1921
- `pn deps [android|ios]`: resolve `[requirements].packages` for every
2022
device target (iOS device, iOS Simulator, and each Android ABI)
2123
without installing anything, and report the wheel each package would

‎docs/guides/testing.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ See [Testing async code](async.md#testing-async-code).
246246

247247
## Going lower level
248248

249+
### A minimal fake backend
250+
249251
`render` is a thin layer over
250252
[`Reconciler`][pythonnative.reconciler.Reconciler] and
251253
[`FakeBackend`][pythonnative.testing.FakeBackend]. Tests that need to

‎src/pythonnative/cli/pn.py‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
- `pn init [name]`: scaffold a new project (``pythonnative.toml`` +
66
``app/``) into ``./name/``, or into the current directory when no name
77
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`.
910
- `pn deps [platform]`: resolve ``[requirements].packages`` for every
1011
device target and report which wheels would be used (or why a package
1112
can't be installed), without building anything.
@@ -264,26 +265,48 @@ def init_project(args: argparse.Namespace) -> None:
264265
# ======================================================================
265266

266267

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+
267282
def doctor_command(args: argparse.Namespace) -> None:
268283
"""Run toolchain/config diagnostics and exit non-zero on errors.
269284
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+
270290
Args:
271-
args: Parsed namespace with optional ``platform``.
291+
args: Parsed namespace with optional ``platform`` and ``json``.
272292
"""
273293
platform: Optional[str] = getattr(args, "platform", None)
294+
as_json: bool = getattr(args, "json", False)
274295
results = doctor_mod.run_doctor(Path.cwd(), platform=platform)
275-
print("PythonNative doctor\n")
276-
for result in results:
277-
print(result.format())
278296
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+
280308
if level == doctor_mod.ERROR:
281-
print("Found problems that will block builds. Address the [x] items above.")
282309
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.")
287310

288311

289312
def app_id_command(args: argparse.Namespace) -> None:
@@ -1094,6 +1117,9 @@ def _build_parser() -> argparse.ArgumentParser:
10941117

10951118
parser_doctor = subparsers.add_parser("doctor", help="Diagnose the local toolchain and config")
10961119
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+
)
10971123
parser_doctor.set_defaults(func=doctor_command)
10981124

10991125
parser_deps = subparsers.add_parser(

‎src/pythonnative/project/doctor.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ def format(self) -> str:
4848
suffix = f": {self.detail}" if self.detail else ""
4949
return f" {symbol} {self.name}{suffix}"
5050

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+
5155

5256
def _which_version(tool: str, version_args: List[str]) -> Optional[str]:
5357
path = shutil.which(tool)

‎tests/project/test_doctor.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,16 @@ def test_check_result_format() -> None:
112112
line = doctor.CheckResult("Thing", doctor.OK, "all good").format()
113113
assert "Thing" in line
114114
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+
}

‎tests/test_cli.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import pythonnative.cli.pn as pn_cli
1414
from pythonnative.project.devices import Device
15+
from pythonnative.project.doctor import CheckResult
1516

1617

1718
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(
798799
}
799800

800801

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+
801883
class _FakeCompletedProc:
802884
"""Stand-in for subprocess.Popen that returns immediately from wait()."""
803885

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL