cumulusci-plus 5.0.21__py3-none-any.whl → 5.0.43__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. cumulusci/__about__.py +1 -1
  2. cumulusci/cli/logger.py +2 -2
  3. cumulusci/cli/service.py +20 -0
  4. cumulusci/cli/task.py +19 -3
  5. cumulusci/cli/tests/test_error.py +3 -1
  6. cumulusci/cli/tests/test_flow.py +279 -2
  7. cumulusci/cli/tests/test_org.py +5 -0
  8. cumulusci/cli/tests/test_service.py +15 -12
  9. cumulusci/cli/tests/test_task.py +122 -2
  10. cumulusci/cli/tests/utils.py +1 -4
  11. cumulusci/core/config/__init__.py +1 -0
  12. cumulusci/core/config/base_task_flow_config.py +26 -1
  13. cumulusci/core/config/org_config.py +2 -1
  14. cumulusci/core/config/project_config.py +14 -20
  15. cumulusci/core/config/scratch_org_config.py +12 -0
  16. cumulusci/core/config/tests/test_config.py +1 -0
  17. cumulusci/core/config/tests/test_config_expensive.py +9 -3
  18. cumulusci/core/config/universal_config.py +3 -4
  19. cumulusci/core/dependencies/base.py +5 -1
  20. cumulusci/core/dependencies/dependencies.py +1 -1
  21. cumulusci/core/dependencies/github.py +1 -2
  22. cumulusci/core/dependencies/resolvers.py +1 -1
  23. cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
  24. cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
  25. cumulusci/core/flowrunner.py +90 -6
  26. cumulusci/core/github.py +1 -1
  27. cumulusci/core/sfdx.py +3 -1
  28. cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
  29. cumulusci/core/source_transforms/transforms.py +1 -1
  30. cumulusci/core/tasks.py +13 -2
  31. cumulusci/core/tests/test_flowrunner.py +100 -0
  32. cumulusci/core/tests/test_tasks.py +65 -0
  33. cumulusci/core/utils.py +3 -1
  34. cumulusci/core/versions.py +1 -1
  35. cumulusci/cumulusci.yml +73 -1
  36. cumulusci/oauth/client.py +1 -1
  37. cumulusci/plugins/plugin_base.py +5 -3
  38. cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
  39. cumulusci/salesforce_api/rest_deploy.py +1 -1
  40. cumulusci/schema/cumulusci.jsonschema.json +69 -0
  41. cumulusci/tasks/apex/anon.py +1 -1
  42. cumulusci/tasks/apex/testrunner.py +421 -144
  43. cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
  44. cumulusci/tasks/bulkdata/extract.py +0 -1
  45. cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
  46. cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
  47. cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
  48. cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
  49. cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
  50. cumulusci/tasks/bulkdata/select_utils.py +1 -1
  51. cumulusci/tasks/bulkdata/snowfakery.py +100 -25
  52. cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
  53. cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
  54. cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
  55. cumulusci/tasks/bulkdata/tests/test_select_utils.py +46 -0
  56. cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
  57. cumulusci/tasks/create_package_version.py +190 -16
  58. cumulusci/tasks/datadictionary.py +1 -1
  59. cumulusci/tasks/metadata_etl/__init__.py +2 -0
  60. cumulusci/tasks/metadata_etl/applications.py +256 -0
  61. cumulusci/tasks/metadata_etl/base.py +7 -3
  62. cumulusci/tasks/metadata_etl/layouts.py +1 -1
  63. cumulusci/tasks/metadata_etl/permissions.py +1 -1
  64. cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
  65. cumulusci/tasks/metadata_etl/tests/test_applications.py +710 -0
  66. cumulusci/tasks/push/README.md +15 -17
  67. cumulusci/tasks/release_notes/README.md +13 -13
  68. cumulusci/tasks/release_notes/generator.py +13 -8
  69. cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
  70. cumulusci/tasks/salesforce/Deploy.py +53 -2
  71. cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
  72. cumulusci/tasks/salesforce/__init__.py +1 -0
  73. cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
  74. cumulusci/tasks/salesforce/composite.py +1 -1
  75. cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
  76. cumulusci/tasks/salesforce/enable_prediction.py +5 -1
  77. cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
  78. cumulusci/tasks/salesforce/insert_record.py +18 -19
  79. cumulusci/tasks/salesforce/sourcetracking.py +1 -1
  80. cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
  81. cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
  82. cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
  83. cumulusci/tasks/salesforce/tests/test_enable_prediction.py +4 -2
  84. cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
  85. cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
  86. cumulusci/tasks/salesforce/tests/test_update_external_auth_identity_provider.py +927 -0
  87. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +1427 -0
  88. cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
  89. cumulusci/tasks/salesforce/tests/test_update_record.py +512 -0
  90. cumulusci/tasks/salesforce/update_dependencies.py +2 -2
  91. cumulusci/tasks/salesforce/update_external_auth_identity_provider.py +551 -0
  92. cumulusci/tasks/salesforce/update_external_credential.py +647 -0
  93. cumulusci/tasks/salesforce/update_named_credential.py +441 -0
  94. cumulusci/tasks/salesforce/update_profile.py +17 -13
  95. cumulusci/tasks/salesforce/update_record.py +217 -0
  96. cumulusci/tasks/salesforce/users/permsets.py +62 -5
  97. cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
  98. cumulusci/tasks/sfdmu/__init__.py +0 -0
  99. cumulusci/tasks/sfdmu/sfdmu.py +376 -0
  100. cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
  101. cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
  102. cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
  103. cumulusci/tasks/tests/test_create_package_version.py +716 -1
  104. cumulusci/tasks/tests/test_util.py +42 -0
  105. cumulusci/tasks/util.py +37 -1
  106. cumulusci/tasks/utility/copyContents.py +402 -0
  107. cumulusci/tasks/utility/credentialManager.py +302 -0
  108. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  109. cumulusci/tasks/utility/env_management.py +1 -1
  110. cumulusci/tasks/utility/secretsToEnv.py +135 -0
  111. cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
  112. cumulusci/tasks/utility/tests/test_credentialManager.py +1150 -0
  113. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  114. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1118 -0
  115. cumulusci/tests/test_integration_infrastructure.py +3 -1
  116. cumulusci/tests/test_utils.py +70 -6
  117. cumulusci/utils/__init__.py +54 -9
  118. cumulusci/utils/classutils.py +5 -2
  119. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  120. cumulusci/utils/options.py +23 -1
  121. cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
  122. cumulusci/utils/yaml/cumulusci_yml.py +8 -3
  123. cumulusci/utils/yaml/model_parser.py +2 -2
  124. cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
  125. cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
  126. cumulusci/vcs/base.py +23 -15
  127. cumulusci/vcs/bootstrap.py +5 -4
  128. cumulusci/vcs/utils/list_modified_files.py +189 -0
  129. cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
  130. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/METADATA +11 -10
  131. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/RECORD +135 -104
  132. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/WHEEL +1 -1
  133. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/entry_points.txt +0 -0
  134. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/AUTHORS.rst +0 -0
  135. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,217 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from pydantic.v1 import root_validator
