Repository: SFDO-Tooling/CumulusCI
Pull request #3421 (2022-11-01 16:53:53)

Adding stripping unwanted component transformation task

davidmreed: Adding the logger parameter looks like it breaks existing tests; hopefully those are pretty straightforward to remediate.

Patch:
diff --git a/cumulusci/core/source_transforms/transforms.py b/cumulusci/core/source_transforms/transforms.py
index e27aa0283..1da2428c2 100644
--- a/cumulusci/core/source_transforms/transforms.py
+++ b/cumulusci/core/source_transforms/transforms.py
@@ -2,6 +2,7 @@ import abc
 import functools
 import io
 import os
+import shutil
 import typing as T
 import zipfile
 from logging import Logger
@@ -11,6 +12,7 @@ from zipfile import ZipFile
 from pydantic import BaseModel, root_validator
 
 from cumulusci.core.exceptions import TaskOptionsError
+from cumulusci.tasks.metadata.package import RemoveSourceComponents
 from cumulusci.utils import (
     cd,
     inject_namespace,
@@ -335,6 +337,36 @@ class FindReplaceTransform(SourceTransform):
         return process_text_in_zipfile(zf, process_file)
 
 
+class StripUnwantedComponentsOptions(BaseModel):
+    package_xml: str
+
+
+class StripUnwantedComponentTransform(SourceTransform):
+    options_model = StripUnwantedComponentsOptions
+    options: StripUnwantedComponentsOptions
+    identifier = "strip_unwanted_components"
+
+    def __init__(self, options: StripUnwantedComponentsOptions):
+        self.options = options
+
+    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+        package_xml_path = os.path.abspath(os.path.expanduser(self.options.package_xml))
+
+        zip_dest = zipfile.ZipFile(io.BytesIO(), "w", zipfile.ZIP_DEFLATED)
+        with temporary_dir():
+            zf.extractall()
+            RemoveSourceComponents(
+                os.getcwd(), package_xml_path, api_version=None, logger=logger
+            )()
+            shutil.copy(package_xml_path, "package.xml")
+            for root, _, files in os.walk("."):
+                for f in files:
+                    file = os.path.join(root, f)
+                    zip_dest.write(file)
+
+        return zip_dest
+
+
 def get_available_transforms() -> T.Dict[str, T.Type[SourceTransform]]:
     """Get a mapping of identifiers (usable in cumulusci.yml) to transform classes"""
     return {
@@ -345,5 +377,6 @@ def get_available_transforms() -> T.Dict[str, T.Type[SourceTransform]]:
             RemoveFeatureParametersTransform,
             BundleStaticResourcesTransform,
             FindReplaceTransform,
+            StripUnwantedComponentTransform,
         ]
     }
diff --git a/cumulusci/cumulusci.yml b/cumulusci/cumulusci.yml
index e4eae923f..eb8cac84a 100644
--- a/cumulusci/cumulusci.yml
+++ b/cumulusci/cumulusci.yml
@@ -562,6 +562,13 @@ tasks:
         class_path: cumulusci.tasks.metadata_etl.SetOrgWideDefaults
         options:
             namespace_inject: $project_config.project__package__namespace
+    strip_unwanted_components:
+        description: Removes components from src folder which are not mentioned in given package.xml file
+        class_path: cumulusci.tasks.metadata.package.RemoveUnwantedComponents
+        options:
+            path: src
+            package_xml: src/package.xml
+        group: Salesforce Metadata
     uninstall_managed:
         description: Uninstalls the managed version of the package
         class_path: cumulusci.tasks.salesforce.UninstallPackage
diff --git a/cumulusci/salesforce_api/package_zip.py b/cumulusci/salesforce_api/package_zip.py
index d5d329a74..da54b9719 100644
--- a/cumulusci/salesforce_api/package_zip.py
+++ b/cumulusci/salesforce_api/package_zip.py
@@ -166,11 +166,6 @@ class MetadataPackageZipBuilder(BasePackageZipBuilder):
         return True
 
     def _process(self):
-        # We have to close the existing zipfile and reopen it before processing;
-        # otherwise we hit a bug in Windows where ZipInfo objects have the wrong path separators.
-        fp = self.zf.fp
-        self.zf.close()
-        self.zf = zipfile.ZipFile(fp, "r")
 
         transforms = []
 
