Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
]
Expand Down
2 changes: 1 addition & 1 deletion src/app_pass/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.1"
__version__ = "0.2.2"
7 changes: 4 additions & 3 deletions src/app_pass/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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


Expand All @@ -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)
Expand Down
12 changes: 5 additions & 7 deletions src/app_pass/_notarize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -111,24 +111,22 @@ 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":
break
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
3 changes: 0 additions & 3 deletions tests/test_main.py

This file was deleted.

184 changes: 184 additions & 0 deletions tests/test_notarize.py
Original file line number Diff line number Diff line change
@@ -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()