cumulusci-plus 5.0.21__py3-none-any.whl → 5.0.35__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 (121) 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 +17 -0
  5. cumulusci/cli/tests/test_error.py +3 -1
  6. cumulusci/cli/tests/test_flow.py +279 -2
  7. cumulusci/cli/tests/test_service.py +15 -12
  8. cumulusci/cli/tests/test_task.py +88 -2
  9. cumulusci/cli/tests/utils.py +1 -4
  10. cumulusci/core/config/base_task_flow_config.py +26 -1
  11. cumulusci/core/config/project_config.py +2 -20
  12. cumulusci/core/config/tests/test_config_expensive.py +9 -3
  13. cumulusci/core/config/universal_config.py +3 -4
  14. cumulusci/core/dependencies/base.py +1 -1
  15. cumulusci/core/dependencies/dependencies.py +1 -1
  16. cumulusci/core/dependencies/github.py +1 -2
  17. cumulusci/core/dependencies/resolvers.py +1 -1
  18. cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
  19. cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
  20. cumulusci/core/flowrunner.py +90 -6
  21. cumulusci/core/github.py +1 -1
  22. cumulusci/core/sfdx.py +3 -1
  23. cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
  24. cumulusci/core/source_transforms/transforms.py +1 -1
  25. cumulusci/core/tasks.py +13 -2
  26. cumulusci/core/tests/test_flowrunner.py +100 -0
  27. cumulusci/core/tests/test_tasks.py +65 -0
  28. cumulusci/core/utils.py +3 -1
  29. cumulusci/core/versions.py +1 -1
  30. cumulusci/cumulusci.yml +55 -0
  31. cumulusci/oauth/client.py +1 -1
  32. cumulusci/plugins/plugin_base.py +5 -3
  33. cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
  34. cumulusci/salesforce_api/rest_deploy.py +1 -1
  35. cumulusci/schema/cumulusci.jsonschema.json +64 -0
  36. cumulusci/tasks/apex/anon.py +1 -1
  37. cumulusci/tasks/apex/testrunner.py +416 -142
  38. cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
  39. cumulusci/tasks/bulkdata/extract.py +0 -1
  40. cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
  41. cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
  42. cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
  43. cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
  44. cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
  45. cumulusci/tasks/bulkdata/select_utils.py +1 -1
  46. cumulusci/tasks/bulkdata/snowfakery.py +100 -25
  47. cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
  48. cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
  49. cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
  50. cumulusci/tasks/bulkdata/tests/test_select_utils.py +26 -0
  51. cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
  52. cumulusci/tasks/create_package_version.py +190 -16
  53. cumulusci/tasks/datadictionary.py +1 -1
  54. cumulusci/tasks/metadata_etl/base.py +7 -3
  55. cumulusci/tasks/metadata_etl/layouts.py +1 -1
  56. cumulusci/tasks/metadata_etl/permissions.py +1 -1
  57. cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
  58. cumulusci/tasks/push/README.md +15 -17
  59. cumulusci/tasks/release_notes/README.md +13 -13
  60. cumulusci/tasks/release_notes/generator.py +13 -8
  61. cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
  62. cumulusci/tasks/salesforce/Deploy.py +53 -2
  63. cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
  64. cumulusci/tasks/salesforce/__init__.py +1 -0
  65. cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
  66. cumulusci/tasks/salesforce/composite.py +1 -1
  67. cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
  68. cumulusci/tasks/salesforce/enable_prediction.py +5 -1
  69. cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
  70. cumulusci/tasks/salesforce/sourcetracking.py +1 -1
  71. cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
  72. cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
  73. cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
  74. cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
  75. cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
  76. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
  77. cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
  78. cumulusci/tasks/salesforce/update_dependencies.py +2 -2
  79. cumulusci/tasks/salesforce/update_external_credential.py +562 -0
  80. cumulusci/tasks/salesforce/update_named_credential.py +441 -0
  81. cumulusci/tasks/salesforce/update_profile.py +17 -13
  82. cumulusci/tasks/salesforce/users/permsets.py +62 -5
  83. cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
  84. cumulusci/tasks/sfdmu/__init__.py +0 -0
  85. cumulusci/tasks/sfdmu/sfdmu.py +363 -0
  86. cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
  87. cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
  88. cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
  89. cumulusci/tasks/tests/test_create_package_version.py +716 -1
  90. cumulusci/tasks/tests/test_util.py +42 -0
  91. cumulusci/tasks/util.py +37 -1
  92. cumulusci/tasks/utility/copyContents.py +402 -0
  93. cumulusci/tasks/utility/credentialManager.py +256 -0
  94. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  95. cumulusci/tasks/utility/env_management.py +1 -1
  96. cumulusci/tasks/utility/secretsToEnv.py +135 -0
  97. cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
  98. cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
  99. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  100. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
  101. cumulusci/tests/test_integration_infrastructure.py +3 -1
  102. cumulusci/tests/test_utils.py +70 -6
  103. cumulusci/utils/__init__.py +54 -9
  104. cumulusci/utils/classutils.py +5 -2
  105. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  106. cumulusci/utils/options.py +23 -1
  107. cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
  108. cumulusci/utils/yaml/cumulusci_yml.py +7 -3
  109. cumulusci/utils/yaml/model_parser.py +2 -2
  110. cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
  111. cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
  112. cumulusci/vcs/base.py +23 -15
  113. cumulusci/vcs/bootstrap.py +5 -4
  114. cumulusci/vcs/utils/list_modified_files.py +189 -0
  115. cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
  116. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
  117. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +121 -96
  118. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
  119. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
  120. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
  121. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,441 @@
