diff --git a/README.md b/README.md index 3c42cc7..ba25e16 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `xcodeproj` is a utility for interacting with Xcode's xcodeproj bundle format. -It expects some level of understanding of the internals of the pbxproj format and, in the future, schemes, etc. Note that this tool only reads projects. It does not write out any changes. If you are looking for more advanced functionality like this, I recommend looking at the Ruby gem of the same name (which is unaffiliated in anyway). +It expects some level of understanding of the internals of the pbxproj format and schemes. Note that this tool only reads projects. It does not write out any changes. If you are looking for more advanced functionality like this, I recommend looking at the Ruby gem of the same name (which is unaffiliated in anyway). To learn more about the format, you can look at any of these locations: @@ -26,7 +26,7 @@ for target in project.targets: print(target.name) # Print from the root level, 2 levels deep (.project is a property on the root -# project as in the future more surfaces, such as schemes, will be exposed) +# project as other properties such as .schemes are also available) for item1 in project.project.main_group.children: print(item1) if not isinstance(item1, xcodeproj.PBXGroup): @@ -49,6 +49,9 @@ key = obj.object_key Note: This library is "lazy". Many things aren't calculated until they are used. This time will be inconsequential on smaller projects, but on larger ones, it can save quite a bit of time due to not parsing the entire project on load. These properties are usually stored though so that subsequent accesses are instant. +## Note on Scheme Support +There's no DTD for xcscheme files, so the implementation has been guessed. There will definitely be holes that still need to be patched in it though. Please open an issue if you find any, along with a sample xcscheme file. + ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a diff --git a/tests/collateral/schemes/CalendarColors.xcscheme b/tests/collateral/schemes/CalendarColors.xcscheme new file mode 100644 index 0000000..313daba --- /dev/null +++ b/tests/collateral/schemes/CalendarColors.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/collateral/schemes/CalendarMeetNow.xcscheme b/tests/collateral/schemes/CalendarMeetNow.xcscheme new file mode 100644 index 0000000..b8f026c --- /dev/null +++ b/tests/collateral/schemes/CalendarMeetNow.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/collateral/schemes/CalendarMeetingDateTimePicker.xcscheme b/tests/collateral/schemes/CalendarMeetingDateTimePicker.xcscheme new file mode 100644 index 0000000..ac1d30c --- /dev/null +++ b/tests/collateral/schemes/CalendarMeetingDateTimePicker.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/collateral/schemes/CalendarMeetingInsights.xcscheme b/tests/collateral/schemes/CalendarMeetingInsights.xcscheme new file mode 100644 index 0000000..9177fc9 --- /dev/null +++ b/tests/collateral/schemes/CalendarMeetingInsights.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/collateral/schemes/CalendarUpNext.xcscheme b/tests/collateral/schemes/CalendarUpNext.xcscheme new file mode 100644 index 0000000..240a33f --- /dev/null +++ b/tests/collateral/schemes/CalendarUpNext.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/collateral/schemes/CalendarWidgetExt.xcscheme b/tests/collateral/schemes/CalendarWidgetExt.xcscheme new file mode 100644 index 0000000..0693886 --- /dev/null +++ b/tests/collateral/schemes/CalendarWidgetExt.xcscheme @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/collateral/schemes/CalendarWidgetExtDataSourceKit.xcscheme b/tests/collateral/schemes/CalendarWidgetExtDataSourceKit.xcscheme new file mode 100644 index 0000000..a70d915 --- /dev/null +++ b/tests/collateral/schemes/CalendarWidgetExtDataSourceKit.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_schemes.py b/tests/test_schemes.py new file mode 100644 index 0000000..917e288 --- /dev/null +++ b/tests/test_schemes.py @@ -0,0 +1,24 @@ +"""Tests for the package.""" + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.abspath(__file__), "..", ".."))) +import xcodeproj + + +# Fixtures work by redefining names, so we need to disable this +# pylint: disable=redefined-outer-name + +COLLATERAL_PATH = os.path.join( + os.path.abspath(os.path.join(os.path.abspath(__file__), "..")), "collateral" +) +SCHEMES_PATH = os.path.join(COLLATERAL_PATH, "schemes") + + +def test_load_schemes() -> None: + """Test that loading schemes works""" + + for scheme_file in os.listdir(SCHEMES_PATH): + scheme_path = os.path.join(SCHEMES_PATH, scheme_file) + _ = xcodeproj.Scheme.from_file(scheme_path) diff --git a/xcodeproj/__init__.py b/xcodeproj/__init__.py index 2ad5ec0..3dc7c44 100755 --- a/xcodeproj/__init__.py +++ b/xcodeproj/__init__.py @@ -40,6 +40,7 @@ from .pathobjects import ( ) from .pbxproject import PBXProject from .other import PBXTargetDependency, PBXContainerItemProxy +from .schemes import Scheme from .targets import PBXAggregateTarget, PBXNativeTarget, PBXProductType from .xcobjects import XCBuildConfiguration, XCConfigurationList @@ -85,6 +86,7 @@ class XcodeProject: objects: Objects project: PBXProject _cached_items: Dict[str, Dict[str, PBXObject]] + _schemes: Optional[List[Scheme]] def __init__(self, path: str) -> None: self.path = path @@ -105,6 +107,7 @@ class XcodeProject: self.project = self.objects[tree["rootObject"]] self._cached_items = {} + self._schemes = None self._set_weak_refs() @@ -239,3 +242,29 @@ class XcodeProject: return native_target.build_configuration_list return None + + @property + def schemes(self) -> List[Scheme]: + """Load the schemes for the project. + + :returns: A list of schemes + """ + if self._schemes is not None: + return self._schemes + + scheme_paths = [] + + for path, _, files in os.walk(self.path): + for file_path in files: + if not file_path.endswith(".xcscheme"): + continue + scheme_paths.append(os.path.join(path, file_path)) + + all_schemes: List[Scheme] = [] + + for scheme_path in scheme_paths: + all_schemes.append(Scheme.from_file(scheme_path)) + + self._schemes = all_schemes + + return all_schemes diff --git a/xcodeproj/schemes.py b/xcodeproj/schemes.py new file mode 100755 index 0000000..bb171a7 --- /dev/null +++ b/xcodeproj/schemes.py @@ -0,0 +1,304 @@ +"""Schemes""" + +import xml.etree.ElementTree as ET + +# pylint: disable=too-many-instance-attributes + + +class Action: + """Base class for actions.""" + + def __init__(self, node) -> None: + self.command_line_arguments = [] + self.environment_variables = [] + self._understood_tags = {"CommandLineArguments", "EnvironmentVariables", "MacroExpansion"} + + for child in node: + if child.tag == "CommandLineArguments": + for argument in child: + self.command_line_arguments.append(CommandLineArgument(argument)) + elif child.tag == "EnvironmentVariables": + for variable in child: + self.environment_variables.append(EnvironmentVariable(variable)) + elif child.tag == "MacroExpansion": + self.macro_expansion = MacroExpansion(child) + + def _understands_tag(self, tag: str) -> bool: + return tag in self._understood_tags + + +class RunAction(Action): + """Base class for runnable actions.""" + + def __init__(self, node) -> None: + super().__init__(node) + self._understood_tags.add("RemoteRunnable") + + for child in node: + if child.tag == "RemoteRunnable": + self.remote_runnable = RemoteRunnable(child) + + +class BuildableReference: + """BuildableReference""" + + def __init__(self, node) -> None: + self.buildable_identifier = node.attrib.get("BuildableIdentifier") + self.blueprint_identifier = node.attrib.get("BlueprintIdentifier") + self.buildable_name = node.attrib.get("BuildableName") + self.blueprint_name = node.attrib.get("BlueprintName") + self.referenced_container = node.attrib.get("ReferencedContainer") + + +class BuildActionEntry: + """BuildActionEntry""" + + def __init__(self, node) -> None: + self.build_for_testing = node.attrib.get("buildForTesting") == "YES" + self.build_for_running = node.attrib.get("buildForRunning") == "YES" + self.build_for_profiling = node.attrib.get("buildForProfiling") == "YES" + self.build_for_archiving = node.attrib.get("buildForArchiving") == "YES" + self.build_for_analyzing = node.attrib.get("buildForAnalyzing") == "YES" + self.buildable_references = [] + + for child in node: + self.buildable_references.append(BuildableReference(child)) + + +class MacroExpansion: + """MacroExpansion""" + + def __init__(self, node) -> None: + self.buildable_references = [] + + for child in node: + self.buildable_references.append(BuildableReference(child)) + + +class CodeCoverageTargets: + """CodeCoverageTargets""" + + def __init__(self, node) -> None: + self.buildable_references = [] + + for child in node: + self.buildable_references.append(BuildableReference(child)) + + +class TestableReference: + """TestableReference""" + + def __init__(self, node) -> None: + self.skipped = node.attrib.get("skipped") == "YES" + self.test_execution_ordering = node.attrib.get("testExecutionOrdering") + self.buildable_references = [] + + for child in node: + self.buildable_references.append(BuildableReference(child)) + + +class TestPlanReference: + """TestPlanReference""" + + def __init__(self, node) -> None: + assert node.tag == "TestPlanReference" + self.reference = node.attrib.get("reference") + self.default = node.attrib.get("default") == "YES" + + +class RemoteRunnable: + """RemoteRunnable""" + + def __init__(self, node) -> None: + self.runnable_debugging_mode = node.attrib.get("runnableDebuggingMode") + self.bundle_identifier = node.attrib.get("BundleIdentifier") + self.buildable_references = [] + + for child in node: + self.buildable_references.append(BuildableReference(child)) + + +class EnvironmentVariable: + """EnvironmentVariable""" + + def __init__(self, node) -> None: + self.key = node.attrib.get("key") + self.value = node.attrib.get("value") + self.is_enabled = node.attrib.get("isEnabled") == "YES" + + +class BuildableProductRunnable: + """BuildableProductRunnable""" + + def __init__(self, node) -> None: + assert node.tag == "BuildableProductRunnable" + self.runnable_debugging_mode = node.attrib.get("runnableDebuggingMode") + self.buildable_references = [] + + for child in node: + self.buildable_references.append(BuildableReference(child)) + + +class CommandLineArgument: + """CommandLineArgument""" + + def __init__(self, node) -> None: + assert node.tag == "CommandLineArgument" + self.argument = node.attrib.get("argument") + self.is_enabled = node.attrib.get("isEnabled") == "YES" + + +class BuildAction(Action): + """BuildAction""" + + def __init__(self, node) -> None: + assert node.tag == "BuildAction" + super().__init__(node) + self.parallelize_buildables = node.attrib.get("parallelizeBuildables") == "YES" + self.build_implicit_dependencies = node.attrib.get("buildImplicitDependencies") == "YES" + self.build_action_entries = [] + for child in node: + if child.tag == "BuildActionEntries": + for entry in child: + assert entry.tag == "BuildActionEntry" + self.build_action_entries.append(BuildActionEntry(entry)) + else: + assert self._understands_tag(child.tag) + + +class TestAction(Action): + """TestAction""" + + def __init__(self, node) -> None: + assert node.tag == "TestAction" + super().__init__(node) + self.build_configuration = node.attrib.get("buildConfiguration") + self.selected_debugger_identifier = node.attrib.get("selectedDebuggerIdentifier") + self.selected_launcher_identifier = node.attrib.get("selectedLauncherIdentifier") + self.should_use_launch_scheme_args_env = ( + node.attrib.get("shouldUseLaunchSchemeArgsEnv") == "YES" + ) + self.code_coverage_enabled = node.attrib.get("codeCoverageEnabled") == "YES" + self.only_generate_coverage_for_specific_targets = ( + node.attrib.get("onlyGenerateCoverageForSpecifiedTargets") == "YES" + ) + self.test_plans = [] + + for child in node: + if child.tag == "CodeCoverageTargets": + self.code_coverage_targets = CodeCoverageTargets(child) + elif child.tag == "Testables": + self.testables = [] + for testable in child: + self.testables.append(TestableReference(testable)) + elif child.tag == "TestPlans": + for plan in child: + self.test_plans.append(TestPlanReference(plan)) + else: + assert self._understands_tag(child.tag) + + +class LaunchAction(RunAction): + """LaunchAction""" + + def __init__(self, node) -> None: + assert node.tag == "LaunchAction" + super().__init__(node) + self.build_configuration = node.attrib.get("buildConfiguration") + self.selected_debugger_identifier = node.attrib.get("selectedDebuggerIdentifier") + self.selected_launcher_identifier = node.attrib.get("selectedLauncherIdentifier") + self.launch_style = node.attrib.get("launchStyle") + self.use_custom_working_directory = node.attrib.get("useCustomWorkingDirectory") == "YES" + self.ignores_persistent_state_on_launch = ( + node.attrib.get("ignoresPersistentStateOnLaunch") == "YES" + ) + self.debug_document_versioning = node.attrib.get("debugDocumentVersioning") == "YES" + self.debug_service_extension = node.attrib.get("debugServiceExtension") + self.allow_location_simulation = node.attrib.get("allowLocationSimulation") == "YES" + self.ask_for_app_to_launch = node.attrib.get("askForAppToLaunch") == "YES" + self.launch_automatically_substyle = node.attrib.get("launchAutomaticallySubstyle") == "YES" + + for child in node: + if child.tag == "BuildableProductRunnable": + self.buildable_product_runnable = BuildableProductRunnable(child) + else: + assert self._understands_tag(child.tag) + + +class ProfileAction(RunAction): + """ProfileAction""" + + def __init__(self, node) -> None: + assert node.tag == "ProfileAction" + super().__init__(node) + self.build_configuration = node.attrib.get("buildConfiguration") + self.should_use_launch_scheme_args_env = ( + node.attrib.get("shouldUseLaunchSchemeArgsEnv") == "YES" + ) + self.saved_tool_identifier = node.attrib.get("savedToolIdentifier") + self.use_custom_working_directory = node.attrib.get("useCustomWorkingDirectory") == "YES" + self.debug_document_versioning = node.attrib.get("debugDocumentVersioning") == "YES" + + for child in node: + if child.tag == "BuildableProductRunnable": + self.buildable_product_runnable = BuildableProductRunnable(child) + else: + assert self._understands_tag(child.tag) + + +class AnalyzeAction(Action): + """AnalyzeAction""" + + def __init__(self, node) -> None: + assert node.tag == "AnalyzeAction" + super().__init__(node) + self.build_configuration = node.attrib.get("buildConfiguration") + + +class ArchiveAction(Action): + """ArchiveAction""" + + def __init__(self, node) -> None: + assert node.tag == "ArchiveAction" + super().__init__(node) + self.build_configuration = node.attrib.get("buildConfiguration") + self.reveal_archive_in_organizer = node.attrib.get("revealArchiveInOrganizer") == "YES" + + +class Scheme: + """Represents an Xcode scheme.""" + + def __init__(self, node) -> None: + assert node.tag == "Scheme" + self.last_upgrade_version = node.attrib.get("LastUpgradeVersion") + self.version = node.attrib.get("version") + self.was_created_for_app_extension = node.attrib.get("wasCreatedForAppExtension") == "YES" + + for child in node: + if child.tag == "BuildAction": + self.build_action = BuildAction(child) + elif child.tag == "TestAction": + self.test_action = TestAction(child) + elif child.tag == "LaunchAction": + self.launch_action = LaunchAction(child) + elif child.tag == "ProfileAction": + self.profile_action = ProfileAction(child) + elif child.tag == "AnalyzeAction": + self.analyze_action = AnalyzeAction(child) + elif child.tag == "ArchiveAction": + self.archive_action = ArchiveAction(child) + else: + assert False + + @staticmethod + def from_file(path: str) -> "Scheme": + """Load a scheme from a file. + + :param path: The path of the scheme file + + :returns: A loaded scheme + """ + tree = ET.parse(path) + root = tree.getroot() + + return Scheme(root)