5
+
6
+ from cumulusci.core.exceptions import SalesforceException
7
+ from cumulusci.tasks.salesforce import BaseSalesforceApiTask
8
+ from cumulusci.utils.options import CCIOptions, Field, MappingOption
9
+
10
+
11
+ class UpdateRecord(BaseSalesforceApiTask):
12
+ task_docs = """
13
+ Update one or more Salesforce records.
14
+ For example, update by record ID:
15
+ cci task run update_record --org dev --object Account --record_id 001xx000003DGbXXXX --values Name:UpdatedName,Status__c:Active
16
+ Or update by query criteria:
17
+ cci task run update_record --org dev --object Account --where Name:TestAccount,Status__c:Draft --values Name:UpdatedName,Status__c:Active
18
+ Or use environment variables with transform_values:
19
+ cci task run update_record --org dev --object Account --record_id 001xx000003DGbXXXX --transform_values Name:ACCOUNT_NAME_VAR,Status__c:ACCOUNT_STATUS_VAR
20
+ """
21
+
22
+ class Options(CCIOptions):
23
+ object: str = Field(..., description="An sObject type to update")
24
+ values: Optional[MappingOption] = Field(
25
+ None,
26
+ description="Field names and values to update in the format 'aa:bb,cc:dd', or a YAML dict in cumulusci.yml.",
27
+ )
28
+ transform_values: Optional[MappingOption] = Field(
29
+ None,
30
+ description="Field names and environment variable keys in the format 'field:ENV_KEY,field2:ENV_KEY2'. Values will be extracted from environment variables.",
31
+ )
32
+ record_id: Optional[str] = Field(
33
+ None,
34
+ description="The ID of a specific record to update. If specified, the 'where' option is ignored.",
35
+ )
36
+ where: Optional[str] = Field(
37
+ None,
38
+ description="Query criteria to identify records in the format 'field:value,field2:value2'. Multiple records may be updated.",
39
+ )
40
+ tooling: bool = Field(
41
+ False, description="If True, use the Tooling API instead of REST API."
42
+ )
43
+ fail_on_error: bool = Field(
44
+ True,
45
+ description="If True (default), fail the task if any record update fails. If False, log errors but continue.",
46
+ )
47
+
48
+ @root_validator
49
+ def validate_options(cls, values):
50
+ """Validate required option combinations"""
51
+ # Validate that either record_id or where is provided
52
+ if not values.get("record_id") and not values.get("where"):
53
+ raise SalesforceException(
54
+ "Either 'record_id' or 'where' option must be specified"
55
+ )
56
+
57
+ # Validate that at least values or transform_values is provided
58
+ if not values.get("values") and not values.get("transform_values"):
59
+ raise SalesforceException(
60
+ "Either 'values' or 'transform_values' option must be specified"
61
+ )
62
+
63
+ return values
64
+
65
+ parsed_options: Options
66
+
67
+ def _init_task(self):
68
+ super()._init_task()
69
+ self.api = self.sf if not self.parsed_options.tooling else self.tooling
70
+
71
+ # Build the final values dict by merging values and transform_values
72
+ self.final_values = {}
73
+
74
+ # Start with regular values if provided
75
+ if self.parsed_options.values:
76
+ self.final_values.update(self.parsed_options.values)
77
+
78
+ # Process transform_values and extract from environment
79
+ if self.parsed_options.transform_values:
80
+ for field, env_key in self.parsed_options.transform_values.items():
81
+ env_value = os.environ.get(env_key, env_key)
82
+ self.final_values[field] = env_value
83
+ self.logger.info(
84
+ f"Transform value for field '{field}': {env_key} -> {env_value}"
85
+ )
86
+
87
+ def _run_task(self):
88
+ if self.parsed_options.record_id:
89
+ # Direct update by record ID
90
+ self._update_by_id(self.parsed_options.record_id)
91
+ else:
92
+ # Query and update multiple records
93
+ self._update_by_query()
94
+
95
+ def _update_by_id(self, record_id):
96
+ """Update a single record by ID"""
97
+ object_handler = getattr(self.api, self.parsed_options.object)
98
+
99
+ try:
100
+ rc = object_handler.update(record_id, self.final_values)
101
+ if rc == 204 or (isinstance(rc, dict) and rc.get("success")):
102
+ self.logger.info(
103
+ f"{self.parsed_options.object} record updated successfully: {record_id}"
104
+ )
105
+ else:
106
+ error_msg = (
107
+ f"Could not update {self.parsed_options.object} record {record_id}"
108
+ )
109
+ if isinstance(rc, dict) and "errors" in rc:
110
+ error_msg += f": {rc['errors']}"
111
+ if self.parsed_options.fail_on_error:
112
+ raise SalesforceException(error_msg)
113
+ else:
114
+ self.logger.error(error_msg)
115
+ except Exception as e:
116
+ if self.parsed_options.fail_on_error:
117
+ raise SalesforceException(
118
+ f"Error updating {self.parsed_options.object} record {record_id}: {str(e)}"
119
+ )
120
+ else:
121
+ self.logger.error(
122
+ f"Error updating {self.parsed_options.object} record {record_id}: {str(e)}"
123
+ )
124
+
125
+ def _update_by_query(self):
126
+ """Query records and update all matching records"""
127
+ # Parse where clause into query criteria - MappingOption already parses it
128
+ from cumulusci.core.utils import parse_list_of_pairs_dict_arg
129
+
130
+ where_criteria = parse_list_of_pairs_dict_arg(self.parsed_options.where)
131
+
132
+ # Build WHERE clause
133
+ where_parts = [
134
+ f"{field} = '{value}'" for field, value in where_criteria.items()
135
+ ]
136
+ where_clause = " AND ".join(where_parts)
137
+
138
+ # Build and execute query
139
+ query = f"SELECT Id FROM {self.parsed_options.object} WHERE {where_clause}"
140
+ self.logger.info(f"Querying records: {query}")
141
+
142
+ try:
143
+ result = self.api.query(query)
144
+ except Exception as e:
145
+ raise SalesforceException(f"Error executing query: {str(e)}")
146
+
147
+ records = result.get("records", [])
148
+ total_count = len(records)
149
+
150
+ if total_count == 0:
151
+ self.logger.warning(
152
+ f"No {self.parsed_options.object} records found matching criteria: {self.parsed_options.where}"
153
+ )
154
+ return
155
+
156
+ self.logger.info(
157
+ f"Found {total_count} {self.parsed_options.object} record(s) to update"
158
+ )
159
+
160
+ # Use different update strategy based on record count
161
+ if total_count == 1:
162
+ # Single record: use direct update
163
+ self._update_by_id(records[0]["Id"])
164
+ else:
165
+ # Multiple records: use bulk update
166
+ self._update_records_bulk(records)
167
+
168
+ def _update_records_bulk(self, records):
169
+ """Update multiple records using Bulk API"""
170
+ # Prepare data for bulk update
171
+ update_data = []
172
+ for record in records:
173
+ record_data = {"Id": record["Id"]}
174
+ record_data.update(self.final_values)
175
+ update_data.append(record_data)
176
+
177
+ self.logger.info(
178
+ f"Performing bulk update of {len(update_data)} {self.parsed_options.object} records"
179
+ )
180
+
181
+ try:
182
+ # Use Bulk API for update
183
+ results = self.bulk.update(self.parsed_options.object, update_data)
184
+
185
+ # Process results
186
+ success_count = 0
187
+ failed_records = []
188
+
189
+ for idx, result in enumerate(results):
190
+ record_id = update_data[idx]["Id"]
191
+ if result.success:
192
+ success_count += 1
193
+ self.logger.info(f"Updated record: {record_id}")
194
+ else:
195
+ error_msg = f"Failed to update record {record_id}: {result.error}"
196
+ failed_records.append({"id": record_id, "error": result.error})
197
+ self.logger.error(error_msg)
198
+
199
+ # Summary logging
200
+ self.logger.info(
201
+ f"Bulk update complete: {success_count}/{len(update_data)} records updated successfully"
202
+ )
203
+
204
+ # Handle failures
205
+ if failed_records and self.parsed_options.fail_on_error:
206
+ error_summary = "\n".join(
207
+ [f" - {rec['id']}: {rec['error']}" for rec in failed_records]
208
+ )
209
+ raise SalesforceException(
210
+ f"Failed to update {len(failed_records)} record(s):\n{error_summary}"
211
+ )
212
+
213
+ except Exception as e:
214
+ if self.parsed_options.fail_on_error:
215
+ raise SalesforceException(f"Bulk update failed: {str(e)}")
216
+ else:
217
+ self.logger.error(f"Bulk update failed: {str(e)}")
@@ -2,23 +2,43 @@ import json
2
2
 
3
3
  from cumulusci.cli.ui import CliTable
4
4
  from cumulusci.core.exceptions import CumulusCIException
5
- from cumulusci.core.utils import process_list_arg
5
+ from cumulusci.core.utils import (
6
+ determine_managed_mode,
7
+ process_bool_arg,
8
+ process_list_arg,
9
+ )
6
10
  from cumulusci.tasks.salesforce import BaseSalesforceApiTask
11
+ from cumulusci.tasks.salesforce.assign_ps_psg import build_name_conditions
12
+ from cumulusci.utils import inject_namespace
7
13
 
8
14
 
9
15
  class AssignPermissionSets(BaseSalesforceApiTask):
10
16
  task_docs = """
11
17
  Assigns Permission Sets whose Names are in ``api_names`` to either the default org user or the user whose Alias is ``user_alias``. This task skips assigning Permission Sets that are already assigned.
18
+
19
+ Permission Set names can include namespace tokens that will be replaced based on the context:
20
+ - ``%%%NAMESPACE%%%`` is replaced with the package's namespace in managed contexts (e.g., when the package is installed)
21
+ - ``%%%NAMESPACED_ORG%%%`` is replaced with the package's namespace in namespaced orgs only (e.g., packaging orgs)
22
+ - ``%%%NAMESPACE_OR_C%%%`` is replaced with the namespace in managed contexts, or 'c' otherwise
23
+ - ``%%%NAMESPACED_ORG_OR_C%%%`` is replaced with the namespace in namespaced orgs, or 'c' otherwise
24
+
25
+ The managed mode and namespaced org detection is automatic based on the org context.
12
26
  """