@@ -198,6 +193,11 @@ class MetadataPackageZipBuilder(BasePackageZipBuilder):
             transforms.append(RemoveFeatureParametersTransform())
 
         for t in transforms:
+            # We have to close the existing zipfile and reopen it before processing;
+            # otherwise we hit a bug in Windows where ZipInfo objects have the wrong path separators.
+            fp = self.zf.fp
+            self.zf.close()
+            self.zf = zipfile.ZipFile(fp, "r")
             new_zipfile = t.process(self.zf, self.logger)
             if new_zipfile != self.zf:
                 # Ensure that zipfiles are closed (in case they're filesystem resources)
diff --git a/cumulusci/tasks/metadata/package.py b/cumulusci/tasks/metadata/package.py
index 5b4bbf8f7..c011459a0 100644
--- a/cumulusci/tasks/metadata/package.py
+++ b/cumulusci/tasks/metadata/package.py
@@ -1,12 +1,15 @@
 import os
 import re
+import shutil
 import urllib.parse
+from logging import Logger, getLogger
 from pathlib import Path
 
 import yaml
 
 from cumulusci.core.tasks import BaseTask
 from cumulusci.utils import elementtree_parse_file
+from cumulusci.utils.xml import metadata_tree
 
 __location__ = os.path.dirname(os.path.realpath(__file__))
 
@@ -52,6 +55,7 @@ class PackageXmlGenerator(object):
         install_class=None,
         uninstall_class=None,
         types=None,
+        logger=None,
     ):
         with open(
             __location__ + "/metadata_map.yml", "r", encoding="utf-8"
@@ -65,6 +69,7 @@ class PackageXmlGenerator(object):
         self.install_class = install_class
         self.uninstall_class = uninstall_class
         self.types = types or []
+        self.logger = logger
 
     def __call__(self):
         if not self.types:
@@ -92,7 +97,8 @@ class PackageXmlGenerator(object):
                     self.directory + "/" + item,  # Directory
                     parser_config.get("extension", ""),  # Extension
                     self.delete,  # Parse for deletion?
-                    **options  # Extra kwargs
+                    self.logger,  # Logger
+                    **options,  # Extra kwargs
                 )
                 self.types.append(parser)
 
@@ -133,12 +139,13 @@ class PackageXmlGenerator(object):
 
 
 class BaseMetadataParser(object):
-    def __init__(self, metadata_type, directory, extension, delete):
+    def __init__(self, metadata_type, directory, extension, delete, logger=None):
         self.metadata_type = metadata_type
         self.directory = directory
         self.extension = extension
         self.delete = delete
         self.members = []
+        self.logger: Logger = logger or getLogger(__file__)
 
         if self.delete:
             self.delete_excludes = self.get_delete_excludes()
@@ -215,11 +222,43 @@ class BaseMetadataParser(object):
         output.append("    </types>")
         return output
 
+    def strip_folder(self, component_list):
+        for item in sorted(os.listdir(self.directory)):
+            if item.startswith("."):
+                continue
+
+            # Ignore the CODEOWNERS file which is special to Github
+            if item in ["CODEOWNERS", "OWNERS"]:
+                continue
+
+            if item.endswith("-meta.xml"):
+                continue
+
+            if self.extension and not item.endswith("." + self.extension):
+                continue
+
+            self._strip_component(item, component_list)
+
+    def _strip_component(self, item, component_list):
+        raise NotImplementedError(
+            "Subclasses should implement their stripping component logic"
+        )
+
 
 class MetadataFilenameParser(BaseMetadataParser):
     def _parse_item(self, item):
         return [self.strip_extension(item)]
 
+    def _strip_component(self, item, component_list):
+        if self.strip_extension(item) not in component_list:
+            path = os.path.join(self.directory, item)
+            self.logger.info(
+                f"Deleting component {self.strip_extension(item)} of type {self.metadata_type}"
+            )
+            os.remove(path)
+            if os.path.exists(path + "-meta.xml"):
+                os.remove(path + "-meta.xml")
+
 
 class MetadataFolderParser(BaseMetadataParser):
     def _parse_item(self, item):
