Skip to content
Open
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
14 changes: 12 additions & 2 deletions cumulusci/core/config/org_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,18 +318,24 @@ 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.

Beta version of a package are represented as "1.2.3b5", where 5 is the build number.
"""
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)
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions cumulusci/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,36 @@ 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"])

# Get package and namespace information
package_name = getattr(project_config, 'project__package__name', None)
namespace = getattr(project_config, 'project__package__namespace', None)
installed_packages = getattr(org_config, 'installed_packages', {})

if "unmanaged" in options:
# Explicit unmanaged flag always takes precedence
return not process_bool_arg(options.get("unmanaged", True))
elif package_name and any(package_name in key for key in installed_packages.keys()):
# If this specific package is installed (or there is any installed package with a Name that contains package_name), we're in managed context
return True
elif bool(namespace) and namespace == getattr(org_config, 'namespace', None):
# We're in a namespaced org (packaging org) developing unmanaged code
return False
else:
# Fall back to checking namespace in installed packages
return bool(namespace) and namespace in installed_packages
11 changes: 4 additions & 7 deletions cumulusci/tasks/apex/anon.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
SalesforceException,
TaskOptionsError,
)
from cumulusci.core.utils import process_bool_arg
from cumulusci.core.utils import process_bool_arg, determine_managed_mode
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
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 4 additions & 8 deletions cumulusci/tasks/apex/testrunner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
CumulusCIException,
TaskOptionsError,
)
from cumulusci.core.utils import decode_to_unicode, process_bool_arg, process_list_arg
from cumulusci.core.utils import decode_to_unicode, process_bool_arg, process_list_arg, determine_managed_mode
from cumulusci.tasks.salesforce import BaseSalesforceApiTask
from cumulusci.utils.http.requests_utils import safe_json_from_response

Expand Down Expand Up @@ -609,13 +609,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()
Expand Down
13 changes: 5 additions & 8 deletions cumulusci/tasks/metadata_etl/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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 process_bool_arg, process_list_arg, determine_managed_mode
from cumulusci.salesforce_api.metadata import ApiRetrieveUnpackaged
from cumulusci.tasks.metadata.package import PackageXmlGenerator
from cumulusci.utils import inject_namespace
Expand Down Expand Up @@ -66,19 +66,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):
Expand Down
8 changes: 4 additions & 4 deletions cumulusci/tasks/salesforce/Deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
SourceTransform,
SourceTransformList,
)
from cumulusci.core.utils import process_bool_arg, process_list_arg
from cumulusci.core.utils import process_bool_arg, process_list_arg, determine_managed_mode
from cumulusci.salesforce_api.metadata import ApiDeploy, ApiRetrieveUnpackaged
from cumulusci.salesforce_api.package_zip import MetadataPackageZipBuilder
from cumulusci.salesforce_api.rest_deploy import RestDeploy
Expand Down Expand Up @@ -154,9 +154,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:
Expand Down
11 changes: 4 additions & 7 deletions cumulusci/tasks/salesforce/composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 process_bool_arg, process_list_arg, determine_managed_mode
from cumulusci.tasks.salesforce import BaseSalesforceApiTask
from cumulusci.utils import inject_namespace

Expand Down Expand Up @@ -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",
Expand Down
11 changes: 4 additions & 7 deletions cumulusci/tasks/salesforce/custom_settings_wait.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 process_bool_arg, determine_managed_mode
from cumulusci.tasks.salesforce import BaseSalesforceApiTask


Expand Down Expand Up @@ -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:
Expand Down
11 changes: 4 additions & 7 deletions cumulusci/tasks/salesforce/enable_prediction.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 process_bool_arg, process_list_arg, determine_managed_mode
from cumulusci.tasks.salesforce import BaseSalesforceApiTask
from cumulusci.utils import inject_namespace
from cumulusci.utils.http.requests_utils import safe_json_from_response
Expand Down Expand Up @@ -37,12 +37,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
Expand Down
1 change: 1 addition & 0 deletions cumulusci/tasks/salesforce/sourcetracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ def retrieve_components(
to a namespace prefix to replace it with a `%%%NAMESPACE%%%` token.
"""


# Resolve output_dir if provided; otherwise let sfdx choose defaults
retrieve_target = os.path.realpath(output_dir) if output_dir else None
profiles = []
Expand Down
46 changes: 45 additions & 1 deletion cumulusci/tasks/salesforce/tests/test_ProfileGrantAllAccess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
8 changes: 8 additions & 0 deletions cumulusci/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,14 @@ def inject_namespace(
logger.info(
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)
Expand Down
2 changes: 2 additions & 0 deletions docs/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<!-- latest-start -->


## v4.5.0 (2025-08-06)

<!-- Release notes generated using configuration in .github/release.yml at main -->
Expand Down Expand Up @@ -1828,6 +1829,7 @@ Critical Changes:
subfolders will see a change in resolution behavior. Previously,
a dependency specified like this:


dependencies:
- github: https://github.com/SalesforceFoundation/NPSP
subfolder: unpackaged/config/trial
Expand Down