diff --git a/cumulusci/core/config/org_config.py b/cumulusci/core/config/org_config.py index e179fbbe3b..cee9def602 100644 --- a/cumulusci/core/config/org_config.py +++ b/cumulusci/core/config/org_config.py @@ -318,10 +318,16 @@ def has_minimum_package_version(self, package_identifier, version_identifier): @property def installed_packages(self): - """installed_packages is a dict mapping a namespace or package Id (033*) to the installed package + """installed_packages is a dict mapping a namespace, package name, or package Id (033*) to the installed package version(s) matching that identifier. All values are lists, because multiple second-generation packages may be installed with the same namespace. + Keys include: + - namespace: "mycompany" + - package name: "My Package Name" + - namespace@version: "mycompany@1.2.3" + - package ID: "033ABCDEF123456" + To check if a required package is present, call `has_minimum_package_version()` with either the namespace or 033 Id of the desired package and its version, in 1.2.3 format. @@ -329,7 +335,7 @@ def installed_packages(self): """ if self._installed_packages is None: isp_result = self.salesforce_client.restful( - "tooling/query/?q=SELECT SubscriberPackage.Id, SubscriberPackage.NamespacePrefix, " + "tooling/query/?q=SELECT SubscriberPackage.Id, SubscriberPackage.Name, SubscriberPackage.NamespacePrefix, " "SubscriberPackageVersionId FROM InstalledSubscriberPackage" ) _installed_packages = defaultdict(list) @@ -357,10 +363,14 @@ def installed_packages(self): version += f"b{spv['BuildNumber']}" version_info = VersionInfo(spv["Id"], StrictVersion(version)) namespace = sp["NamespacePrefix"] + package_name = sp["Name"] _installed_packages[namespace].append(version_info) namespace_version = f"{namespace}@{version}" _installed_packages[namespace_version].append(version_info) _installed_packages[sp["Id"]].append(version_info) + # Add package name as a key for specific package detection + if package_name: + _installed_packages[package_name].append(version_info) self._installed_packages = _installed_packages return self._installed_packages diff --git a/cumulusci/core/config/tests/test_config.py b/cumulusci/core/config/tests/test_config.py index 77973526dd..c1fcff4123 100644 --- a/cumulusci/core/config/tests/test_config.py +++ b/cumulusci/core/config/tests/test_config.py @@ -1185,6 +1185,7 @@ def test_community_info_exception(self, mock_fetch): { "SubscriberPackage": { "Id": "03350000000DEz4AAG", + "Name": "Volunteers for Salesforce", "NamespacePrefix": "GW_Volunteers", }, "SubscriberPackageVersionId": "04t1T00000070yqQAA", @@ -1192,6 +1193,7 @@ def test_community_info_exception(self, mock_fetch): { "SubscriberPackage": { "Id": "03350000000DEz5AAG", + "Name": "Volunteers for Salesforce", "NamespacePrefix": "GW_Volunteers", }, "SubscriberPackageVersionId": "04t000000000001AAA", @@ -1199,6 +1201,7 @@ def test_community_info_exception(self, mock_fetch): { "SubscriberPackage": { "Id": "03350000000DEz7AAG", + "Name": "Test Package", "NamespacePrefix": "TESTY", }, "SubscriberPackageVersionId": "04t000000000002AAA", @@ -1206,6 +1209,7 @@ def test_community_info_exception(self, mock_fetch): { "SubscriberPackage": { "Id": "03350000000DEz4AAG", + "Name": "Blah Package", "NamespacePrefix": "blah", }, "SubscriberPackageVersionId": "04t0000000BOGUSAAA", @@ -1213,6 +1217,7 @@ def test_community_info_exception(self, mock_fetch): { "SubscriberPackage": { "Id": "03350000000DEz8AAG", + "Name": "Error Package", "NamespacePrefix": "error", }, "SubscriberPackageVersionId": "04t0000000ERRORAAA", @@ -1297,6 +1302,13 @@ def test_installed_packages(self, sf): "03350000000DEz7AAG": [ VersionInfo("04t000000000002AAA", StrictVersion("1.10.0b5")) ], + "Volunteers for Salesforce": [ + VersionInfo("04t1T00000070yqQAA", StrictVersion("3.119")), + VersionInfo("04t000000000001AAA", StrictVersion("12.0.1")), + ], + "Test Package": [ + VersionInfo("04t000000000002AAA", StrictVersion("1.10.0b5")) + ], } # get it twice so we can make sure it is cached assert config.installed_packages == expected diff --git a/cumulusci/core/utils.py b/cumulusci/core/utils.py index 88cd570657..66a2c674bb 100644 --- a/cumulusci/core/utils.py +++ b/cumulusci/core/utils.py @@ -362,3 +362,40 @@ def make_jsonable(x): return x except (TypeError, OverflowError): return str(x) + + +def determine_managed_mode(options, project_config, org_config): + """Determine the managed mode based on options, project config, and org config. + + Args: + options: Dict of task options that may contain 'managed' or 'unmanaged' flags + project_config: Project configuration object with package info + org_config: Org configuration object with installed packages and namespace info + + Returns: + bool: True if in managed mode, False if in unmanaged mode + """ + if "managed" in options: + return process_bool_arg(options["managed"]) + + if "unmanaged" in options: + return not process_bool_arg(options.get("unmanaged", True)) + + # Get package and namespace information + package_name = getattr(project_config, 'project__package__name', None) + namespace = ( + getattr(project_config, 'project__package__namespace', None) + or options.get("namespace") + ) + + if not package_name and not namespace: + return False + + if bool(namespace) and namespace == getattr(org_config, 'namespace', None): + return False + + installed_packages = getattr(org_config, 'installed_packages', {}) + if package_name and any(package_name in key for key in installed_packages.keys()): + return True + + return bool(namespace) and namespace in installed_packages diff --git a/cumulusci/tasks/apex/anon.py b/cumulusci/tasks/apex/anon.py index 56bea2a4c4..74956b6f3d 100644 --- a/cumulusci/tasks/apex/anon.py +++ b/cumulusci/tasks/apex/anon.py @@ -4,7 +4,7 @@ SalesforceException, TaskOptionsError, ) -from cumulusci.core.utils import process_bool_arg +from cumulusci.core.utils import determine_managed_mode, process_bool_arg from cumulusci.tasks.salesforce import BaseSalesforceApiTask from cumulusci.utils import in_directory, inject_namespace from cumulusci.utils.http.requests_utils import safe_json_from_response @@ -107,12 +107,9 @@ def _process_apex_string(self, apex_string): def _prepare_apex(self, apex): # Process namespace tokens namespace = self.project_config.project__package__namespace - if "managed" in self.options: - managed = process_bool_arg(self.options["managed"]) - else: - managed = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + managed = determine_managed_mode( + self.options, self.project_config, self.org_config + ) if "namespaced" in self.options: namespaced = process_bool_arg(self.options["namespaced"]) else: diff --git a/cumulusci/tasks/apex/testrunner.py b/cumulusci/tasks/apex/testrunner.py index 5a51f655ed..50a4aa5fad 100644 --- a/cumulusci/tasks/apex/testrunner.py +++ b/cumulusci/tasks/apex/testrunner.py @@ -10,7 +10,12 @@ CumulusCIException, TaskOptionsError, ) -from cumulusci.core.utils import decode_to_unicode, process_bool_arg, process_list_arg +from cumulusci.core.utils import ( + decode_to_unicode, + determine_managed_mode, + process_bool_arg, + process_list_arg, +) from cumulusci.tasks.salesforce import BaseSalesforceApiTask from cumulusci.utils.http.requests_utils import safe_json_from_response @@ -609,13 +614,9 @@ def _enqueue_test_run(self, class_ids): def _init_task(self): super()._init_task() - if "managed" in self.options: - self.options["managed"] = process_bool_arg(self.options["managed"] or False) - else: - namespace = self.options.get("namespace") - self.options["managed"] = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + self.options["managed"] = determine_managed_mode( + self.options, self.project_config, self.org_config + ) def _run_task(self): result = self._get_test_classes() diff --git a/cumulusci/tasks/apex/tests/test_apex_tasks.py b/cumulusci/tasks/apex/tests/test_apex_tasks.py index 263f5fd265..8fed3be1ca 100644 --- a/cumulusci/tasks/apex/tests/test_apex_tasks.py +++ b/cumulusci/tasks/apex/tests/test_apex_tasks.py @@ -68,6 +68,7 @@ def setup_method(self): }, "test", ) + self.org_config._installed_packages = {} self.base_tooling_url = "{}/services/data/v{}/tooling/".format( self.org_config.instance_url, self.api_version ) diff --git a/cumulusci/tasks/metadata_etl/base.py b/cumulusci/tasks/metadata_etl/base.py index d7e9dbd849..4c91f77ac8 100644 --- a/cumulusci/tasks/metadata_etl/base.py +++ b/cumulusci/tasks/metadata_etl/base.py @@ -7,7 +7,11 @@ from cumulusci.core.enums import StrEnum from cumulusci.core.exceptions import CumulusCIException, TaskOptionsError from cumulusci.core.tasks import BaseSalesforceTask -from cumulusci.core.utils import process_bool_arg, process_list_arg +from cumulusci.core.utils import ( + determine_managed_mode, + process_bool_arg, + process_list_arg, +) from cumulusci.salesforce_api.metadata import ApiRetrieveUnpackaged from cumulusci.tasks.metadata.package import PackageXmlGenerator from cumulusci.utils import inject_namespace @@ -66,19 +70,16 @@ def _init_namespace_injection(self): self.options.get("namespace_inject") or self.project_config.project__package__namespace ) - if "managed" in self.options: - self.options["managed"] = process_bool_arg(self.options["managed"] or False) - else: - self.options["managed"] = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + self.options["managed"] = determine_managed_mode( + self.options, self.project_config, self.org_config + ) if "namespaced_org" in self.options: self.options["namespaced_org"] = process_bool_arg( self.options["namespaced_org"] or False ) else: self.options["namespaced_org"] = ( - bool(namespace) and namespace == self.org_config.namespace + bool(namespace) and namespace == getattr(self.org_config, 'namespace', None) ) def _inject_namespace(self, text): diff --git a/cumulusci/tasks/salesforce/Deploy.py b/cumulusci/tasks/salesforce/Deploy.py index 8c6ff6350c..757ad3add4 100644 --- a/cumulusci/tasks/salesforce/Deploy.py +++ b/cumulusci/tasks/salesforce/Deploy.py @@ -12,7 +12,11 @@ SourceTransform, SourceTransformList, ) -from cumulusci.core.utils import process_bool_arg, process_list_arg +from cumulusci.core.utils import ( + determine_managed_mode, + process_bool_arg, + process_list_arg, +) from cumulusci.salesforce_api.metadata import ApiDeploy, ApiRetrieveUnpackaged from cumulusci.salesforce_api.package_zip import MetadataPackageZipBuilder from cumulusci.salesforce_api.rest_deploy import RestDeploy @@ -154,9 +158,9 @@ def _get_api(self, path=None): ) def _has_namespaced_package(self, ns: Optional[str]) -> bool: - if "unmanaged" in self.options: - return not process_bool_arg(self.options.get("unmanaged", True)) - return bool(ns) and ns in self.org_config.installed_packages + return determine_managed_mode( + self.options, self.project_config, self.org_config + ) def _is_namespaced_org(self, ns: Optional[str]) -> bool: if "namespaced_org" in self.options: diff --git a/cumulusci/tasks/salesforce/composite.py b/cumulusci/tasks/salesforce/composite.py index cd83202221..623c3d65e9 100644 --- a/cumulusci/tasks/salesforce/composite.py +++ b/cumulusci/tasks/salesforce/composite.py @@ -6,7 +6,7 @@ from cumulusci.cli.ui import CliTable from cumulusci.core.exceptions import SalesforceException -from cumulusci.core.utils import process_bool_arg, process_list_arg +from cumulusci.core.utils import determine_managed_mode, process_list_arg from cumulusci.tasks.salesforce import BaseSalesforceApiTask from cumulusci.utils import inject_namespace @@ -83,12 +83,9 @@ def _process_json(self, body): body = body.replace("%%%USERID%%%", user_id) namespace = self.project_config.project__package__namespace - if "managed" in self.options: - managed = process_bool_arg(self.options["managed"]) - else: - managed = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + managed = determine_managed_mode( + self.options, self.project_config, self.org_config + ) _, body = inject_namespace( "composite", diff --git a/cumulusci/tasks/salesforce/custom_settings_wait.py b/cumulusci/tasks/salesforce/custom_settings_wait.py index 77432d5517..7adb59bcef 100644 --- a/cumulusci/tasks/salesforce/custom_settings_wait.py +++ b/cumulusci/tasks/salesforce/custom_settings_wait.py @@ -3,7 +3,7 @@ from simple_salesforce.exceptions import SalesforceError from cumulusci.core.exceptions import TaskOptionsError -from cumulusci.core.utils import process_bool_arg +from cumulusci.core.utils import determine_managed_mode, process_bool_arg from cumulusci.tasks.salesforce import BaseSalesforceApiTask @@ -93,12 +93,9 @@ def _poll_again(self): def _apply_namespace(self): # Process namespace tokens namespace = self.project_config.project__package__namespace - if "managed" in self.options: - managed = process_bool_arg(self.options["managed"]) - else: - managed = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + managed = determine_managed_mode( + self.options, self.project_config, self.org_config + ) if "namespaced" in self.options: namespaced = process_bool_arg(self.options["namespaced"]) else: diff --git a/cumulusci/tasks/salesforce/enable_prediction.py b/cumulusci/tasks/salesforce/enable_prediction.py index bc99463e30..a164536db1 100644 --- a/cumulusci/tasks/salesforce/enable_prediction.py +++ b/cumulusci/tasks/salesforce/enable_prediction.py @@ -1,7 +1,11 @@ from simple_salesforce.exceptions import SalesforceError from cumulusci.core.exceptions import CumulusCIException -from cumulusci.core.utils import process_bool_arg, process_list_arg +from cumulusci.core.utils import ( + determine_managed_mode, + process_bool_arg, + process_list_arg, +) from cumulusci.tasks.salesforce import BaseSalesforceApiTask from cumulusci.utils import inject_namespace from cumulusci.utils.http.requests_utils import safe_json_from_response @@ -37,12 +41,9 @@ def _init_namespace_injection(self): or self.project_config.project__package__namespace ) self.options["namespace_inject"] = namespace - if "managed" in self.options: - self.options["managed"] = process_bool_arg(self.options["managed"] or False) - else: - self.options["managed"] = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + self.options["managed"] = determine_managed_mode( + self.options, self.project_config, self.org_config + ) if "namespaced_org" in self.options: self.options["namespaced_org"] = process_bool_arg( self.options["namespaced_org"] or False diff --git a/cumulusci/tasks/salesforce/tests/test_ProfileGrantAllAccess.py b/cumulusci/tasks/salesforce/tests/test_ProfileGrantAllAccess.py index efd3e76977..aff43dcf3a 100644 --- a/cumulusci/tasks/salesforce/tests/test_ProfileGrantAllAccess.py +++ b/cumulusci/tasks/salesforce/tests/test_ProfileGrantAllAccess.py @@ -591,7 +591,51 @@ def test_init_options__namespace_injection(): ) assert task.options["namespace_inject"] == "ns" assert task.options["namespaced_org"] - assert task.options["managed"] + assert not task.options["managed"] # Fixed: namespaced org should be unmanaged + + +def test_init_options__managed_explicit_unmanaged_flag(): + """Test that explicit unmanaged=True forces managed=False even with installed packages.""" + pc = create_project_config(namespace="ns") + org_config = DummyOrgConfig({"namespace": "other"}) + org_config._installed_packages = {"ns": None} # Package is installed + task = create_task( + ProfileGrantAllAccess, {"unmanaged": True}, project_config=pc, org_config=org_config + ) + assert not task.options["managed"] # Should be False due to explicit unmanaged=True + + +def test_init_options__managed_explicit_unmanaged_false(): + """Test that explicit unmanaged=False forces managed=True.""" + pc = create_project_config(namespace="ns") + org_config = DummyOrgConfig({"namespace": "other"}) + org_config._installed_packages = {} # No packages installed + task = create_task( + ProfileGrantAllAccess, {"unmanaged": False}, project_config=pc, org_config=org_config + ) + assert task.options["managed"] # Should be True due to explicit unmanaged=False + + +def test_init_options__managed_fallback_to_installed_packages(): + """Test that we fall back to installed packages check when not in namespaced org.""" + pc = create_project_config(namespace="ns") + org_config = DummyOrgConfig({"namespace": "different"}) # Different namespace + org_config._installed_packages = {"ns": None} # But package is installed + task = create_task( + ProfileGrantAllAccess, {}, project_config=pc, org_config=org_config + ) + assert task.options["managed"] # Should be True due to installed package + + +def test_init_options__managed_no_installed_package(): + """Test that managed=False when package is not installed and not in namespaced org.""" + pc = create_project_config(namespace="ns") + org_config = DummyOrgConfig({"namespace": "different"}) + org_config._installed_packages = {} # No packages installed + task = create_task( + ProfileGrantAllAccess, {}, project_config=pc, org_config=org_config + ) + assert not task.options["managed"] # Should be False - no package installed def test_generate_package_xml__retrieve(): diff --git a/cumulusci/utils/__init__.py b/cumulusci/utils/__init__.py index 2d740c40cb..0d47d377ba 100644 --- a/cumulusci/utils/__init__.py +++ b/cumulusci/utils/__init__.py @@ -264,6 +264,14 @@ def inject_namespace( f' {name}: Replaced {filename_token} with "{namespace_prefix}"' ) + # Also replace ___NAMESPACED_ORG___ tokens in package.xml + prev_content = content + content = content.replace(namespaced_org_file_token, namespaced_org) + if logger and content != prev_content: + logger.info( + f' {name}: Replaced {namespaced_org_file_token} with "{namespaced_org}"' + ) + prev_content = content content = content.replace(namespaced_org_token, namespaced_org) if logger and content != prev_content: @@ -430,7 +438,7 @@ def get_option_usage_string(name, option): """ usage_str = option.get("usage") if not usage_str: - usage_str = f"--{name} {name.replace('_','').upper()}" + usage_str = f"--{name} {name.replace('_', '').upper()}" return usage_str