@@ -249,6 +288,34 @@ class MetadataFolderParser(BaseMetadataParser):
     def _parse_subitem(self, item, subitem):
         return [item + "/" + self.strip_extension(subitem)]
 
+    def _strip_component(self, item, component_list):
+        path = os.path.join(self.directory, item)
+        # Check if it is a directory and take action
+        if (
+            os.path.isdir(path)
+            and os.path.exists(path + "-meta.xml")
+            and item not in component_list
+        ):
+            self.logger.info(f"Deleting component {item} of type {self.metadata_type}")
+            shutil.rmtree(path)
+            os.remove(path + "-meta.xml")
+
+        if os.path.isdir(path):
+            for subitem in sorted(os.listdir(path)):
+                if subitem.endswith("-meta.xml") or subitem.startswith("."):
+                    continue
+                self._strip_subitem(item, subitem, component_list)
+
+    def _strip_subitem(self, item, subitem, component_list):
+        if item + "/" + self.strip_extension(subitem) not in component_list:
+            path = os.path.join(self.directory, item, subitem)
+            self.logger.info(
+                f"Deleting component {item}/{self.strip_extension(subitem)} of type {self.metadata_type}"
+            )
+            os.remove(path)
+            if os.path.exists(path + "-meta.xml"):
+                os.remove(path + "-meta.xml")
+
 
 class MissingNameElementError(Exception):
     pass
@@ -268,11 +335,12 @@ class MetadataXmlElementParser(BaseMetadataParser):
         directory,
         extension,
         delete,
+        logger=None,
         item_xpath=None,
         name_xpath=None,
     ):
         super(MetadataXmlElementParser, self).__init__(
-            metadata_type, directory, extension, delete
+            metadata_type, directory, extension, delete, logger
         )
         if not item_xpath:
             raise ParserConfigurationError("You must provide a value for item_xpath")
@@ -317,6 +385,25 @@ class MetadataXmlElementParser(BaseMetadataParser):
     def item_name_prefix(self, parent):
         return parent + "."
 
+    def _strip_component(self, item, component_list):
+        parent = self.strip_extension(item)
+        package_tree = elementtree_parse_file(
+            os.path.join(self.directory, item), namespace=self.namespaces["sf"]
+        )
+        root = package_tree.getroot()
+        for element in self.get_item_elements(root):
+            component_name = (
+                self.item_name_prefix(parent) + self.get_name_elements(element)[0].text
+            )
+            if component_name not in component_list:
+                self.logger.info(
+                    f"Deleting component {component_name} of type {self.metadata_type}"
+                )
+                root.remove(element)
+        package_tree.write(
+            os.path.join(self.directory, item), encoding="UTF-8", xml_declaration=True
+        )
+
 
 # TYPE SPECIFIC PARSERS
 
@@ -346,6 +433,27 @@ class CustomObjectParser(MetadataFilenameParser):
         members.append(self.strip_extension(item))
         return members
 
+    def _strip_component(self, item, component_list):
+        # Skip namespaced custom objects
+        if len(item.split("__")) > 2:
+            return
+
+        # Skip standard objects
+        if (
+            not item.endswith("__c.object")
+            and not item.endswith("__mdt.object")
+            and not item.endswith("__e.object")
+            and not item.endswith("__b.object")
+        ):
+            return
+
+        if self.strip_extension(item) not in component_list:
+            self.logger.info(
+                f"Deleting component {self.strip_extension(item)} of type {self.metadata_type}"
+            )
+            path = os.path.join(self.directory, item)
+            os.remove(path)
+
 
 class RecordTypeParser(MetadataXmlElementParser):
     def check_delete_excludes(self, item):
@@ -373,6 +481,16 @@ class BundleParser(BaseMetadataParser):
 
         return members
 
+    def _strip_component(self, item, component_list):
+        path = os.path.join(self.directory, item)
+        # Check if it is a directory and take action
+        if os.path.isdir(path):
+            if item not in component_list:
+                self.logger.info(
+                    f"Deleting component {item} of type {self.metadata_type}"
+                )
+                shutil.rmtree(path)
+
 
 class LWCBundleParser(BaseMetadataParser):
     def _parse_item(self, item):
@@ -388,11 +506,31 @@ class LWCBundleParser(BaseMetadataParser):
 
         return members
 
