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

feat(cli): add --json output to pn app-id (#77) · pythonnative/pythonnative@94e9190 · GitHub

Commit 94e9190

Browse files
feat(cli): add --json output to pn app-id (#77)
Refs: #56
1 parent a838a56 commit 94e9190

3 files changed

Lines changed: 144 additions & 13 deletions

File tree

‎docs/api/cli.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ the documented behavior never drifts from the code.
5757
an iOS release build to App Store Connect. See
5858
[Building for release](../guides/building-for-release.md).
5959
- `pn app-id android|ios`: print the resolved application id (Android)
60-
or bundle id (iOS), handy for scripts and CI.
60+
or bundle id (iOS), handy for scripts and CI. Flag: `--json` to print
61+
a `{"platform": "...", "app_id": "..."}` object to stdout for
62+
scripting; the config
63+
error goes to stderr instead, and a missing config still exits 1.
6164
- `pn clean`: remove the local `build/` directory.
6265
- `pn --version` (`-V`): print the installed PythonNative version.
6366

‎src/pythonnative/cli/pn.py‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
- `pn build android|ios`: produce standalone artifacts (signed APK/AAB,
2626
or an iOS archive/IPA, optionally uploaded to App Store Connect).
2727
- `pn app-id android|ios`: print the resolved application/bundle id
28-
(handy for scripts and CI).
28+
(handy for scripts and CI), as plain text or as JSON with `--json`.
2929
- `pn clean`: remove the local `build/` directory.
3030
3131
The heavy lifting lives in the ``pythonnative.project`` and
@@ -343,11 +343,27 @@ def doctor_command(args: argparse.Namespace) -> None:
343343
def app_id_command(args: argparse.Namespace) -> None:
344344
"""Print the resolved application id (Android) or bundle id (iOS).
345345
346+
With ``--json``, stdout carries one object,
347+
``{"platform": "...", "app_id": "..."}``, and nothing else; the config
348+
error goes to stderr instead so stdout stays parseable. Keys may be
349+
added to that object later, but none will be removed or renamed, so
350+
a caller reading ``app_id`` keeps working.
351+
352+
A missing or invalid config still exits 1, with or without the flag.
353+
That differs from ``pn devices --json``, which exits 0 on an empty
354+
result, because "no devices" is a valid answer to a query whereas
355+
an unreadable config means there is no id to report at all.
356+
346357
Args:
347-
args: Parsed namespace with ``platform``.
358+
args: Parsed namespace with ``platform`` and optional ``json``.
348359
"""
349-
config = _load_config_or_exit()
350-
print(config.application_id if args.platform == "android" else config.bundle_id)
360+
as_json: bool = getattr(args, "json", False)
361+
config = _load_config_or_exit(stream=sys.stderr if as_json else None)
362+
app_id = config.application_id if args.platform == "android" else config.bundle_id
363+
if as_json:
364+
print(json.dumps({"platform": args.platform, "app_id": app_id}, indent=2))
365+
return
366+
print(app_id)
351367

352368

353369
# ======================================================================
@@ -981,11 +997,22 @@ def clean_project(args: argparse.Namespace) -> None:
981997
# ======================================================================
982998

983999

984-
def _load_config_or_exit(project_dir: Optional[Path] = None) -> AppConfig:
1000+
def _load_config_or_exit(project_dir: Optional[Path] = None, *, stream: Optional[TextIO] = None) -> AppConfig:
1001+
"""Load the project config or exit 1, reporting the error on ``stream``.
1002+
1003+
Args:
1004+
project_dir: Project root; defaults to the current directory.
1005+
stream: Where to report a failure. Defaults to stdout, which is
1006+
what every caller wants except a machine-readable mode, where
1007+
an error on stdout would corrupt the document.
1008+
1009+
Returns:
1010+
The loaded config.
1011+
"""
9851012
try:
9861013
return AppConfig.load(project_dir or Path.cwd())
9871014
except ConfigError as exc:
988-
print(f"Error: {exc}")
1015+
print(f"Error: {exc}", file=stream or sys.stdout)
9891016
sys.exit(1)
9901017

9911018

@@ -1248,6 +1275,9 @@ def _add_server_args(sub: argparse.ArgumentParser) -> None:
12481275

12491276
parser_app_id = subparsers.add_parser("app-id", help="Print the resolved application/bundle id")
12501277
parser_app_id.add_argument("platform", choices=["android", "ios"], help="Target platform")
1278+
parser_app_id.add_argument(
1279+
"--json", action="store_true", help="Print a JSON object to stdout for scripting (errors go to stderr)"
1280+
)
12511281
parser_app_id.set_defaults(func=app_id_command)
12521282

12531283
parser_clean = subparsers.add_parser("clean", help="Remove the local build/ directory")

‎tests/test_cli.py‎

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -454,13 +454,111 @@ def test_cli_run_without_config_errors() -> None:
454454
shutil.rmtree(tmpdir, ignore_errors=True)
455455

456456

457-
def test_cli_app_id_resolves(tmp_path: Path) -> None:
458-
assert run_pn(["init", "my_app"], str(tmp_path)).returncode == 0
459-
project_dir = str(tmp_path / "my_app")
460-
result = run_pn(["app-id", "android"], project_dir)
457+
# A default `pn init` scaffold sets only `app.id`, so both platforms resolve to
458+
# the same string and a test built on it passes with the platform branch
459+
# inverted. These per-platform overrides make the two diverge.
460+
_APP_ID_TOML = """\
461+
[app]
462+
id = "com.example.base"
463+
name = "over"
464+
465+
[ios]
466+
bundle_id = "com.example.ios_override"
467+
468+
[android]
469+
application_id = "com.example.android_override"
470+
"""
471+
472+
_EXPECTED_APP_IDS = {
473+
"android": "com.example.android_override",
474+
"ios": "com.example.ios_override",
475+
}
476+
477+
478+
def _app_id_project(tmp_path: Path) -> str:
479+
project_dir = tmp_path / "proj"
480+
project_dir.mkdir()
481+
(project_dir / "pythonnative.toml").write_text(_APP_ID_TOML, encoding="utf-8")
482+
return str(project_dir)
483+
484+
485+
@pytest.mark.parametrize("platform", ["android", "ios"])
486+
def test_cli_app_id_resolves(tmp_path: Path, platform: str) -> None:
487+
project_dir = _app_id_project(tmp_path)
488+
489+
result = run_pn(["app-id", platform], project_dir)
490+
491+
assert result.returncode == 0, result.stderr
492+
# Unstripped, because scripts/run-e2e.sh captures this into a shell
493+
# variable: any extra line would corrupt APP_ID and only surface later
494+
# as a Maestro failure against a bundle id that doesn't exist.
495+
assert result.stdout == f"{_EXPECTED_APP_IDS[platform]}\n"
496+
assert result.stderr == ""
497+
498+
499+
@pytest.mark.parametrize("platform", ["android", "ios"])
500+
def test_cli_app_id_json(tmp_path: Path, platform: str) -> None:
501+
project_dir = _app_id_project(tmp_path)
502+
503+
result = run_pn(["app-id", platform, "--json"], project_dir)
504+
461505
assert result.returncode == 0, result.stderr
462-
assert result.stdout.strip() == "com.example.my_app"
463-
assert run_pn(["app-id", "ios"], project_dir).stdout.strip() == "com.example.my_app"
506+
payload = json.loads(result.stdout)
507+
assert payload == {"platform": platform, "app_id": _EXPECTED_APP_IDS[platform]}
508+
assert result.stderr == ""
509+
510+
511+
def test_cli_app_id_json_is_indented(tmp_path: Path) -> None:
512+
# json.loads is blind to formatting, so nothing else here would notice
513+
# indent=2 being dropped. Scripts consume the bytes, and the docstring
514+
# promises an additive-only contract, so pin the exact shape once.
515+
project_dir = _app_id_project(tmp_path)
516+
517+
result = run_pn(["app-id", "android", "--json"], project_dir)
518+
519+
assert result.stdout == ("{\n" ' "platform": "android",\n' ' "app_id": "com.example.android_override"\n' "}\n")
520+
521+
522+
def test_cli_app_id_json_differs_per_platform(tmp_path: Path) -> None:
523+
# The pair, so a branch that ignored `platform` would fail even if each
524+
# single-platform assertion above were somehow satisfied.
525+
project_dir = _app_id_project(tmp_path)
526+
527+
android = json.loads(run_pn(["app-id", "android", "--json"], project_dir).stdout)
528+
ios = json.loads(run_pn(["app-id", "ios", "--json"], project_dir).stdout)
529+
530+
assert android["app_id"] != ios["app_id"]
531+
532+
533+
def test_cli_app_id_json_keeps_errors_off_stdout(tmp_path: Path) -> None:
534+
# Without --json this same error goes to stdout, which is why the stream
535+
# is scoped rather than global.
536+
result = run_pn(["app-id", "android", "--json"], str(tmp_path))
537+
538+
assert result.returncode == 1
539+
assert result.stdout == ""
540+
# Match the noun, not the sentence. A malformed config also exits 1 with
541+
# an error on stderr, so without this phrase the test passes for either.
542+
assert "No pythonnative.toml" in result.stderr
543+
544+
545+
def test_cli_app_id_plain_error_stays_on_stdout(tmp_path: Path) -> None:
546+
# Pins the scoping: the default path is untouched by the stream change.
547+
result = run_pn(["app-id", "android"], str(tmp_path))
548+
549+
assert result.returncode == 1
550+
assert "No pythonnative.toml" in result.stdout
551+
assert result.stderr == ""
552+
553+
554+
def test_cli_app_id_json_flag_is_wired_through_argparse(tmp_path: Path) -> None:
555+
# Everything here goes through run_pn, so argparse is always exercised.
556+
# Confirmed rather than assumed: #22 shipped six in-process tests that
557+
# all passed with the add_argument deleted.
558+
result = run_pn(["app-id", "android", "--json"], str(tmp_path))
559+
560+
assert result.returncode == 1, "expected the config error, not argparse exit 2"
561+
assert "unrecognized arguments" not in result.stderr
464562

465563

466564
def test_cli_doctor_runs(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL