예제 #1
0
 def _validate_artifact_with_altool(self, altool: Altool,
                                    artifact_path: pathlib.Path):
     self.logger.info(Colors.BLUE('\nValidate "%s" for App Store Connect'),
                      artifact_path)
     result = altool.validate_app(artifact_path)
     message = result.success_message or f'No errors validating archive at "{artifact_path}".'
     self.logger.info(Colors.GREEN(message))
예제 #2
0
 def log_filtered(self, resource_type: Type[R], resources: Sequence[R], constraint: str):
     count = len(resources)
     name = resource_type.plural(count)
     if count == 0:
         self.logger.info(Colors.YELLOW(f'Did not find any {name} {constraint}'))
     else:
         self.logger.info(Colors.GREEN(f'Filtered out {count} {name} {constraint}'))
예제 #3
0
 def _upload_artifact_with_altool(self, altool: Altool,
                                  artifact_path: pathlib.Path):
     self.logger.info(Colors.BLUE('\nUpload "%s" to App Store Connect'),
                      artifact_path)
     result = altool.upload_app(artifact_path)
     message = result.success_message or f'No errors uploading "{artifact_path}".'
     self.logger.info(Colors.GREEN(message))
예제 #4
0
    def get_test_destinations(self,
                              runtimes: Optional[Sequence[Runtime]] = None,
                              simulator_name: Optional[re.Pattern] = None,
                              include_unavailable: bool = False,
                              json_output: bool = False,
                              should_print: bool = True) -> List[Simulator]:
        """
        List available destinations for test runs
        """

        try:
            all(r.validate() for r in (runtimes or []))
        except ValueError as ve:
            TestArgument.RUNTIMES.raise_argument_error(str(ve))

        self.logger.info(Colors.BLUE('List available test devices'))
        try:
            simulators = Simulator.list(runtimes, simulator_name, include_unavailable)
        except IOError as e:
            raise XcodeProjectException(str(e)) from e
        if not simulators:
            raise XcodeProjectException('No simulator runtimes are available')

        if should_print:
            if json_output:
                self.echo(json.dumps([s.dict() for s in simulators], indent=4))
            else:
                runtime_simulators = defaultdict(list)
                for s in simulators:
                    runtime_simulators[s.runtime].append(s)
                for runtime in sorted(runtime_simulators.keys()):
                    self.echo(Colors.GREEN('Runtime: %s'), runtime)
                    for simulator in runtime_simulators[runtime]:
                        self.echo(f'- {simulator.name}')
        return simulators
예제 #5
0
 def _clean(self, xcodebuild: Xcodebuild):
     self.logger.info(Colors.BLUE(f'Clean {(xcodebuild.workspace or xcodebuild.xcode_project).name}'))
     try:
         xcodebuild.clean()
     except IOError as error:
         raise XcodeProjectException(*error.args)
     self.logger.info(Colors.GREEN(f'Cleaned {(xcodebuild.workspace or xcodebuild.xcode_project).name}\n'))
예제 #6
0
    def _get_publishing_application_packages(
        self, path_patterns: Sequence[pathlib.Path]
    ) -> List[Union[Ipa, MacOsPackage]]:
        found_application_paths = list(self.find_paths(*path_patterns))
        application_packages: List[Union[Ipa, MacOsPackage]] = []
        for path in found_application_paths:
            if path.suffix == '.ipa':
                application_package: Union[Ipa, MacOsPackage] = Ipa(path)
            elif path.suffix == '.pkg':
                application_package = MacOsPackage(path)
            else:
                raise AppStoreConnectError(
                    f'Unsupported package type for App Store Connect publishing: {path}'
                )

            try:
                application_package.get_summary()
            except FileNotFoundError as fnf:
                message = f'Invalid package for App Store Connect publishing: {fnf.args[0]} not found from {path}'
                self.logger.warning(Colors.YELLOW(message))
            except (ValueError, IOError) as error:
                message = f'Unable to process package {path} for App Store Connect publishing: {error.args[0]}'
                self.logger.warning(Colors.YELLOW(message))
            else:
                application_packages.append(application_package)

        if not application_packages:
            patterns = ', '.join(f'"{pattern}"' for pattern in path_patterns)
            raise AppStoreConnectError(
                f'No application packages found for patterns {patterns}')
        return application_packages
예제 #7
0
    def notify_profile_usage(self):
        self.logger.info(
            Colors.GREEN('Completed configuring code signing settings'))

        if not self._matched_profiles:
            message = 'Did not find matching provisioning profiles for code signing!'
            self.logger.warning(Colors.YELLOW(message))
            return

        for info in sorted(self._matched_profiles, key=lambda i: i.sort_key()):
            self.logger.info(Colors.BLUE(info.format()))
예제 #8
0
    def build_ipa(self,
                  xcode_project_path: Optional[pathlib.Path] = None,
                  xcode_workspace_path: Optional[pathlib.Path] = None,
                  target_name: Optional[str] = None,
                  configuration_name: Optional[str] = None,
                  scheme_name: Optional[str] = None,
                  clean: bool = False,
                  archive_directory: pathlib.Path = ExportIpaArgument.ARCHIVE_DIRECTORY.get_default(),
                  archive_xcargs: Optional[str] = XcodeArgument.ARCHIVE_XCARGS.get_default(),
                  archive_flags: Optional[str] = XcodeArgument.ARCHIVE_FLAGS.get_default(),
                  ipa_directory: pathlib.Path = ExportIpaArgument.IPA_DIRECTORY.get_default(),
                  export_options_plist: pathlib.Path = ExportIpaArgument.EXPORT_OPTIONS_PATH_EXISTING.get_default(),
                  export_xcargs: Optional[str] = XcodeArgument.EXPORT_XCARGS.get_default(),
                  export_flags: Optional[str] = XcodeArgument.EXPORT_FLAGS.get_default(),
                  remove_xcarchive: bool = False,
                  disable_xcpretty: bool = False,
                  xcpretty_options: str = XcprettyArgument.OPTIONS.get_default()) -> pathlib.Path:
        """
        Build ipa by archiving the Xcode project and then exporting it
        """
        self._ensure_project_or_workspace(xcode_project_path, xcode_workspace_path)

        export_options = ExportOptions.from_path(export_options_plist)
        xcodebuild = self._get_xcodebuild(**locals())
        clean and self._clean(xcodebuild)

        self.logger.info(Colors.BLUE(f'Archive {(xcodebuild.workspace or xcodebuild.xcode_project).name}'))
        try:
            xcarchive = xcodebuild.archive(
                export_options, archive_directory,
                xcargs=archive_xcargs, custom_flags=archive_flags)
        except IOError as error:
            raise XcodeProjectException(*error.args)
        self.logger.info(Colors.GREEN(f'Successfully created archive at {xcarchive}\n'))

        self._update_export_options(xcarchive, export_options_plist, export_options)

        self.logger.info(Colors.BLUE(f'Export {xcarchive} to {ipa_directory}'))
        try:
            ipa = xcodebuild.export_archive(
                xcarchive, export_options_plist, ipa_directory,
                xcargs=export_xcargs, custom_flags=export_flags)
        except IOError as error:
            raise XcodeProjectException(*error.args)
        else:
            self.logger.info(Colors.GREEN(f'Successfully exported ipa to {ipa}\n'))
        finally:
            if not disable_xcpretty:
                self.logger.info(f'Raw xcodebuild logs stored in {xcodebuild.logs_path}')
            if xcarchive is not None and remove_xcarchive:
                self.logger.info(f'Removing generated xcarchive {xcarchive.resolve()}')
                shutil.rmtree(xcarchive, ignore_errors=True)
        return ipa
예제 #9
0
 def log_found(self,
               resource_type: Type[R],
               resources: Sequence[R],
               resource_filter: Optional[ResourceManager.Filter] = None,
               related_resource_type: Optional[Type[R2]] = None):
     count = len(resources)
     name = resource_type.plural(count)
     suffix = f' matching specified filters: {resource_filter}.' if resource_filter is not None else ''
     related = f' for {related_resource_type}' if related_resource_type is not None else ''
     if count == 0:
         self.logger.info(Colors.YELLOW(f'Did not find any {name}{related}{suffix}'))
     else:
         self.logger.info(Colors.GREEN(f'Found {count} {name}{related}{suffix}'))
예제 #10
0
    def use_profiles(self,
                     xcode_project_patterns: Sequence[pathlib.Path],
                     profile_path_patterns: Sequence[pathlib.Path],
                     export_options_plist: pathlib.Path = ExportIpaArgument.
                     EXPORT_OPTIONS_PATH.get_default(),
                     custom_export_options: Optional[Dict] = None,
                     warn_only: bool = False):
        """
        Set up code signing settings on specified Xcode projects
        to use given provisioning profiles
        """
        from .keychain import Keychain

        self.logger.info(Colors.BLUE('Configure code signing settings'))

        profile_paths = self.find_paths(*profile_path_patterns)
        xcode_projects = self.find_paths(*xcode_project_patterns)

        try:
            profiles = [
                ProvisioningProfile.from_path(p) for p in profile_paths
            ]
        except (ValueError, IOError) as error:
            raise XcodeProjectException(*error.args)

        available_certs = Keychain().list_code_signing_certificates(
            should_print=False)
        code_signing_settings_manager = CodeSigningSettingsManager(
            profiles, available_certs)

        for xcode_project in xcode_projects:
            try:
                code_signing_settings_manager.use_profiles(xcode_project)
            except (ValueError, IOError) as error:
                if warn_only:
                    self.logger.warning(
                        Colors.YELLOW(
                            f'Using profiles on {xcode_project} failed'))
                else:
                    raise XcodeProjectException(*error.args)

        code_signing_settings_manager.notify_profile_usage()
        export_options = code_signing_settings_manager.generate_export_options(
            custom_export_options)
        export_options.notify(
            Colors.GREEN('Generated options for exporting the project'))
        export_options.save(export_options_plist)

        self.logger.info(
            Colors.GREEN(f'Saved export options to {export_options_plist}'))
        return export_options
    def notify(self, title: str):
        logger = log.get_logger(self.__class__)
        logger.info(title)
        options = self.dict()

        for key in sorted(options.keys()):
            value = options[key]
            option = re.sub(f'([A-Z])', r' \1', key.replace('ID', 'Id')).lstrip(' ').title()
            if isinstance(value, dict):
                logger.info(Colors.BLUE(f' - {option}:'))
                for k, v in value.items():
                    logger.info(Colors.BLUE(f'     - {k}: {v}'))
            else:
                logger.info(Colors.BLUE(f' - {option}: {value}'))
예제 #12
0
    def _detect_project_bundle_ids(self,
                                   xcode_project: pathlib.Path,
                                   target_name: Optional[str],
                                   config_name: Optional[str],
                                   include_pods: bool) -> List[str]:

        def group(bundle_ids):
            groups = defaultdict(list)
            for bundle_id in bundle_ids:
                groups['$' in bundle_id].append(bundle_id)
            return groups[True], groups[False]

        if not include_pods and xcode_project.stem == 'Pods':
            self.logger.info(f'Skip Bundle ID detection from Pod project {xcode_project}')
            return []

        detector = BundleIdDetector(xcode_project, target_name, config_name)
        detector.notify()
        try:
            detected_bundle_ids = detector.detect()
        except (ValueError, IOError) as error:
            raise XcodeProjectException(*error.args)

        env_var_bundle_ids, valid_bundle_ids = group(detected_bundle_ids)
        if env_var_bundle_ids:
            msg = f'Bundle IDs {", ".join(env_var_bundle_ids)} contain environment variables, exclude them.'
            self.logger.info(Colors.YELLOW(msg))
        self.logger.info(f'Detected Bundle IDs: {", ".join(valid_bundle_ids)}')
        return valid_bundle_ids
