diff --git a/pyproject.toml b/pyproject.toml index dc7c365..cfb21c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "app_pass" -version = "0.2.1" +version = "0.2.2" authors = [ {name = "Dominik Kutra", email = "dominik.kutra@embl.de"} ] diff --git a/src/app_pass/__init__.py b/src/app_pass/__init__.py index 3ced358..b5fdc75 100644 --- a/src/app_pass/__init__.py +++ b/src/app_pass/__init__.py @@ -1 +1 @@ -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/src/app_pass/__main__.py b/src/app_pass/__main__.py index 127dbc8..87a9e5c 100644 --- a/src/app_pass/__main__.py +++ b/src/app_pass/__main__.py @@ -80,6 +80,7 @@ def parse_args() -> Namespace: notarize_args.add_argument("keychain", type=Path) notarize_args.add_argument("apple_id_email", type=str) notarize_args.add_argument("team_id", type=str) + notarize_args.add_argument("-t", "--timeout-minutes", type=int, default=60) parser = ArgumentParser() @@ -181,8 +182,8 @@ def fixsign( return commands -def notarize(app_path: Path, keychain_profile: str, keychain: Path, apple_id_email: str, team_id: str) -> int: - success = notarize_impl(app_path, keychain_profile, keychain, apple_id_email, team_id) +def notarize(app_path: Path, keychain_profile: str, keychain: Path, apple_id_email: str, team_id: str, timeout_minutes: int = 60) -> int: + success = notarize_impl(app_path, keychain_profile, keychain, apple_id_email, team_id, timeout_minutes) return success @@ -193,7 +194,7 @@ def main(): commands: list[Command] = [] match args.action: case "notarize": - return notarize(args.app_bundle, args.keychain_profile, args.keychain, args.apple_id_email, args.team_id) + return notarize(args.app_bundle, args.keychain_profile, args.keychain, args.apple_id_email, args.team_id, args.timeout_minutes) case "check" | "fix" | "sign" | "fixsign": app = OSXAPP.from_path(args.app_bundle, with_progress=not args.no_progress) commands.extend(app.jar_extract) diff --git a/src/app_pass/_notarize.py b/src/app_pass/_notarize.py index 06bccd2..b2a9498 100644 --- a/src/app_pass/_notarize.py +++ b/src/app_pass/_notarize.py @@ -78,7 +78,7 @@ def staple(app_bundle: Path): LOGGER.info(output) -def notarize_impl(app_path: Path, keychain_profile: str, keychain: Path, apple_id_email: str, team_id: str) -> int: +def notarize_impl(app_path: Path, keychain_profile: str, keychain: Path, apple_id_email: str, team_id: str, timeout_minutes: int) -> int: """Notarize an .app bundle with given credentials, wait for completion and staple This is equivalent to doing the following steps manually: @@ -111,13 +111,12 @@ def notarize_impl(app_path: Path, keychain_profile: str, keychain: Path, apple_i tosign_zip = compress(app_path) submission_id = submit(tosign_zip, keychain_profile, keychain, apple_id_email, team_id) - OVERALL_TIMEOUT = 40 * 60 + OVERALL_TIMEOUT = timeout_minutes * 60 SLEEP_S = 60 timeout = time.perf_counter() + OVERALL_TIMEOUT status = "NEVER CHECKED" - while timeout > time.perf_counter(): - + while within_time_limit := (timeout >= time.perf_counter()): status = check(submission_id, keychain_profile, keychain, apple_id_email, team_id) LOGGER.info(f"Submission status {status} for {submission_id}") if status == "accepted": @@ -125,10 +124,9 @@ def notarize_impl(app_path: Path, keychain_profile: str, keychain: Path, apple_i time.sleep(SLEEP_S) LOGGER.info(f"Notarization finished with {status=}") - if status == "accepted": + if within_time_limit and status == "accepted": staple(app_path) - - if status == "accepted": return 0 else: + LOGGER.info(f"Notarization incomplete with {within_time_limit=} and {status=}") return -1 diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index d3cf1c4..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,3 +0,0 @@ -def test_main(): - """Dummy test in order to make conda build pass""" - assert True diff --git a/tests/test_notarize.py b/tests/test_notarize.py new file mode 100644 index 0000000..9f8cff6 --- /dev/null +++ b/tests/test_notarize.py @@ -0,0 +1,184 @@ +import subprocess +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import app_pass._notarize +import pytest + + +@patch.object(subprocess, "check_call") +def test_compress(check_call_patch: MagicMock): + app_path = Path("Path_to_my_app.app") + to_sign_path = app_path.name.replace(".app", "-tosign.zip") + app_pass._notarize.compress(app_path) + + check_call_patch.assert_called_once() + args = check_call_patch.call_args.args[0] + assert args == ["/usr/bin/ditto", "-v", "-c", "-k", "--keepParent", str(app_path), str(to_sign_path)] + + +@patch.object(subprocess, "check_call") +def test_remove_apple_double(check_call_patch: MagicMock): + app_path = Path("Path_to_my_app") + app_pass._notarize.remove_apple_double(app_path) + + check_call_patch.assert_called_once() + args = check_call_patch.call_args.args[0] + assert args == ["find", str(app_path), "-type", "f", "-name", "._*", "-delete"] + + +@patch.object(subprocess, "check_output") +def test_submit(check_output_patch: MagicMock): + check_output_patch.return_value = '{"id": "some_id"}' + app_path_to_sign = Path("Path_to_my_app_to_sign") + keychain_profile = "keychain_profile" + keychain = Path("Path_to_my_keychain") + apple_id_email = "hello@ilastik.org" + team_id = "ilastik team" + app_pass._notarize.submit(app_path_to_sign, keychain_profile, keychain, apple_id_email, team_id) + + check_output_patch.assert_called_once() + args = check_output_patch.call_args.args[0] + assert args == [ + "xcrun", + "notarytool", + "submit", + "--output-format", + "json", + "--keychain-profile", + keychain_profile, + "--keychain", + str(keychain), + "--apple-id", + apple_id_email, + "--team-id", + team_id, + str(app_path_to_sign), + ] + + +@patch.object(subprocess, "check_output") +def test_staple(check_output_patch: MagicMock): + app_path = Path("Path_to_my_app.app") + + app_pass._notarize.staple(app_path) + check_output_patch.assert_called_once() + args = check_output_patch.call_args.args[0] + assert args == ["xcrun", "stapler", "staple", str(app_path)] + + +@patch.object(subprocess, "check_output") +def test_check(check_output_patch: MagicMock): + keychain_profile = "keychain_profile" + keychain = Path("Path_to_my_keychain") + apple_id_email = "hello@ilastik.org" + team_id = "ilastik team" + submission_id = "123" + + check_output_patch.return_value = f'{{"id": {submission_id}, "status": "blah", "name": "somename"}}' + + app_pass._notarize.check(submission_id, keychain_profile, keychain, apple_id_email, team_id) + check_output_patch.assert_called_once() + args = check_output_patch.call_args.args[0] + assert args == [ + "xcrun", + "notarytool", + "info", + "--output-format", + "json", + "--keychain-profile", + keychain_profile, + "--keychain", + str(keychain), + "--apple-id", + apple_id_email, + "--team-id", + team_id, + submission_id, + ] + + +def mocked_perf_counter(): + value = -1 + + def func(): + nonlocal value + value += 60 + return value + + return func + + +@patch.object(time, "sleep") +@patch.object(time, "perf_counter", new_callable=mocked_perf_counter) +@patch.object(app_pass._notarize, "staple") +@patch.object(app_pass._notarize, "check") +@patch.object(app_pass._notarize, "submit") +@patch.object(app_pass._notarize, "compress") +@patch.object(app_pass._notarize, "remove_apple_double") +def test_notarize_timeout( + apple_double_mock: MagicMock, + compress_mock: MagicMock, + submit_mock: MagicMock, + check_mock: MagicMock, + staple_mock: MagicMock, + perf_counter_mock: MagicMock, + sleep_mock: MagicMock, +): + app_path = Path("Path_to_my_app") + keychain_profile = "keychain_profile" + keychain = Path("Path_to_my_keychain") + apple_id_email = "hello@ilastik.org" + team_id = "ilastik team" + + check_mock.return_value = "in progress" + ret_val = app_pass._notarize.notarize_impl( + app_path, keychain_profile, keychain, apple_id_email, team_id, timeout_minutes=142 + ) + + apple_double_mock.assert_called_once() + compress_mock.assert_called_once() + submit_mock.assert_called_once() + assert ret_val == -1 + assert sleep_mock.call_count == 142 + assert check_mock.call_count == 142 + + staple_mock.assert_not_called() + + +@patch.object(time, "sleep") +@patch.object(time, "perf_counter", new_callable=mocked_perf_counter) +@patch.object(app_pass._notarize, "staple") +@patch.object(app_pass._notarize, "check") +@patch.object(app_pass._notarize, "submit") +@patch.object(app_pass._notarize, "compress") +@patch.object(app_pass._notarize, "remove_apple_double") +def test_notarize_success( + apple_double_mock: MagicMock, + compress_mock: MagicMock, + submit_mock: MagicMock, + check_mock: MagicMock, + staple_mock: MagicMock, + perf_counter_mock: MagicMock, + sleep_mock: MagicMock, +): + app_path = Path("Path_to_my_app") + keychain_profile = "keychain_profile" + keychain = Path("Path_to_my_keychain") + apple_id_email = "hello@ilastik.org" + team_id = "ilastik team" + + check_mock.return_value = "accepted" + ret_val = app_pass._notarize.notarize_impl( + app_path, keychain_profile, keychain, apple_id_email, team_id, timeout_minutes=42 + ) + + apple_double_mock.assert_called_once() + compress_mock.assert_called_once() + submit_mock.assert_called_once() + assert sleep_mock.call_count == 0 + assert check_mock.call_count == 1 + + assert ret_val == 0 + staple_mock.assert_called_once()