1
+ import os
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from pydantic.v1 import root_validator
5
+
6
+ from cumulusci.core.exceptions import SalesforceDXException
7
+ from cumulusci.salesforce_api.utils import get_simple_salesforce_connection
8
+ from cumulusci.tasks.salesforce import BaseSalesforceApiTask
9
+ from cumulusci.utils.options import CCIOptions, Field
10
+
11
+
12
+ class NamedCredentialCalloutOptions(CCIOptions):
13
+ # Callout options
14
+ allow_merge_fields_in_body: bool = Field(
15
+ None,
16
+ description="Allow merge fields in body. [default to None]",
17
+ )
18
+ allow_merge_fields_in_header: bool = Field(
19
+ None,
20
+ description="Allow merge fields in header. [default to None]",
21
+ )
22
+ generate_authorization_header: bool = Field(
23
+ None,
24
+ description="Generate authorization header. [default to None]",
25
+ )
26
+
27
+
28
+ class NamedCredentialHttpHeader(CCIOptions):
29
+ name: str = Field(
30
+ None,
31
+ description="Name. [default to None]",
32
+ )
33
+ value: str = Field(
34
+ None,
35
+ description="Value. [default to None]",
36
+ )
37
+ sequence_number: int = Field(
38
+ None,
39
+ description="Sequence number. [default to None]",
40
+ )
41
+ secret: bool = Field(
42
+ False,
43
+ description="Is the value a secret. [default to False]",
44
+ )
45
+
46
+
47
+ class NamedCredentialParameter(CCIOptions):
48
+ allowed_managed_package_namespaces: str = Field(
49
+ None,
50
+ description="Allowed managed package namespaces. [default to None]",
51
+ )
52
+ url: str = Field(
53
+ None,
54
+ description="Url. [default to None]",
55
+ )
56
+ authentication: str = Field(
57
+ None,
58
+ description="Authentication. [default to None]",
59
+ )
60
+ certificate: str = Field(
61
+ None,
62
+ description="Certificate. [default to None]",
63
+ )
64
+ http_header: List["NamedCredentialHttpHeader"] = Field(
65
+ [],
66
+ description="Http header. [default to empty list]",
67
+ )
68
+
69
+ @root_validator
70
+ def check_parameters(cls, values):
71
+ """Check if only one of the parameters is provided"""
72
+ if (
73
+ len(
74
+ ([values.get("url")] if values.get("url") else [])
75
+ + (
76
+ [values.get("allowed_managed_package_namespaces")]
77
+ if values.get("allowed_managed_package_namespaces")
78
+ else []
79
+ )
80
+ + (
81
+ [values.get("authentication")]
82
+ if values.get("authentication")
83
+ else []
84
+ )
85
+ + ([values.get("certificate")] if values.get("certificate") else [])
86
+ + (
87
+ [len(values.get("http_header"))]
88
+ if values.get("http_header")
89
+ else []
90
+ )
91
+ )
92
+ == 1
93
+ ):
94
+ return values
95
+ raise ValueError("Only one of the parameters is required.")
96
+
97
+ def param_type(self):
98
+ """Get the parameter type"""
99
+ if self.url:
100
+ return "Url"
101
+ if self.authentication:
102
+ return "Authentication"
103
+ if self.certificate:
104
+ return "ClientCertificate"
105
+ if self.allowed_managed_package_namespaces:
106
+ return "AllowedManagedPackageNamespaces"
107
+ if self.http_header:
108
+ return "HttpHeader"
109
+
110
+ return None
111
+
112
+ def param_value(self, http_header=None):
113
+ """Get the parameter value"""
114
+ if self.url:
115
+ return self.url
116
+ if self.authentication:
117
+ return self.authentication
118
+ if self.certificate:
119
+ return self.certificate
120
+ if self.allowed_managed_package_namespaces:
121
+ return self.allowed_managed_package_namespaces
122
+ if http_header:
123
+ http_header_item = next(
124
+ (item for item in self.http_header if item.name == http_header), None
125
+ )
126
+ if http_header_item:
127
+ return http_header_item.value
128
+
129
+ def get_parameter_to_update(self):
130
+ """Get the parameter to update"""
131
+ ret = []
132
+
133
+ if len(self.http_header) > 0:
134
+ for http_header_item in self.http_header:
135
+ param_to_update = {}
136
+ param_to_update["parameterName"] = http_header_item.name
137
+ param_to_update["parameterType"] = "HttpHeader"
138
+ param_to_update["parameterValue"] = self.param_value(
139
+ http_header=http_header_item.name
140
+ )
141
+ param_to_update["secret"] = http_header_item.secret
142
+
143
+ if http_header_item.sequence_number is not None:
144
+ param_to_update["sequenceNumber"] = http_header_item.sequence_number
145
+
146
+ ret.append(param_to_update.copy())
147
+ else:
148
+ param_to_update = {}
149
+ param_to_update["parameterType"] = self.param_type()
150
+ param_to_update["parameterValue"] = self.param_value()
151
+
152
+ if param_to_update["parameterType"] == "ClientCertificate":
153
+ param_to_update["parameterName"] = "ClientCertificate"
154
+ param_to_update["certificate"] = param_to_update["parameterValue"]
155
+
156
+ if param_to_update["parameterType"] == "Authentication":
157
+ param_to_update["parameterName"] = "ExternalCredential"
158
+ param_to_update["externalCredential"] = param_to_update[
159
+ "parameterValue"
160
+ ]
161
+
162
+ ret.append(param_to_update.copy())
163
+
164
+ return ret
165
+
166
+
167
+ class TransformNamedCredentialParameter(NamedCredentialParameter):
168
+ def param_value(self, http_header=None):
169
+ value = super().param_value(http_header)
170
+ if value:
171
+ return os.getenv(value)
172
+ return None
173
+
174
+
175
+ NamedCredentialParameter.update_forward_refs()
176
+ TransformNamedCredentialParameter.update_forward_refs()
177
+
178
+
179
+ class UpdateNamedCredential(BaseSalesforceApiTask):
180
+ """Custom task to update named credential parameters.
181
+ This task is based on the managability rules of named credentials.
182
+ https://developer.salesforce.com/docs/atlas.en-us.pkg2_dev.meta/pkg2_dev/packaging_packageable_components.htm#mdc_named_credential
183
+ """
184
+
185
+ class Options(CCIOptions):
186
+ name: str = Field(..., description="Name of the named credential to update.")
187
+ namespace: str = Field(
188
+ "",
189
+ description="Namespace of the named credential to update. [default to empty string]",
190
+ )
191
+ # Callout options
192
+ callout_options: NamedCredentialCalloutOptions = Field(
193
+ None,
194
+ description="Callout options. [default to None]",
195
+ )
196
+ # Named credential parameters
197
+ parameters: List[NamedCredentialParameter] = Field(
198
+ [],
199
+ description="Parameters to update. [default to empty list]",
200
+ )
201
+ # Named credential parameters
202
+ transform_parameters: List[TransformNamedCredentialParameter] = Field(
203
+ [],
204
+ description="Parameters to transform. [default to empty list]",
205
+ )
206
+
207
+ parsed_options: Options
208
+
209
+ def _init_task(self):
210
+ self.tooling = get_simple_salesforce_connection(
211
+ self.project_config,
212
+ self.org_config,
213
+ api_version=self.project_config.project__package__api_version,
214
+ base_url="tooling",
215
+ )
216
+
217
+ def _run_task(self):
218
+ # Step 1: Get the named credential id from the named credential name
219
+ named_credential_id = self._get_named_credential_id()
220
+
221
+ if not named_credential_id:
222
+ msg = f"Named credential '{self.parsed_options.name}' not found"
223
+ raise SalesforceDXException(msg)
224
+
225
+ # Step 2: Get the named credential object
226
+ named_credential = self._get_named_credential_object(named_credential_id)
227
+
228
+ if not named_credential:
229
+ msg = f"Failed to retrieve named credential object for '{self.parsed_options.name}'"
230
+ raise SalesforceDXException(msg)
231
+
232
+ if named_credential.get("namedCredentialType") != "SecuredEndpoint":
233
+ msg = f"Named credential '{self.parsed_options.name}' is not a secured endpoint, Aborting update. Only SecuredEndpoint is supported."
234
+ raise SalesforceDXException(msg)
235
+
236
+ # Step 3: Update the named credential parameters
237
+ self._update_named_credential_parameters(named_credential)
238
+
239
+ updated_named_credential = self._update_named_credential_object(
240
+ named_credential_id, named_credential
241
+ )
242
+
243
+ if not updated_named_credential:
244
+ msg = f"Failed to update named credential object for '{self.parsed_options.name}'"
245
+ raise SalesforceDXException(msg)
246
+
247
+ self.logger.info(
248
+ f"Successfully updated named credential '{self.parsed_options.name}'"
249
+ )
250
+
251
+ def _get_named_credential_id(self) -> Optional[str]:
252
+ """Get the named credential ID from the named credential name"""
253
+ query = f"SELECT Id FROM NamedCredential WHERE DeveloperName='{self.parsed_options.name}'"
254
+
255
+ if self.parsed_options.namespace:
256
+ query += f" AND NamespacePrefix='{self.parsed_options.namespace}'"
257
+
258
+ query += " LIMIT 1"
259
+
260
+ try:
261
+ res = self.tooling.query(query)
262
+ if res["size"] == 0:
263
+ return None
264
+ return res["records"][0]["Id"]
265
+ except Exception as e:
266
+ self.logger.error(f"Error querying named credential: {str(e)}")
267
+ return None
268
+
269
+ def _get_named_credential_object(
270
+ self, named_credential_id: str
271
+ ) -> Optional[Dict[str, Any]]:
272
+ """Get the named credential object using Metadata API"""
273
+ try:
274
+ # Use Tooling API to get the named credential metadata
275
+ result = self.tooling._call_salesforce(
276
+ method="GET",
277
+ url=f"{self.tooling.base_url}sobjects/NamedCredential/{named_credential_id}",
278
+ )
279
+
280
+ if result.status_code != 200:
281
+ self.logger.error(
282
+ f"Error retrieving named credential object: {result.json()}"
283
+ )
284
+ return None
285
+
286
+ return result.json().get("Metadata", None)
287
+
288
+ except Exception as e:
289
+ self.logger.error(f"Error retrieving named credential object: {str(e)}")
290
+ return None
291
+
292
+ def _update_named_credential_object(
293
+ self, named_credential_id: str, named_credential: Dict[str, Any]
294
+ ) -> Optional[bool]:
295
+ """Update the named credential object"""
296
+ try:
297
+ resultUpdate = self.tooling._call_salesforce(
298
+ method="PATCH",
299
+ url=f"{self.tooling.base_url}sobjects/NamedCredential/{named_credential_id}",
300
+ json={"Metadata": named_credential},
301
+ )
302
+ if not resultUpdate.ok:
303
+ self.logger.error(
304
+ f"Error updating named credential object: {resultUpdate.json()}"
305
+ )
306
+ return None
307
+
308
+ return resultUpdate.ok
309
+ except Exception as e:
310
+ raise SalesforceDXException(
311
+ f"Failed to update named credential object: {str(e)}"
312
+ )
313
+
314
+ def _update_named_credential_parameters(self, named_credential: Dict[str, Any]):
315
+ """Update the named credential parameters"""
316
+ try:
317
+ template_param = self._get_named_credential_template_parameter(
318
+ named_credential
319
+ )
320
+
321
+ self._update_callout_options(named_credential)
322
+ self._update_parameters(
323
+ named_credential, self.parsed_options.parameters, template_param
324
+ )
325
+ self._update_parameters(
326
+ named_credential,
327
+ self.parsed_options.transform_parameters,
328
+ template_param,
329
+ )
330
+
331
+ except Exception as e:
332
+ raise SalesforceDXException(f"Failed to update parameters: {str(e)}")
333
+
334
+ def _update_callout_options(self, named_credential: Dict[str, Any]):
335
+ """Update the callout options"""
336
+ if not self.parsed_options.callout_options:
337
+ return
338
+
339
+ if self.parsed_options.callout_options.allow_merge_fields_in_body:
340
+ named_credential[
341
+ "allowMergeFieldsInBody"
342
+ ] = self.parsed_options.callout_options.allow_merge_fields_in_body
343
+ if self.parsed_options.callout_options.allow_merge_fields_in_header:
344
+ named_credential[
345
+ "allowMergeFieldsInHeader"
346
+ ] = self.parsed_options.callout_options.allow_merge_fields_in_header
347
+ if self.parsed_options.callout_options.generate_authorization_header:
348
+ named_credential[
349
+ "generateAuthorizationHeader"
350
+ ] = self.parsed_options.callout_options.generate_authorization_header
351
+
352
+ def _update_parameters(
353
+ self,
354
+ named_credential: Dict[str, Any],
355
+ named_credential_parameters: List[NamedCredentialParameter],
356
+ template_param: Dict[str, Any],
357
+ ):
358
+ """Update the parameters"""
359
+ for param_input in named_credential_parameters:
360
+ params_to_update = param_input.get_parameter_to_update()
361
+
362
+ for param_to_update in params_to_update:
363
+ secret = param_to_update.pop("secret", False)
364
+
365
+ param_to_update_copy = param_to_update.copy()
366
+ param_to_update_copy.pop("parameterValue", None)
367
+ param_to_update_copy.pop("certificate", None)
368
+ param_to_update_copy.pop("externalCredential", None)
369
+
370
+ cred_param = next(
371
+ (
372
+ param
373
+ for param in named_credential.get(
374
+ "namedCredentialParameters", []
375
+ )
376
+ if param_to_update_copy.items() <= param.items()
377
+ ),
378
+ None,
379
+ )
380
+
381
+ if cred_param:
382
+ cred_param.update(param_to_update)
383
+ self.logger.info(
384
+ f"Updated parameter {cred_param['parameterType']}-{cred_param['parameterName']} with new value {param_to_update['parameterValue'] if not secret else '********'}"
385
+ )
386
+ else:
387
+ copy_template_param = template_param.copy()
388
+ copy_template_param.update(param_to_update)
389
+ named_credential["namedCredentialParameters"].append(
390
+ copy_template_param
391
+ )
392
+ self.logger.info(
393
+ f"Added parameter {copy_template_param['parameterType']}-{copy_template_param['parameterName']} with new value {param_to_update['parameterValue'] if not secret else '********'}"
394
+ )
395
+
396
+ def _update_parameter_value(
397
+ self, named_credential_parameter: Dict[str, Any], new_value: str
398
+ ):
399
+ """Update an existing parameter's value"""
400
+ update_data = {"parameterValue": new_value}
401
+ named_credential_parameter.update(update_data)
402
+
403
+ def _get_named_credential_template_parameter(
404
+ self, named_credential: Dict[str, Any]
405
+ ) -> Dict[str, Any]:
406
+ """Get the named credential template parameter"""
407
+ template_param = [
408
+ param
409
+ for param in named_credential.get("namedCredentialParameters", [])
410
+ if param.get("parameterType") == "Url"
411
+ ]
412
+ if len(template_param) == 0:
413
+ self.logger.warning(
414
+ f"No template parameter found for named credential '{self.parsed_options.name}', using default template parameter."
415
+ )
416
+ return {
417
+ "certificate": None,
418
+ "description": None,
419
+ "externalCredential": None,
420
+ "globalNamedPrincipalCredential": None,
421
+ "managedFeatureEnabledCallout": None,
422
+ "outboundNetworkConnection": None,
423
+ "parameterName": None,
424
+ "parameterType": None,
425
+ "parameterValue": None,
426
+ "readOnlyNamedCredential": None,
427
+ "sequenceNumber": None,
428
+ "systemUserNamedCredential": None,
429
+ }
430
+
431
+ ret = template_param[0].copy()
432
+ ret.update(
433
+ {
434
+ "parameterName": None,
435
+ "parameterType": None,
436
+ "parameterValue": None,
437
+ "sequenceNumber": None,
438
+ }
439
+ )
440
+
441
+ return ret
@@ -231,28 +231,28 @@ class ProfileGrantAllAccess(MetadataSingleEntityTransformTask, BaseSalesforceApi
231
231
 
232
232
  def _strip_namespace_from_record_type(self, record_type):
233
233
  """Strip namespace prefix from record type string.
234
-
234
+
235
235
  Converts 'namespace__Object__c.namespace__RecordType' to 'Object__c.RecordType'
236
236
  """
237
237
  if not self.options.get("namespace_inject"):
238
238
  return record_type
239
-
239
+
240
240
  namespace = self.options["namespace_inject"]
241
241
  namespace_prefix = f"{namespace}__"
242
-
242
+
243
243
  # Split on the dot to handle object and record type separately
244
244
  parts = record_type.split(".", 1)
245
245
  if len(parts) != 2:
246
246
  return record_type
247
-
247
+
248
248
  object_name, record_type_name = parts
249
-
249
+
250
250
  # Strip namespace prefix from both object and record type if present
251
251
  if object_name.startswith(namespace_prefix):
252
- object_name = object_name[len(namespace_prefix):]
252
+ object_name = object_name[len(namespace_prefix) :]
253
253
  if record_type_name.startswith(namespace_prefix):
254
- record_type_name = record_type_name[len(namespace_prefix):]
255
-
254
+ record_type_name = record_type_name[len(namespace_prefix) :]
255
+
256
256
  return f"{object_name}.{record_type_name}"
257
257
 
258
258
  def _set_record_types(self, tree, api_name):
@@ -287,13 +287,17 @@ class ProfileGrantAllAccess(MetadataSingleEntityTransformTask, BaseSalesforceApi
287
287
  # Look for the recordTypeVisibilities element
288
288
  # First try with the original record type
289
289
  elem = tree.find("recordTypeVisibilities", recordType=rt["record_type"])
290
-
291
- # If not found and we're in a namespaced org, try with namespace stripped
290
+
291
+ # If not found and we're in a namespaced org, try with namespace stripped
292
292
  # (since Salesforce may return without namespace in namespaced orgs)
293
293
  if elem is None and self.options.get("namespaced_org"):
294
- record_type_without_namespace = self._strip_namespace_from_record_type(rt["record_type"])
295
- elem = tree.find("recordTypeVisibilities", recordType=record_type_without_namespace)
296
-
294
+ record_type_without_namespace = self._strip_namespace_from_record_type(
295
+ rt["record_type"]
296
+ )
297
+ elem = tree.find(
298
+ "recordTypeVisibilities", recordType=record_type_without_namespace
299
+ )
300
+
297
301
  if elem is None:
298
302
  raise TaskOptionsError(
299
303
  f"Record Type {rt['record_type']} (or {record_type_without_namespace}) not found in retrieved {api_name}.profile"
@@ -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 = [