예제 #13
0
    def _update_export_options(
            self, xcarchive: pathlib.Path, export_options_path: pathlib.Path, export_options: ExportOptions):
        """
        For non-App Store exports, if the app is using either CloudKit or CloudDocuments
        extensions, then "com.apple.developer.icloud-container-environment" entitlement
        needs to be specified. Available options are Development and Production.
        Defaults to Development.
        """
        if export_options.is_app_store_export() or export_options.iCloudContainerEnvironment:
            return

        archive_entitlements = CodeSignEntitlements.from_xcarchive(xcarchive)
        icloud_services = archive_entitlements.get_icloud_services()
        if not {'CloudKit', 'CloudDocuments'}.intersection(icloud_services):
            return

        if 'Production' in archive_entitlements.get_icloud_container_environments():
            icloud_container_environment = 'Production'
        else:
            icloud_container_environment = 'Development'

        self.echo('App is using iCloud services that require iCloudContainerEnvironment export option')
        self.echo('Set iCloudContainerEnvironment export option to %s', icloud_container_environment)
        export_options.set_value('iCloudContainerEnvironment', icloud_container_environment)
        export_options.notify(Colors.GREEN('\nUsing options for exporting IPA'))
        export_options.save(export_options_path)
예제 #14
0
 def log_saved(self, resource: Union[SigningCertificate, Profile],
               path: pathlib.Path):
     destination = shlex.quote(str(path))
     self.logger.info(
         Colors.GREEN(
             f'Saved {resource.__class__} {resource.get_display_info()} to {destination}'
         ))
예제 #15
0
 def has_certificate(profile) -> bool:
     try:
         profile_certificates = self.api_client.profiles.list_certificate_ids(profile)
         return bool(certificate_ids.issubset({c.id for c in profile_certificates}))
     except AppStoreConnectApiError as err:
         error = f'Listing {SigningCertificate.s} for {Profile} {profile.id} failed unexpectedly'
         self.logger.warning(Colors.YELLOW(f'{error}: {err.error_response}'))
         return False
def test_raise_argument_error_custom_message(argument: cli.Argument,
                                             cli_argument_group):
    argument.register(cli_argument_group)
    with pytest.raises(argparse.ArgumentError) as error_info:
        argument.raise_argument_error('Custom error')

    error_msg = Colors.remove(str(error_info.value))
    assert error_msg == f'argument {argument.key}: Custom error'
예제 #17
0
 def missing_profile(bundle_id) -> bool:
     try:
         bundle_ids_profiles = self.api_client.bundle_ids.list_profile_ids(bundle_id)
         return not (profile_ids & {p.id for p in bundle_ids_profiles})
     except AppStoreConnectApiError as err:
         error = f'Listing {Profile.s} for {BundleId} {bundle_id.id} failed unexpectedly'
         self.logger.warning(Colors.YELLOW(f'{error}: {err.error_response}'))
         return True
예제 #18
0
 def _save_test_suite(self,
                      xcresult: pathlib.Path,
                      test_suites: TestSuites,
                      output_dir: pathlib.Path,
                      output_extension: str):
     result_path = output_dir / f'{xcresult.stem}.{output_extension}'
     test_suites.save_xml(result_path)
     self.echo(Colors.GREEN('Saved JUnit XML report to %s'), result_path)
예제 #19
0
 def _format_build_config_meta(cls, build_config_info):
     profile = build_config_info['profile']
     project = build_config_info['project_name']
     target = build_config_info['target_name']
     config = build_config_info['build_configuration']
     return Colors.BLUE(
         f' - Using profile "{profile.name}" [{profile.uuid}] '
         f'for target "{target}" [{config}] from project "{project}"', )
예제 #20
0
 def print_resource(self, resource: R, should_print: bool):
     if should_print is not True:
         return
     if self.print_json:
         self.print(resource.json())
     else:
         header = f'-- {resource.__class__}{" (Created)" if resource.created else ""} --'
         self.print(Colors.BLUE(header))
         self.print(str(resource))