13
27
 
14
28
  task_options = {
15
29
  "api_names": {
16
- "description": "API Names of desired Permission Sets, separated by commas.",
30
+ "description": "API Names of desired Permission Sets, separated by commas. Can include namespace tokens like %%%NAMESPACE%%%.",
17
31
  "required": True,
18
32
  },
19
33
  "user_alias": {
20
34
  "description": "Target user aliases, separated by commas. Defaults to the current running user."
21
35
  },
36
+ "namespace_inject": {
37
+ "description": "Namespace to use for Permission Set names. If not provided, the namespace from the project config will be used.",
38
+ },
39
+ "managed": {
40
+ "description": "Whether the deployment is managed. If not provided, the managed mode will be determined based on the org config.",
41
+ },
22
42
  }
23
43
 
24
44
  permission_name = "PermissionSet"
@@ -35,6 +55,38 @@ Assigns Permission Sets whose Names are in ``api_names`` to either the default o
35
55
  self.options["user_alias"] = process_list_arg(
36
56
  self.options.get("user_alias") or []
37
57
  )
58
+ self._init_namespace_injection()
59
+
60
+ def _init_namespace_injection(self):
61
+ self.options["namespace_inject"] = (
62
+ self.options.get("namespace_inject")
63
+ or self.project_config.project__package__namespace
64
+ )
65
+ self.options["managed"] = self.options.get("managed") or determine_managed_mode(
66
+ self.options, self.project_config, self.org_config
67
+ )
68
+ self.options["namespaced_org"] = process_bool_arg(
69
+ True
70
+ if self.options["namespace_inject"] is not None
71
+ and self.options["namespace_inject"]
72
+ == getattr(self.org_config, "namespace", None)
73
+ else False
74
+ )
75
+ self.options["api_names"] = [
76
+ self._inject_namespace(api_name) for api_name in self.options["api_names"]
77
+ ]
78
+
79
+ def _inject_namespace(self, text):
80
+ """Inject the namespace into the given text if running in managed mode."""
81
+ _, name_processed = inject_namespace(
82
+ "",
83
+ text,
84
+ namespace=self.options.get("namespace_inject"),
85
+ managed=self.options.get("managed"),
86
+ namespaced_org=self.options.get("namespaced_org"),
87
+ logger=self.logger,
88
+ )
89
+ return name_processed
38
90
 
39
91
  def _run_task(self):
40
92
  users = self._query_existing_assignments()
@@ -84,12 +136,17 @@ Assigns Permission Sets whose Names are in ``api_names`` to either the default o
84
136
  return assigned_perms
85
137
 
86
138
  def _get_perm_ids(self):
87
- api_names = "', '".join(self.options["api_names"])
139
+ name_conditions, _ = build_name_conditions(
140
+ self.options["api_names"], field_name=self.permission_name_field
141
+ )
88
142
  perms = self.sf.query(
89
- f"SELECT Id,{self.permission_name_field} FROM {self.permission_name} WHERE {self.permission_name_field} IN ('{api_names}')"
143
+ f"SELECT Id, NamespacePrefix, {self.permission_name_field} FROM {self.permission_name} WHERE ({' OR '.join(name_conditions)})"
90
144
  )
91
145
  perms_by_ids = {
92
- p["Id"]: p[self.permission_name_field] for p in perms["records"]
146
+ p["Id"]: f"{p['NamespacePrefix']}__{p[self.permission_name_field]}"
147
+ if p["NamespacePrefix"]
148
+ else p[self.permission_name_field]
149
+ for p in perms["records"]
93
150
  }
94
151
 
95
152
  missing_perms = [