+    def _strip_component(self, item, component_list):
+        path = os.path.join(self.directory, item)
+        # Check if it is a directory and take action
+        if os.path.isdir(path):
+            if item not in component_list:
+                self.logger.info(
+                    f"Deleting component {item} of type {self.metadata_type}"
+                )
+                shutil.rmtree(path)
+
 
 class DocumentParser(MetadataFolderParser):
     def _parse_subitem(self, item, subitem):
         return [item + "/" + subitem]
 
+    def _strip_subitem(self, item, subitem, component_list):
+        if item + "/" + subitem not in component_list:
+            path = os.path.join(self.directory, item, subitem)
+            self.logger.info(
+                f"Deleting component {item}/{subitem} of type {self.metadata_type}"
+            )
+            os.remove(path)
+            if os.path.exists(path + "-meta.xml"):
+                os.remove(path + "-meta.xml")
+
 
 class UpdatePackageXml(BaseTask):
     task_options = {
@@ -444,3 +582,66 @@ class UpdatePackageXml(BaseTask):
         package_xml = self.package_xml()
         with open(self.options.get("output", output), mode="w", encoding="utf-8") as f:
             f.write(package_xml)
+
+
+class RemoveSourceComponents:
+    def __init__(self, directory, package_xml, api_version, logger):
+        self.directory = directory
+        self.package_xml = package_xml
+        self.api_version = api_version
+        self.logger = logger
+
+    def __call__(self):
+        xml_map = self.generate_package_xml_map()
+        self.folder_parser = PackageXmlGenerator(
+            directory=self.directory, api_version=self.api_version, logger=self.logger
+        )
+        self.folder_parser.parse_types()
+        self.folder_parser.types.sort(key=lambda x: x.metadata_type.upper())
+        for parse_type in self.folder_parser.types:
+            parse_type.strip_folder(
+                xml_map[parse_type.metadata_type]
+                if parse_type.metadata_type in xml_map
+                else []
+            )
+
+    def generate_package_xml_map(self) -> dict:
+        package = metadata_tree.parse(self.package_xml)
+        xml_map = {}
+        for type in package.types:
+            members = []
+            try:
+                for member in type.members:
+                    members.append(member.text)
+            except AttributeError:  # Exception if there are no members for a type
+                pass
+            xml_map[type["name"].text] = members
+        return xml_map
+
+
+class RemoveUnwantedComponents(BaseTask):
+    task_options = {
+        "path": {
+            "description": "The path to a folder of metadata to strip the components",
+            "required": True,
+        },
+        "package_xml": {
+            "description": "The path to package xml file to refer",
+            "required": True,
+        },
+    }
+
+    def _init_options(self, kwargs):
+        super()._init_options(kwargs)
+        self.run_class = RemoveSourceComponents(
+            directory=self.options.get("path"),
+            package_xml=self.options.get("package_xml"),
+            api_version=self.project_config.project__package__api_version,
+            logger=self.logger,
+        )
+
+    def _run_task(self):
+        self.logger.info(
+            "Removing unwanted components from " + self.options.get("path")
+        )
+        self.run_class()
diff --git a/cumulusci/utils/xml/__init__.py b/cumulusci/utils/xml/__init__.py
index 5c21b15a6..ba94553c6 100644
--- a/cumulusci/utils/xml/__init__.py
+++ b/cumulusci/utils/xml/__init__.py
@@ -7,11 +7,15 @@ from lxml import etree as lxml_etree
 UTF8 = "UTF-8"
 
 
-def elementtree_parse_file(path: T.Union[str, Path, T.IO]) -> etree.ElementTree:
+def elementtree_parse_file(
+    path: T.Union[str, Path, T.IO], namespace=None
+) -> etree.ElementTree:
     """Parse a file from filename, Path or Stream using Python stdlib.
 
     All else equal, prefer elementtree over LXML for performance and simplicity reasons."""
     try:
+        if namespace:
+            etree.register_namespace("", namespace)
         tree = etree.parse(path)
     except etree.ParseError as err:
         err.filename = path
diff --git a/docs/deploy.md b/docs/deploy.md
index 6a205ea2a..95db2d9ac 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -118,3 +118,16 @@ options:
                   - find: foo
                     replace: bar
 ```
+
+### Stripping Components with a `package.xml` Manifest
+
+This transformation allows you to deploy a subset of a metadata directory based on a `package.xml` manifest by removing unwanted components. It will compare components available in the source folder with a provided `package.xml` file and delete/modify component files which are not found.
+
+```yaml
+task: deploy
+options:
+    transforms:
+        - transform: strip_unwanted_components
+          options:
+              package_xml: PACKAGE_XML_FILE_PATH
+```


Test patch:
diff --git a/cumulusci/core/source_transforms/tests/test_transforms.py b/cumulusci/core/source_transforms/tests/test_transforms.py
index 19912a223..f4bde1c40 100644
--- a/cumulusci/core/source_transforms/tests/test_transforms.py
+++ b/cumulusci/core/source_transforms/tests/test_transforms.py
@@ -18,6 +18,8 @@ from cumulusci.core.source_transforms.transforms import (
     RemoveFeatureParametersTransform,
     SourceTransformList,
     SourceTransformSpec,
+    StripUnwantedComponentsOptions,
+    StripUnwantedComponentTransform,
 )
 from cumulusci.salesforce_api.package_zip import MetadataPackageZipBuilder
 from cumulusci.utils import temporary_dir
@@ -57,7 +59,11 @@ class ZipFileSpec:
                         io.BytesIO(other), "r", zipfile.ZIP_DEFLATED
                     )
                 elif isinstance(this, str):
-                    outcome = this.encode("utf-8") == other
+                    outcome = this.encode("utf-8").replace(
+                        b"\r\n", b"\n"
+                    ) == other.replace(
+                        b"\r\n", b"\n"
+                    )  # Replacing windows new line characters with Unix new characters
                 else:
                     outcome = this == other
 
@@ -528,3 +534,139 @@ def test_source_transform_parsing__bad_transform():
         )
 
         assert "destroy_the_things is not valid" in str(e)
+
+
+def test_strip_unwanted_files():
+    """
+    This test covers all strip options available during deployment.
+    It covers below scenarios:
+        1. Class files - Removes Bar.class and keeps Foo.class file
+        2. Aura files - Removes aura2.cmp file and keeps aura1.cmp file
+        3. Report files - Keeps TestReports folder, FooReport.report file and deletes BlahReport.report file
+        4. Custom object files - Removes Test2__c.CustomField2__c field from Test2__c.object file
+        5. Lightning bundle - Removes lwc2.cmp file from lwc folder
+    """
+
+    with temporary_dir() as temp_dir:
+        package_xml_data = """<?xml version="1.0" encoding="UTF-8"?>
+<Package xmlns="http://soap.sforce.com/2006/04/metadata">
+    <fullName>TestPackage</fullName>
+    <types>
+        <members>Foo</members>
+        <name>ApexClass</name>
+    </types>
+    <types>
+        <members>bundle1</members>
+        <name>AuraDefinitionBundle</name>
+    </types>
+    <types>
+        <members>TestReports</members>
+        <members>TestReports/FooReport</members>
+        <name>Report</name>
+    </types>
+    <types>
+        <members>Test1__c.CustomField1__c</members>
+        <members>Test1__c.CustomField2__c</members>
+        <members>Test2__c.CustomField1__c</members>
+        <name>CustomField</name>
+    </types>
+    <types>
+        <members>Test1__c</members>
+        <members>Test2__c</members>
+        <name>CustomObject</name>
+    </types>
+        <types>
+        <members>lwcBundle1</members>
+        <name>LightningComponentBundle</name>
+    </types>
+    <version>43.0</version>
+</Package>
+"""
+        meta_xml = """<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
+    <apiVersion>56.0</apiVersion>
+</ApexClass>"""
+
+        object_xml_data = """<?xml version='1.0' encoding='UTF-8'?>
+<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
+    <fields>
+        <fullName>CustomField1__c</fullName>
+        <externalId>false</externalId>
+        <label>Expected Completion Date</label>
+        <required>false</required>
+        <trackTrending>false</trackTrending>
+        <type>Text</type>
+    </fields>
+    <fields>
+        <fullName>CustomField2__c</fullName>
+        <externalId>false</externalId>
+        <label>Expected Completion Date</label>
+        <required>false</required>
+        <trackTrending>false</trackTrending>
+        <type>Text</type>
+    </fields>
+</CustomObject>"""
+        strip_object2_xml_data = """<?xml version='1.0' encoding='UTF-8'?>
+<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
+    <fields>
+        <fullName>CustomField1__c</fullName>
+        <externalId>false</externalId>
+        <label>Expected Completion Date</label>
+        <required>false</required>
+        <trackTrending>false</trackTrending>
+        <type>Text</type>
+    </fields>
+    </CustomObject>"""
+        td = Path(temp_dir)
+
+        (td / "package_test.xml").write_text(package_xml_data)
+        builder = MetadataPackageZipBuilder.from_zipfile(
+            ZipFileSpec(
+                {
+                    Path(os.path.join("aura", "bundle1", "aura1.cmp")): "Comp 1",
+                    Path(os.path.join("aura", "bundle2", "aura2.cmp")): "Comp 2",
+                    Path(os.path.join("classes", "Foo.cls")): "System.debug('foo');",
+                    Path(os.path.join("classes", "Foo.cls-meta.xml")): meta_xml,
+                    Path(os.path.join("classes", "Bar.cls")): "System.debug('bar');",
+                    Path(os.path.join("classes", "Bar.cls-meta.xml")): meta_xml,
+                    Path(os.path.join("reports", "TestReports-meta.xml")): meta_xml,
+                    Path(
+                        os.path.join("reports", "TestReports"), "FooReport.report"
+                    ): "Foo Report",
+                    Path(
+                        os.path.join("reports", "TestReports"), "BlahReport.report"
+                    ): "Blah Report",
+                    Path(os.path.join("objects", "Test1__c.object")): object_xml_data,
+                    Path(os.path.join("objects", "Test2__c.object")): object_xml_data,
+                    Path(os.path.join("lwc", "lwcBundle1", "lwc1.cmp")): "Comp 1",
+                    Path(os.path.join("lwc", "lwcBundle2", "lwc2.cmp")): "Comp 2",
+                }
+            ).as_zipfile(),
+            transforms=[
+                StripUnwantedComponentTransform(
+                    StripUnwantedComponentsOptions.parse_obj(
+                        {"package_xml": "package_test.xml"}
+                    )
+                )
+            ],
+        )
+
+        assert (
+            ZipFileSpec(
+                {
+                    Path(os.path.join("aura", "bundle1", "aura1.cmp")): "Comp 1",
+                    Path(os.path.join("classes", "Foo.cls")): "System.debug('foo');",
+                    Path(os.path.join("classes", "Foo.cls-meta.xml")): meta_xml,
+                    Path(os.path.join("reports", "TestReports-meta.xml")): meta_xml,
+                    Path(
+                        os.path.join("reports", "TestReports"), "FooReport.report"
+                    ): "Foo Report",
+                    Path(os.path.join("objects", "Test1__c.object")): object_xml_data,
+                    Path(
+                        os.path.join("objects", "Test2__c.object")
+                    ): strip_object2_xml_data,
+                    Path(os.path.join("lwc", "lwcBundle1", "lwc1.cmp")): "Comp 1",
+                    Path("package.xml"): package_xml_data,
+                }
+            )
+            == builder.zf
+        )
diff --git a/cumulusci/tasks/metadata/tests/test_package.py b/cumulusci/tasks/metadata/tests/test_package.py
index 24ec8c507..3b378d770 100644
--- a/cumulusci/tasks/metadata/tests/test_package.py
+++ b/cumulusci/tasks/metadata/tests/test_package.py
@@ -320,7 +320,7 @@ class TestCustomObjectParser:
 class TestRecordTypeParser:
     def test_check_delete_excludes(self):
         parser = RecordTypeParser(
-            "RecordType", None, "object", True, "./sf:recordTypes"
+            "RecordType", None, "object", True, item_xpath="./sf:recordTypes"
         )
         assert parser.check_delete_excludes("asdf")
 
@@ -328,7 +328,7 @@ class TestRecordTypeParser:
 class TestBusinessProcessParser:
     def test_check_delete_excludes(self):
         parser = BusinessProcessParser(
-            "BusinessProcess", None, "object", True, "./sf:businessProcesses"
+            "BusinessProcess", None, "object", True, item_xpath="./sf:businessProcesses"
         )
         assert parser.check_delete_excludes("asdf")