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,376 @@
1
+ """SFDmu task for CumulusCI."""
2
+
3
+ import os
4
+ import shutil
5
+
6
+ import sarge
7
+
8
+ from cumulusci.core.exceptions import TaskOptionsError
9
+ from cumulusci.core.sfdx import sfdx
10
+ from cumulusci.core.tasks import BaseSalesforceTask
11
+ from cumulusci.core.utils import determine_managed_mode
12
+ from cumulusci.tasks.command import Command
13
+
14
+
15
+ class SfdmuTask(BaseSalesforceTask, Command):
16
+ """Execute SFDmu data migration with namespace injection support."""
17
+
18
+ salesforce_task = (
19
+ False # Override to False since we manage our own org requirements
20
+ )
21
+
22
+ task_options = {
23
+ "source": {
24
+ "description": "Source org name (CCI org name like dev, beta, qa, etc.) or 'csvfile'",
25
+ "required": True,
26
+ },
27
+ "target": {
28
+ "description": "Target org name (CCI org name like dev, beta, qa, etc.) or 'csvfile'",
29
+ "required": True,
30
+ },
31
+ "path": {
32
+ "description": "Path to folder containing export.json and other CSV files",
33
+ "required": True,
34
+ },
35
+ "additional_params": {
36
+ "description": "Additional parameters to append to the sf sfdmu command (e.g., '--simulation --noprompt --nowarnings')",
37
+ "required": False,
38
+ },
39
+ "return_always_success": {
40
+ "description": "If True, the task will return success (exit code 0) even if SFDMU fails. A warning will be logged instead of raising an error.",
41
+ "required": False,
42
+ "default": False,
43
+ },
44
+ }
45
+
46
+ def _init_options(self, kwargs):
47
+ super()._init_options(kwargs)
48
+
49
+ # Convert path to absolute path
50
+ if "path" in self.options and self.options["path"]:
51
+ if os.path.isabs(self.options["path"]):
52
+ # Path is already absolute, normalize it
53
+ self.options["path"] = os.path.abspath(self.options["path"])
54
+ else:
55
+ # Path is relative, join with repo_root
56
+ repo_root = self.project_config.repo_root
57
+ if not repo_root:
58
+ raise TaskOptionsError(
59
+ "Cannot resolve relative path: no repository root found"
60
+ )
61
+ self.options["path"] = os.path.abspath(
62
+ os.path.join(repo_root, self.options["path"])
63
+ )
64
+
65
+ # Validate that the path exists and contains export.json
66
+ if not os.path.exists(self.options["path"]):
67
+ raise TaskOptionsError(f"Path {self.options['path']} does not exist")
68
+
69
+ export_json_path = os.path.join(self.options["path"], "export.json")
70
+ if not os.path.exists(export_json_path):
71
+ raise TaskOptionsError(f"export.json not found in {self.options['path']}")
72
+
73
+ def _validate_org(self, org_name):
74
+ """Validate that a CCI org exists and return the org config."""
75
+ if org_name == "csvfile":
76
+ return None
77
+
78
+ try:
79
+ if self.project_config.keychain is None:
80
+ raise TaskOptionsError("No keychain available")
81
+ org_config = self.project_config.keychain.get_org(org_name)
82
+ return org_config
83
+ except Exception as e:
84
+ raise TaskOptionsError(f"Org '{org_name}' does not exist: {str(e)}")
85
+
86
+ def _get_sf_org_name(self, org_config):
87
+ """Get the SF org name from org config."""
88
+ if hasattr(org_config, "sfdx_alias") and org_config.sfdx_alias:
89
+ return org_config.sfdx_alias
90
+ elif hasattr(org_config, "username") and org_config.username:
91
+ return org_config.username
92
+ else:
93
+ raise TaskOptionsError("Could not determine SF org name for org config")
94
+
95
+ def _create_execute_directory(self, base_path):
96
+ """Create /execute directory and copy files from base_path."""
97
+ execute_path = os.path.join(base_path, "execute")
98
+
99
+ # Remove existing execute directory if it exists
100
+ if os.path.exists(execute_path):
101
+ shutil.rmtree(execute_path)
102
+
103
+ # Create execute directory
104
+ os.makedirs(execute_path, exist_ok=True)
105
+
106
+ # Copy only files (not directories) from base_path to execute
107
+ for item in os.listdir(base_path):
108
+ item_path = os.path.join(base_path, item)
109
+ if os.path.isfile(item_path) and item.endswith((".json", ".csv")):
110
+ shutil.copy2(item_path, execute_path)
111
+
112
+ return execute_path
113
+
114
+ def _update_credentials(self):
115
+ """Override to handle cases where org_config might be None."""
116
+ # Only update credentials if we have an org_config
117
+ if self.org_config is not None:
118
+ super()._update_credentials()
119
+
120
+ def _inject_namespace_tokens(
121
+ self, execute_path, source_org_config, target_org_config
122
+ ):
123
+ """Inject namespace tokens into files in execute directory using the same mechanism as Deploy task."""
124
+ # Determine which org config to use for namespace injection
125
+ # When exporting (source=org, target=csvfile), use source org
126
+ # When importing (source=csvfile, target=org), use target org
127
+ # When transferring (source=org, target=org), use target org
128
+ org_config_for_injection = (
129
+ target_org_config if target_org_config is not None else source_org_config
130
+ )
131
+
132
+ if (
133
+ org_config_for_injection is None
134
+ ): # both source and target are csvfile (unlikely but handle it)
135
+ return
136
+
137
+ # Get namespace information
138
+ namespace = self.project_config.project__package__namespace
139
+ managed = determine_managed_mode(
140
+ self.options, self.project_config, org_config_for_injection
141
+ )
142
+ namespaced_org = bool(namespace) and namespace == getattr(
143
+ org_config_for_injection, "namespace", None
144
+ )
145
+
146
+ # Create a temporary zipfile with all files from execute directory
147
+ import tempfile
148
+ import zipfile
149
+
150
+ from cumulusci.core.dependencies.utils import TaskContext
151
+ from cumulusci.core.source_transforms.transforms import (
152
+ NamespaceInjectionOptions,
153
+ NamespaceInjectionTransform,
154
+ )
155
+
156
+ with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as temp_zip:
157
+ temp_zip_path = temp_zip.name
158
+
159
+ try:
160
+ # Create zipfile with all files from execute directory
161
+ with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
162
+ for root, dirs, files in os.walk(execute_path):
163
+ for file in files:
164
+ if file.endswith((".json", ".csv")):
165
+ file_path = os.path.join(root, file)
166
+ # Calculate relative path from execute_path
167
+ rel_path = os.path.relpath(file_path, execute_path)
168
+ zf.write(file_path, rel_path)
169
+
170
+ # Apply namespace injection using the same mechanism as Deploy task
171
+ with zipfile.ZipFile(temp_zip_path, "r") as zf:
172
+ # Create namespace injection options
173
+ options = NamespaceInjectionOptions(
174
+ namespace_tokenize=None,
175
+ namespace_inject=namespace,
176
+ namespace_strip=None,
177
+ unmanaged=not managed,
178
+ namespaced_org=namespaced_org,
179
+ )
180
+
181
+ # Create transform
182
+ transform = NamespaceInjectionTransform(options)
183
+
184
+ # Create task context
185
+ context = TaskContext(
186
+ org_config_for_injection, self.project_config, self.logger
187
+ )
188
+
189
+ # Apply namespace injection
190
+ new_zf = transform.process(zf, context)
191
+
192
+ # Extract processed files back to execute directory
193
+ # First, remove all existing files
194
+ for root, dirs, files in os.walk(execute_path):
195
+ for file in files:
196
+ if file.endswith((".json", ".csv")):
197
+ os.remove(os.path.join(root, file))
198
+
199
+ # Extract processed files
200
+ for file_info in new_zf.infolist():
201
+ if file_info.filename.endswith((".json", ".csv")):
202
+ # Extract to execute directory
203
+ target_path = os.path.join(execute_path, file_info.filename)
204
+ # Ensure directory exists
205
+ os.makedirs(os.path.dirname(target_path), exist_ok=True)
206
+ with new_zf.open(file_info) as source:
207
+ with open(target_path, "wb") as target:
208
+ target.write(source.read())
209
+
210
+ self.logger.info(
211
+ f"Applied namespace injection to {file_info.filename}"
212
+ )
213
+
214
+ finally:
215
+ # Clean up temporary zipfile
216
+ if os.path.exists(temp_zip_path):
217
+ os.unlink(temp_zip_path)
218
+
219
+ def _process_csv_exports(self, execute_path, base_path):
220
+ """Process CSV files when target is csvfile.
221
+
222
+ This method performs the following operations:
223
+ 1. Replace namespace prefix with %%%MANAGED_OR_NAMESPACED_ORG%%% in CSV file contents
224
+ 2. Rename CSV files replacing namespace prefix with ___MANAGED_OR_NAMESPACED_ORG___
225
+ 3. Copy all CSV files from execute folder to base path, replacing existing files
226
+ """
227
+ namespace = self.project_config.project__package__namespace
228
+ if not namespace:
229
+ self.logger.info("No namespace configured, skipping CSV post-processing")
230
+ return
231
+
232
+ namespace_prefix = namespace + "__"
233
+ content_token = "%%%MANAGED_OR_NAMESPACED_ORG%%%"
234
+ filename_token = "___MANAGED_OR_NAMESPACED_ORG___"
235
+
236
+ # Get all CSV files in execute directory
237
+ csv_files = [f for f in os.listdir(execute_path) if f.endswith(".csv")]
238
+
239
+ if not csv_files:
240
+ self.logger.info("No CSV files found in execute directory")
241
+ return
242
+
243
+ self.logger.info(f"Processing {len(csv_files)} CSV file(s) for export")
244
+
245
+ # Process each CSV file
246
+ processed_files = []
247
+ for filename in csv_files:
248
+ file_path = os.path.join(execute_path, filename)
249
+
250
+ # Step 1: Replace namespace prefix in file contents
251
+ with open(file_path, "r", encoding="utf-8") as f:
252
+ content = f.read()
253
+
254
+ if namespace_prefix in content:
255
+ content = content.replace(namespace_prefix, content_token)
256
+ with open(file_path, "w", encoding="utf-8") as f:
257
+ f.write(content)
258
+ self.logger.debug(f"Replaced namespace prefix in content of {filename}")
259
+
260
+ # Step 2: Rename file if it contains namespace prefix
261
+ new_filename = filename.replace(namespace_prefix, filename_token)
262
+ if new_filename != filename:
263
+ new_file_path = os.path.join(execute_path, new_filename)
264
+ os.rename(file_path, new_file_path)
265
+ self.logger.debug(f"Renamed file: {filename} -> {new_filename}")
266
+ file_path = new_file_path
267
+ filename = new_filename
268
+
269
+ processed_files.append((file_path, filename))
270
+
271
+ # Step 3: Delete all CSV files in base_path and copy processed files
272
+ self.logger.debug(f"Copying processed CSV files to {base_path}")
273
+
274
+ # Remove existing CSV files in base_path
275
+ for item in os.listdir(base_path):
276
+ if item.endswith(".csv"):
277
+ item_path = os.path.join(base_path, item)
278
+ if os.path.isfile(item_path):
279
+ os.remove(item_path)
280
+ self.logger.debug(f"Removed existing file: {item}")
281
+
282
+ # Copy processed files to base_path
283
+ for file_path, filename in processed_files:
284
+ target_path = os.path.join(base_path, filename)
285
+ shutil.copy2(file_path, target_path)
286
+ self.logger.debug(f"Copied {filename} to {base_path}")
287
+
288
+ self.logger.info("CSV post-processing completed successfully")
289
+
290
+ def _run_task(self):
291
+ """Execute the SFDmu task."""
292
+ # Validate source and target orgs
293
+ source_org_config = self._validate_org(self.options["source"])
294
+ target_org_config = self._validate_org(self.options["target"])
295
+
296
+ # Get SF org names
297
+ if source_org_config:
298
+ source_sf_org = self._get_sf_org_name(source_org_config)
299
+ else:
300
+ source_sf_org = "csvfile"
301
+
302
+ if target_org_config:
303
+ target_sf_org = self._get_sf_org_name(target_org_config)
304
+ else:
305
+ target_sf_org = "csvfile"
306
+
307
+ # Create execute directory and copy files
308
+ execute_path = self._create_execute_directory(self.options["path"])
309
+ self.logger.info(f"Created execute directory at {execute_path}")
310
+
311
+ # Apply namespace injection
312
+ self._inject_namespace_tokens(
313
+ execute_path, source_org_config, target_org_config
314
+ )
315
+
316
+ # Build and execute SFDmu command
317
+ # Use shell_quote to properly handle paths with spaces on Windows
318
+ command_parts = [
319
+ "-s",
320
+ source_sf_org,
321
+ "-u",
322
+ target_sf_org,
323
+ "-p",
324
+ execute_path,
325
+ ]
326
+
327
+ # Append additional parameters if provided
328
+ if self.options.get("additional_params"):
329
+ # Split the additional_params string into individual arguments
330
+ # This handles cases like "-no-warnings -m -t error" -> ["-no-warnings", "-m", "-t", "error"]
331
+ additional_args = self.options["additional_params"].split()
332
+ # Quote each argument to handle spaces properly
333
+ command_parts.extend(additional_args)
334
+
335
+ # Join command parts into a single string for sarge (which uses shell=True)
336
+ command = "sf sfdmu run " + " ".join(command_parts)
337
+ self.logger.info(f"Executing: {command}")
338
+
339
+ # Determine if we should fail on error or just warn
340
+ return_always_success = self.options.get("return_always_success", False)
341
+
342
+ try:
343
+ p: sarge.Command = sfdx(
344
+ "sfdmu run",
345
+ log_note="Running SFDmu",
346
+ args=command_parts,
347
+ check_return=not return_always_success, # Don't check return if return_always_success is True
348
+ username=None,
349
+ )
350
+
351
+ for line in p.stdout_text:
352
+ self.logger.info(line)
353
+
354
+ for line in p.stderr_text:
355
+ self.logger.error(line)
356
+
357
+ # Check if command failed when return_always_success is True
358
+ if return_always_success and p.returncode != 0:
359
+ self.logger.warning(
360
+ f"SFDmu command failed with exit code {p.returncode}, but return_always_success is True. "
361
+ "Task will continue and return success."
362
+ )
363
+ else:
364
+ self.logger.info("SFDmu task completed successfully")
365
+ except Exception as e:
366
+ if return_always_success:
367
+ self.logger.warning(
368
+ f"SFDmu command failed with error: {str(e)}, but return_always_success is True. "
369
+ "Task will continue and return success."
370
+ )
371
+ else:
372
+ raise
373
+
374
+ # Post-process CSV files if target is csvfile
375
+ if self.options["target"] == "csvfile":
376
+ self._process_csv_exports(execute_path, self.options["path"])
@@ -0,0 +1 @@
1
+ """Tests for SFDmu tasks."""
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env python3
2
+ """Simple test runner for SFDmu tests."""
3
+
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ from unittest import mock
8
+
9
+ # Add the project root to the path
10
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
11
+
12
+ from cumulusci.tasks.salesforce.tests.util import create_task
13
+ from cumulusci.tasks.sfdmu.sfdmu import SfdmuTask
14
+
15
+
16
+ def test_create_execute_directory():
17
+ """Test _create_execute_directory creates directory and copies files."""
18
+ with tempfile.TemporaryDirectory() as base_dir:
19
+ # Create test files
20
+ export_json = os.path.join(base_dir, "export.json")
21
+ test_csv = os.path.join(base_dir, "test.csv")
22
+ test_txt = os.path.join(base_dir, "test.txt") # Should not be copied
23
+
24
+ with open(export_json, "w") as f:
25
+ f.write('{"test": "data"}')
26
+ with open(test_csv, "w") as f:
27
+ f.write("col1,col2\nval1,val2")
28
+ with open(test_txt, "w") as f:
29
+ f.write("text file")
30
+
31
+ # Create subdirectory (should not be copied)
32
+ subdir = os.path.join(base_dir, "subdir")
33
+ os.makedirs(subdir)
34
+ with open(os.path.join(subdir, "file.txt"), "w") as f:
35
+ f.write("subdir file")
36
+
37
+ task = create_task(
38
+ SfdmuTask, {"source": "dev", "target": "qa", "path": base_dir}
39
+ )
40
+
41
+ execute_path = task._create_execute_directory(base_dir)
42
+
43
+ # Check that execute directory was created
44
+ assert os.path.exists(execute_path)
45
+ assert execute_path == os.path.join(base_dir, "execute")
46
+
47
+ # Check that files were copied
48
+ assert os.path.exists(os.path.join(execute_path, "export.json"))
49
+ assert os.path.exists(os.path.join(execute_path, "test.csv"))
50
+ assert not os.path.exists(
51
+ os.path.join(execute_path, "test.txt")
52
+ ) # Not a valid file type
53
+ assert not os.path.exists(os.path.join(execute_path, "subdir")) # Not a file
54
+
55
+ # Check file contents
56
+ with open(os.path.join(execute_path, "export.json"), "r") as f:
57
+ assert f.read() == '{"test": "data"}'
58
+ with open(os.path.join(execute_path, "test.csv"), "r") as f:
59
+ assert f.read() == "col1,col2\nval1,val2"
60
+
61
+ print("✅ test_create_execute_directory passed")
62
+
63
+
64
+ def test_inject_namespace_tokens_csvfile_target():
65
+ """Test that namespace injection is skipped when both source and target are csvfile."""
66
+ with tempfile.TemporaryDirectory() as execute_dir:
67
+ # Create test files
68
+ test_json = os.path.join(execute_dir, "test.json")
69
+ with open(test_json, "w") as f:
70
+ f.write('{"field": "%%%NAMESPACE%%%Test"}')
71
+
72
+ # Create export.json file
73
+ export_json_path = os.path.join(execute_dir, "export.json")
74
+ with open(export_json_path, "w") as f:
75
+ f.write('{"test": "data"}')
76
+
77
+ task = create_task(
78
+ SfdmuTask, {"source": "csvfile", "target": "csvfile", "path": execute_dir}
79
+ )
80
+
81
+ # Should not raise any errors and files should remain unchanged
82
+ task._inject_namespace_tokens(execute_dir, None, None)
83
+
84
+ # Check that file content was not changed
85
+ with open(test_json, "r") as f:
86
+ assert f.read() == '{"field": "%%%NAMESPACE%%%Test"}'
87
+
88
+ print("✅ test_inject_namespace_tokens_csvfile_target passed")
89
+
90
+
91
+ def test_inject_namespace_tokens_managed_mode():
92
+ """Test namespace injection in managed mode."""
93
+ with tempfile.TemporaryDirectory() as execute_dir:
94
+ # Create test files with namespace tokens
95
+ test_json = os.path.join(execute_dir, "test.json")
96
+ test_csv = os.path.join(execute_dir, "test.csv")
97
+
98
+ with open(test_json, "w") as f:
99
+ f.write(
100
+ '{"field": "%%%NAMESPACE%%%Test", "org": "%%%NAMESPACED_ORG%%%Value"}'
101
+ )
102
+ with open(test_csv, "w") as f:
103
+ f.write("Name,%%%NAMESPACE%%%Field\nTest,Value")
104
+
105
+ # Create filename with namespace token
106
+ filename_with_token = os.path.join(execute_dir, "___NAMESPACE___test.json")
107
+ with open(filename_with_token, "w") as f:
108
+ f.write('{"test": "data"}')
109
+
110
+ # Create export.json file
111
+ export_json_path = os.path.join(execute_dir, "export.json")
112
+ with open(export_json_path, "w") as f:
113
+ f.write('{"test": "data"}')
114
+
115
+ task = create_task(
116
+ SfdmuTask, {"source": "dev", "target": "qa", "path": execute_dir}
117
+ )
118
+
119
+ # Mock the project config namespace
120
+ task.project_config.project__package__namespace = "testns"
121
+
122
+ mock_org_config = mock.Mock()
123
+ mock_org_config.namespace = "testns"
124
+
125
+ # Mock determine_managed_mode to return True
126
+ with mock.patch(
127
+ "cumulusci.tasks.sfdmu.sfdmu.determine_managed_mode", return_value=True
128
+ ):
129
+ task._inject_namespace_tokens(execute_dir, None, mock_org_config)
130
+
131
+ # Check that namespace tokens were replaced in content
132
+ with open(test_json, "r") as f:
133
+ content = f.read()
134
+ assert "testns__Test" in content
135
+ assert "testns__Value" in content
136
+
137
+ with open(test_csv, "r") as f:
138
+ content = f.read()
139
+ assert "testns__Field" in content
140
+
141
+ # Check that filename token was replaced
142
+ expected_filename = os.path.join(execute_dir, "testns__test.json")
143
+ assert os.path.exists(expected_filename)
144
+ assert not os.path.exists(filename_with_token)
145
+
146
+ print("✅ test_inject_namespace_tokens_managed_mode passed")
147
+
148
+
149
+ def test_inject_namespace_tokens_unmanaged_mode():
150
+ """Test namespace injection in unmanaged mode."""
151
+ with tempfile.TemporaryDirectory() as execute_dir:
152
+ # Create test files with namespace tokens
153
+ test_json = os.path.join(execute_dir, "test.json")
154
+ with open(test_json, "w") as f:
155
+ f.write(
156
+ '{"field": "%%%NAMESPACE%%%Test", "org": "%%%NAMESPACED_ORG%%%Value"}'
157
+ )
158
+
159
+ # Create export.json file
160
+ export_json_path = os.path.join(execute_dir, "export.json")
161
+ with open(export_json_path, "w") as f:
162
+ f.write('{"test": "data"}')
163
+
164
+ task = create_task(
165
+ SfdmuTask, {"source": "dev", "target": "qa", "path": execute_dir}
166
+ )
167
+
168
+ # Mock the project config namespace
169
+ task.project_config.project__package__namespace = "testns"
170
+
171
+ mock_org_config = mock.Mock()
172
+ mock_org_config.namespace = "testns"
173
+
174
+ # Mock determine_managed_mode to return False
175
+ with mock.patch(
176
+ "cumulusci.tasks.sfdmu.sfdmu.determine_managed_mode", return_value=False
177
+ ):
178
+ task._inject_namespace_tokens(execute_dir, None, mock_org_config)
179
+
180
+ # Check that namespace tokens were replaced with empty strings
181
+ with open(test_json, "r") as f:
182
+ content = f.read()
183
+ assert "Test" in content # %%NAMESPACE%% removed
184
+ assert "Value" in content # %%NAMESPACED_ORG%% removed
185
+ assert "%%%NAMESPACE%%%" not in content
186
+ assert "%%%NAMESPACED_ORG%%%" not in content
187
+
188
+ print("✅ test_inject_namespace_tokens_unmanaged_mode passed")
189
+
190
+
191
+ def main():
192
+ """Run all tests."""
193
+ print("Running SFDmu tests...")
194
+
195
+ try:
196
+ test_create_execute_directory()
197
+ test_inject_namespace_tokens_csvfile_target()
198
+ test_inject_namespace_tokens_managed_mode()
199
+ test_inject_namespace_tokens_unmanaged_mode()
200
+
201
+ print("\n🎉 All tests passed!")
202
+ return 0
203
+ except Exception as e:
204
+ print(f"\n❌ Test failed: {e}")
205
+ import traceback
206
+
207
+ traceback.print_exc()
208
+ return 1
209
+
210
+
211
+ if __name__ == "__main__":
212
+ sys.exit(main())