예제 #21
0
    def _publish_application_package(
        self, altool: Altool, application_package: Union[Ipa, MacOsPackage]
    ) -> Tuple[Build, PreReleaseVersion]:
        """
        :raises IOError in case any step of publishing fails
        """
        self.logger.info(Colors.BLUE('\nPublish "%s" to App Store Connect'),
                         application_package.path)
        self.logger.info(application_package.get_text_summary())

        self._validate_artifact_with_altool(altool, application_package.path)
        self._upload_artifact_with_altool(altool, application_package.path)

        bundle_id = application_package.bundle_identifier
        self.logger.info(
            Colors.BLUE(
                '\nFind application entry from App Store Connect for uploaded binary'
            ))
        try:
            app = self.list_apps(bundle_id_identifier=bundle_id,
                                 should_print=False)[0]
        except IndexError:
            raise IOError(
                f'Did not find app with bundle identifier "{bundle_id}" from App Store Connect'
            )
        else:
            self.printer.print_resource(app, True)

        self.logger.info(Colors.BLUE('\nFind freshly uploaded build'))
        for build in self.list_app_builds(app.id, should_print=False):
            if build.attributes.version == application_package.version_code:
                pre_release_version = self.get_build_pre_release_version(
                    build.id, should_print=False)
                if pre_release_version.attributes.version == application_package.version:
                    break
        else:
            raise IOError(
                f'Did not find corresponding build from App Store versions for "{application_package.path}"'
            )

        self.logger.info(Colors.GREEN('\nPublished build is'))
        self.printer.print_resource(build, True)
        self.printer.print_resource(pre_release_version, True)
        return build, pre_release_version
def test_raise_argument_error(argument: cli.Argument, cli_argument_group):
    argument.register(cli_argument_group)
    with pytest.raises(argparse.ArgumentError) as error_info:
        argument.raise_argument_error()

    error_msg = Colors.remove(str(error_info.value))
    key = argument.key
    assert error_msg.startswith(f'argument {key}: ')
    assert any([
        f'Value {key.upper()} not provided' in error_msg,
        f'Missing value {key.upper()}' in error_msg
    ])
    def _deprecation_notice(self):
        from .android_app_bundle import AndroidAppBundle

        current_action = f'{self.get_executable_name()} {self.generate.action_name}'
        new_action = f'{AndroidAppBundle.get_executable_name()} {AndroidAppBundle.build_universal_apks.action_name}'
        lines = (
            f'Warning! Action "{current_action}" is deprecated and will be removed in future releases.',
            f'Please use action "{new_action}" instead.',
            f'See "{AndroidAppBundle.get_executable_name()} --help" for more information.',
        )
        for line in lines:
            self.logger.info(Colors.YELLOW(line))
예제 #24
0
    def get_default_test_destination(self,
                                     json_output: bool = False,
                                     should_print: bool = True) -> Simulator:
        """
        Show default test destination for the chosen Xcode version
        """
        xcode = Xcode.get_selected()
        if should_print:
            msg_template = 'Show default test destination for Xcode %s (%s)'
            self.logger.info(Colors.BLUE(msg_template), xcode.version, xcode.build_version)

        try:
            simulator = Simulator.get_default()
        except ValueError as error:
            raise XcodeProjectException(str(error)) from error

        if should_print:
            if json_output:
                self.echo(json.dumps(simulator.dict(), indent=4))
            else:
                self.echo(Colors.GREEN(f'{simulator.runtime} {simulator.name}'))
        return simulator
예제 #25
0
    def _get_test_destinations(self, requested_devices: Optional[List[str]]) -> List[Simulator]:
        if not requested_devices:
            simulators = [self.get_default_test_destination(should_print=False)]
        else:
            try:
                simulators = Simulator.find_simulators(requested_devices)
            except ValueError as ve:
                raise TestArgument.TEST_DEVICES.raise_argument_error(str(ve)) from ve

        self.echo(Colors.BLUE('Running tests on simulators:'))
        for s in simulators:
            self.echo('- %s %s (%s)', s.runtime, s.name, s.udid)
        self.echo('')
        return simulators
