| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -57,7 +57,10 @@ the documented behavior never drifts from the code. | |||
| 57 | 57 | an iOS release build to App Store Connect. See | |
| 58 | 58 | [Building for release](../guides/building-for-release.md). | |
| 59 | 59 | - `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. | ||
| 61 | 64 | - `pn clean`: remove the local `build/` directory. | |
| 62 | 65 | - `pn --version` (`-V`): print the installed PythonNative version. | |
| 63 | 66 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -25,7 +25,7 @@ | |||
| 25 | 25 | - `pn build android|ios`: produce standalone artifacts (signed APK/AAB, | |
| 26 | 26 | or an iOS archive/IPA, optionally uploaded to App Store Connect). | |
| 27 | 27 | - `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`. | ||
| 29 | 29 | - `pn clean`: remove the local `build/` directory. | |
| 30 | 30 | ||
| 31 | 31 | The heavy lifting lives in the ``pythonnative.project`` and | |
@@ -343,11 +343,27 @@ def doctor_command(args: argparse.Namespace) -> None: | |||
| 343 | 343 | def app_id_command(args: argparse.Namespace) -> None: | |
| 344 | 344 | """Print the resolved application id (Android) or bundle id (iOS). | |
| 345 | 345 | ||
| 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 | + | ||
| 346 | 357 | Args: | |
| 347 | - args: Parsed namespace with ``platform``. | ||
| 358 | + args: Parsed namespace with ``platform`` and optional ``json``. | ||
| 348 | 359 | """ | |
| 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) | ||
| 351 | 367 | ||
| 352 | 368 | ||
| 353 | 369 | # ====================================================================== | |
@@ -981,11 +997,22 @@ def clean_project(args: argparse.Namespace) -> None: | |||
| 981 | 997 | # ====================================================================== | |
| 982 | 998 | ||
| 983 | 999 | ||
| 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 | + """ | ||
| 985 | 1012 | try: | |
| 986 | 1013 | return AppConfig.load(project_dir or Path.cwd()) | |
| 987 | 1014 | except ConfigError as exc: | |
| 988 | - print(f"Error: {exc}") | ||
| 1015 | + print(f"Error: {exc}", file=stream or sys.stdout) | ||
| 989 | 1016 | sys.exit(1) | |
| 990 | 1017 | ||
| 991 | 1018 | ||
@@ -1248,6 +1275,9 @@ def _add_server_args(sub: argparse.ArgumentParser) -> None: | |||
| 1248 | 1275 | ||
| 1249 | 1276 | parser_app_id = subparsers.add_parser("app-id", help="Print the resolved application/bundle id") | |
| 1250 | 1277 | 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 | + ) | ||
| 1251 | 1281 | parser_app_id.set_defaults(func=app_id_command) | |
| 1252 | 1282 | ||
| 1253 | 1283 | parser_clean = subparsers.add_parser("clean", help="Remove the local build/ directory") | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -454,13 +454,111 @@ def test_cli_run_without_config_errors() -> None: | |||
| 454 | 454 | shutil.rmtree(tmpdir, ignore_errors=True) | |
| 455 | 455 | ||
| 456 | 456 | ||
| 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 | + | ||
| 461 | 505 | 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 | ||
| 464 | 562 | ||
| 465 | 563 | ||
| 466 | 564 | def test_cli_doctor_runs(tmp_path: Path) -> None: | |
| Back | FazBrowse Home | New Git URL |
0 commit comments