예제 #26
0
    def _find_certificates(self):
        process = self.execute(('security', 'find-certificate', '-a', '-p', self.path), show_output=False)
        if process.returncode != 0:
            raise KeychainError(f'Unable to list certificates from keychain {self.path}', process)

        pem = ''
        for line in process.stdout.splitlines():
            pem += line + '\n'
            if line == '-----END CERTIFICATE-----':
                try:
                    yield Certificate.from_pem(pem)
                except ValueError:
                    self.logger.warning(Colors.YELLOW('Failed to read certificate from keychain'))
                pem = ''
예제 #27
0
    def log_creating(self, resource_type: Type[R], **params):
        def fmt(item: Tuple[str, Any]):
            name, value = item
            if isinstance(value, list):
                return f'{name.replace("_", " ")}: {[shlex.quote(str(el)) for el in value]}'
            elif isinstance(value, enum.Enum):
                value = str(value.value)
            elif not isinstance(value, (str, bytes)):
                value = str(value)
            return f'{name.replace("_", " ")}: {shlex.quote(value)}'

        message = f'Creating new {resource_type}'
        if params:
            message = f'{message}: {", ".join(map(fmt, params.items()))}'
        self.logger.info(Colors.BLUE(message))
예제 #28
0
    def publish(self,
                application_package_path_patterns: Sequence[pathlib.Path],
                apple_id: Optional[str] = None,
                app_specific_password: Optional[
                    Types.AppSpecificPassword] = None,
                submit_to_testflight: Optional[bool] = None) -> None:
        """
        Publish application packages to App Store and submit them to Testflight
        """

        if not (apple_id and app_specific_password):
            self._assert_api_client_credentials(
                'Either Apple ID and app specific password or App Store Connect API key information is required.'
            )
        elif submit_to_testflight:
            self._assert_api_client_credentials(
                'It is required for submitting an app to Testflight.')

        application_packages = self._get_publishing_application_packages(
            application_package_path_patterns)
        try:
            altool = Altool(
                username=apple_id,
                password=app_specific_password.value
                if app_specific_password else None,
                key_identifier=self._key_identifier,
                issuer_id=self._issuer_id,
                private_key=self._private_key,
            )
        except ValueError as ve:
            raise AppStoreConnectError(str(ve))

        failed_packages: List[str] = []
        for application_package in application_packages:
            try:
                build, pre_release_version = self._publish_application_package(
                    altool, application_package)
                if submit_to_testflight:
                    self.create_beta_app_review_submission(build.id)
            except IOError as error:
                # TODO: Should we fail the whole action on first publishing failure?
                failed_packages.append(str(application_package.path))
                self.logger.error(Colors.RED(error.args[0]))

        if failed_packages:
            raise AppStoreConnectError(
                f'Failed to publish {", ".join(failed_packages)}')
예제 #29
0
    def initialize(self, password: Password = Password(''), timeout: Optional[Seconds] = None) -> Keychain:
        """
        Set up the keychain to be used for code signing. Create the keychain
        at specified path with specified password with given timeout.
        Make it default and unlock it for upcoming use.
        """

        if not self._path:
            self._generate_path()

        message = f'Initialize new keychain to store code signing certificates at {self.path}'
        self.logger.info(Colors.GREEN(message))
        self.create(password)
        self.set_timeout(timeout=timeout)
        self.make_default()
        self.unlock(password)
        return self
예제 #30
0
    def _get_test_suites(self,
                         xcresult_collector: XcResultCollector,
                         show_found_result: bool = False,
                         save_xcresult_dir: Optional[pathlib.Path] = None):
        if show_found_result:
            self.logger.info(Colors.GREEN('Found test results at'))
            for xcresult in xcresult_collector.get_collected_results():
                self.logger.info('- %s', xcresult)
            self.logger.info('')

        xcresult = xcresult_collector.get_merged_xcresult()
        try:
            test_suites = XcResultConverter.xcresult_to_junit(xcresult)
        finally:
            if save_xcresult_dir:
                shutil.copytree(xcresult, save_xcresult_dir / xcresult.name)
            xcresult_collector.forget_merged_result()
        return test_suites, xcresult