cumulusci-plus 5.0.19__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.
- cumulusci/__about__.py +1 -1
- cumulusci/cli/logger.py +2 -2
- cumulusci/cli/service.py +20 -0
- cumulusci/cli/task.py +17 -0
- cumulusci/cli/tests/test_error.py +3 -1
- cumulusci/cli/tests/test_flow.py +279 -2
- cumulusci/cli/tests/test_service.py +15 -12
- cumulusci/cli/tests/test_task.py +88 -2
- cumulusci/cli/tests/utils.py +1 -4
- cumulusci/core/config/base_task_flow_config.py +26 -1
- cumulusci/core/config/project_config.py +2 -20
- cumulusci/core/config/tests/test_config_expensive.py +9 -3
- cumulusci/core/config/universal_config.py +3 -4
- cumulusci/core/dependencies/base.py +1 -1
- cumulusci/core/dependencies/dependencies.py +1 -1
- cumulusci/core/dependencies/github.py +1 -2
- cumulusci/core/dependencies/resolvers.py +1 -1
- cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
- cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
- cumulusci/core/flowrunner.py +90 -6
- cumulusci/core/github.py +1 -1
- cumulusci/core/sfdx.py +3 -1
- cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
- cumulusci/core/source_transforms/transforms.py +1 -1
- cumulusci/core/tasks.py +13 -2
- cumulusci/core/tests/test_flowrunner.py +100 -0
- cumulusci/core/tests/test_tasks.py +65 -0
- cumulusci/core/utils.py +3 -1
- cumulusci/core/versions.py +1 -1
- cumulusci/cumulusci.yml +55 -0
- cumulusci/oauth/client.py +1 -1
- cumulusci/plugins/plugin_base.py +5 -3
- cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
- cumulusci/salesforce_api/rest_deploy.py +1 -1
- cumulusci/schema/cumulusci.jsonschema.json +64 -0
- cumulusci/tasks/apex/anon.py +1 -1
- cumulusci/tasks/apex/testrunner.py +416 -142
- cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
- cumulusci/tasks/bulkdata/extract.py +0 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
- cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
- cumulusci/tasks/bulkdata/select_utils.py +1 -1
- cumulusci/tasks/bulkdata/snowfakery.py +100 -25
- cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
- cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
- cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
- cumulusci/tasks/bulkdata/tests/test_select_utils.py +26 -0
- cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
- cumulusci/tasks/create_package_version.py +190 -16
- cumulusci/tasks/datadictionary.py +1 -1
- cumulusci/tasks/metadata_etl/base.py +7 -3
- cumulusci/tasks/metadata_etl/layouts.py +1 -1
- cumulusci/tasks/metadata_etl/permissions.py +1 -1
- cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
- cumulusci/tasks/push/README.md +15 -17
- cumulusci/tasks/release_notes/README.md +13 -13
- cumulusci/tasks/release_notes/generator.py +13 -8
- cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
- cumulusci/tasks/salesforce/Deploy.py +53 -2
- cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
- cumulusci/tasks/salesforce/__init__.py +1 -0
- cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
- cumulusci/tasks/salesforce/composite.py +1 -1
- cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
- cumulusci/tasks/salesforce/enable_prediction.py +5 -1
- cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
- cumulusci/tasks/salesforce/profiles.py +13 -9
- cumulusci/tasks/salesforce/sourcetracking.py +1 -1
- cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
- cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
- cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
- cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
- cumulusci/tasks/salesforce/tests/test_profiles.py +43 -3
- cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
- cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
- cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
- cumulusci/tasks/salesforce/update_dependencies.py +2 -2
- cumulusci/tasks/salesforce/update_external_credential.py +562 -0
- cumulusci/tasks/salesforce/update_named_credential.py +441 -0
- cumulusci/tasks/salesforce/update_profile.py +17 -13
- cumulusci/tasks/salesforce/users/permsets.py +62 -5
- cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
- cumulusci/tasks/sfdmu/__init__.py +0 -0
- cumulusci/tasks/sfdmu/sfdmu.py +363 -0
- cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
- cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
- cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
- cumulusci/tasks/tests/test_create_package_version.py +716 -1
- cumulusci/tasks/tests/test_util.py +42 -0
- cumulusci/tasks/util.py +37 -1
- cumulusci/tasks/utility/copyContents.py +402 -0
- cumulusci/tasks/utility/credentialManager.py +256 -0
- cumulusci/tasks/utility/directoryRecreator.py +30 -0
- cumulusci/tasks/utility/env_management.py +1 -1
- cumulusci/tasks/utility/secretsToEnv.py +135 -0
- cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
- cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
- cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
- cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
- cumulusci/tests/test_integration_infrastructure.py +3 -1
- cumulusci/tests/test_utils.py +70 -6
- cumulusci/utils/__init__.py +54 -9
- cumulusci/utils/classutils.py +5 -2
- cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
- cumulusci/utils/options.py +23 -1
- cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
- cumulusci/utils/yaml/cumulusci_yml.py +7 -3
- cumulusci/utils/yaml/model_parser.py +2 -2
- cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
- cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
- cumulusci/vcs/base.py +23 -15
- cumulusci/vcs/bootstrap.py +5 -4
- cumulusci/vcs/utils/list_modified_files.py +189 -0
- cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +123 -98
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,363 @@
|
|
|
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
|
+
self.options["path"] = os.path.abspath(self.options["path"])
|
|
51
|
+
|
|
52
|
+
# Validate that the path exists and contains export.json
|
|
53
|
+
if not os.path.exists(self.options["path"]):
|
|
54
|
+
raise TaskOptionsError(f"Path {self.options['path']} does not exist")
|
|
55
|
+
|
|
56
|
+
export_json_path = os.path.join(self.options["path"], "export.json")
|
|
57
|
+
if not os.path.exists(export_json_path):
|
|
58
|
+
raise TaskOptionsError(f"export.json not found in {self.options['path']}")
|
|
59
|
+
|
|
60
|
+
def _validate_org(self, org_name):
|
|
61
|
+
"""Validate that a CCI org exists and return the org config."""
|
|
62
|
+
if org_name == "csvfile":
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
if self.project_config.keychain is None:
|
|
67
|
+
raise TaskOptionsError("No keychain available")
|
|
68
|
+
org_config = self.project_config.keychain.get_org(org_name)
|
|
69
|
+
return org_config
|
|
70
|
+
except Exception as e:
|
|
71
|
+
raise TaskOptionsError(f"Org '{org_name}' does not exist: {str(e)}")
|
|
72
|
+
|
|
73
|
+
def _get_sf_org_name(self, org_config):
|
|
74
|
+
"""Get the SF org name from org config."""
|
|
75
|
+
if hasattr(org_config, "sfdx_alias") and org_config.sfdx_alias:
|
|
76
|
+
return org_config.sfdx_alias
|
|
77
|
+
elif hasattr(org_config, "username") and org_config.username:
|
|
78
|
+
return org_config.username
|
|
79
|
+
else:
|
|
80
|
+
raise TaskOptionsError("Could not determine SF org name for org config")
|
|
81
|
+
|
|
82
|
+
def _create_execute_directory(self, base_path):
|
|
83
|
+
"""Create /execute directory and copy files from base_path."""
|
|
84
|
+
execute_path = os.path.join(base_path, "execute")
|
|
85
|
+
|
|
86
|
+
# Remove existing execute directory if it exists
|
|
87
|
+
if os.path.exists(execute_path):
|
|
88
|
+
shutil.rmtree(execute_path)
|
|
89
|
+
|
|
90
|
+
# Create execute directory
|
|
91
|
+
os.makedirs(execute_path, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
# Copy only files (not directories) from base_path to execute
|
|
94
|
+
for item in os.listdir(base_path):
|
|
95
|
+
item_path = os.path.join(base_path, item)
|
|
96
|
+
if os.path.isfile(item_path) and item.endswith((".json", ".csv")):
|
|
97
|
+
shutil.copy2(item_path, execute_path)
|
|
98
|
+
|
|
99
|
+
return execute_path
|
|
100
|
+
|
|
101
|
+
def _update_credentials(self):
|
|
102
|
+
"""Override to handle cases where org_config might be None."""
|
|
103
|
+
# Only update credentials if we have an org_config
|
|
104
|
+
if self.org_config is not None:
|
|
105
|
+
super()._update_credentials()
|
|
106
|
+
|
|
107
|
+
def _inject_namespace_tokens(
|
|
108
|
+
self, execute_path, source_org_config, target_org_config
|
|
109
|
+
):
|
|
110
|
+
"""Inject namespace tokens into files in execute directory using the same mechanism as Deploy task."""
|
|
111
|
+
# Determine which org config to use for namespace injection
|
|
112
|
+
# When exporting (source=org, target=csvfile), use source org
|
|
113
|
+
# When importing (source=csvfile, target=org), use target org
|
|
114
|
+
# When transferring (source=org, target=org), use target org
|
|
115
|
+
org_config_for_injection = (
|
|
116
|
+
target_org_config if target_org_config is not None else source_org_config
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
org_config_for_injection is None
|
|
121
|
+
): # both source and target are csvfile (unlikely but handle it)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Get namespace information
|
|
125
|
+
namespace = self.project_config.project__package__namespace
|
|
126
|
+
managed = determine_managed_mode(
|
|
127
|
+
self.options, self.project_config, org_config_for_injection
|
|
128
|
+
)
|
|
129
|
+
namespaced_org = bool(namespace) and namespace == getattr(
|
|
130
|
+
org_config_for_injection, "namespace", None
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Create a temporary zipfile with all files from execute directory
|
|
134
|
+
import tempfile
|
|
135
|
+
import zipfile
|
|
136
|
+
|
|
137
|
+
from cumulusci.core.dependencies.utils import TaskContext
|
|
138
|
+
from cumulusci.core.source_transforms.transforms import (
|
|
139
|
+
NamespaceInjectionOptions,
|
|
140
|
+
NamespaceInjectionTransform,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as temp_zip:
|
|
144
|
+
temp_zip_path = temp_zip.name
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
# Create zipfile with all files from execute directory
|
|
148
|
+
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
149
|
+
for root, dirs, files in os.walk(execute_path):
|
|
150
|
+
for file in files:
|
|
151
|
+
if file.endswith((".json", ".csv")):
|
|
152
|
+
file_path = os.path.join(root, file)
|
|
153
|
+
# Calculate relative path from execute_path
|
|
154
|
+
rel_path = os.path.relpath(file_path, execute_path)
|
|
155
|
+
zf.write(file_path, rel_path)
|
|
156
|
+
|
|
157
|
+
# Apply namespace injection using the same mechanism as Deploy task
|
|
158
|
+
with zipfile.ZipFile(temp_zip_path, "r") as zf:
|
|
159
|
+
# Create namespace injection options
|
|
160
|
+
options = NamespaceInjectionOptions(
|
|
161
|
+
namespace_tokenize=None,
|
|
162
|
+
namespace_inject=namespace,
|
|
163
|
+
namespace_strip=None,
|
|
164
|
+
unmanaged=not managed,
|
|
165
|
+
namespaced_org=namespaced_org,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Create transform
|
|
169
|
+
transform = NamespaceInjectionTransform(options)
|
|
170
|
+
|
|
171
|
+
# Create task context
|
|
172
|
+
context = TaskContext(
|
|
173
|
+
org_config_for_injection, self.project_config, self.logger
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Apply namespace injection
|
|
177
|
+
new_zf = transform.process(zf, context)
|
|
178
|
+
|
|
179
|
+
# Extract processed files back to execute directory
|
|
180
|
+
# First, remove all existing files
|
|
181
|
+
for root, dirs, files in os.walk(execute_path):
|
|
182
|
+
for file in files:
|
|
183
|
+
if file.endswith((".json", ".csv")):
|
|
184
|
+
os.remove(os.path.join(root, file))
|
|
185
|
+
|
|
186
|
+
# Extract processed files
|
|
187
|
+
for file_info in new_zf.infolist():
|
|
188
|
+
if file_info.filename.endswith((".json", ".csv")):
|
|
189
|
+
# Extract to execute directory
|
|
190
|
+
target_path = os.path.join(execute_path, file_info.filename)
|
|
191
|
+
# Ensure directory exists
|
|
192
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
193
|
+
with new_zf.open(file_info) as source:
|
|
194
|
+
with open(target_path, "wb") as target:
|
|
195
|
+
target.write(source.read())
|
|
196
|
+
|
|
197
|
+
self.logger.info(
|
|
198
|
+
f"Applied namespace injection to {file_info.filename}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
finally:
|
|
202
|
+
# Clean up temporary zipfile
|
|
203
|
+
if os.path.exists(temp_zip_path):
|
|
204
|
+
os.unlink(temp_zip_path)
|
|
205
|
+
|
|
206
|
+
def _process_csv_exports(self, execute_path, base_path):
|
|
207
|
+
"""Process CSV files when target is csvfile.
|
|
208
|
+
|
|
209
|
+
This method performs the following operations:
|
|
210
|
+
1. Replace namespace prefix with %%%MANAGED_OR_NAMESPACED_ORG%%% in CSV file contents
|
|
211
|
+
2. Rename CSV files replacing namespace prefix with ___MANAGED_OR_NAMESPACED_ORG___
|
|
212
|
+
3. Copy all CSV files from execute folder to base path, replacing existing files
|
|
213
|
+
"""
|
|
214
|
+
namespace = self.project_config.project__package__namespace
|
|
215
|
+
if not namespace:
|
|
216
|
+
self.logger.info("No namespace configured, skipping CSV post-processing")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
namespace_prefix = namespace + "__"
|
|
220
|
+
content_token = "%%%MANAGED_OR_NAMESPACED_ORG%%%"
|
|
221
|
+
filename_token = "___MANAGED_OR_NAMESPACED_ORG___"
|
|
222
|
+
|
|
223
|
+
# Get all CSV files in execute directory
|
|
224
|
+
csv_files = [f for f in os.listdir(execute_path) if f.endswith(".csv")]
|
|
225
|
+
|
|
226
|
+
if not csv_files:
|
|
227
|
+
self.logger.info("No CSV files found in execute directory")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
self.logger.info(f"Processing {len(csv_files)} CSV file(s) for export")
|
|
231
|
+
|
|
232
|
+
# Process each CSV file
|
|
233
|
+
processed_files = []
|
|
234
|
+
for filename in csv_files:
|
|
235
|
+
file_path = os.path.join(execute_path, filename)
|
|
236
|
+
|
|
237
|
+
# Step 1: Replace namespace prefix in file contents
|
|
238
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
239
|
+
content = f.read()
|
|
240
|
+
|
|
241
|
+
if namespace_prefix in content:
|
|
242
|
+
content = content.replace(namespace_prefix, content_token)
|
|
243
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
244
|
+
f.write(content)
|
|
245
|
+
self.logger.debug(f"Replaced namespace prefix in content of {filename}")
|
|
246
|
+
|
|
247
|
+
# Step 2: Rename file if it contains namespace prefix
|
|
248
|
+
new_filename = filename.replace(namespace_prefix, filename_token)
|
|
249
|
+
if new_filename != filename:
|
|
250
|
+
new_file_path = os.path.join(execute_path, new_filename)
|
|
251
|
+
os.rename(file_path, new_file_path)
|
|
252
|
+
self.logger.debug(f"Renamed file: {filename} -> {new_filename}")
|
|
253
|
+
file_path = new_file_path
|
|
254
|
+
filename = new_filename
|
|
255
|
+
|
|
256
|
+
processed_files.append((file_path, filename))
|
|
257
|
+
|
|
258
|
+
# Step 3: Delete all CSV files in base_path and copy processed files
|
|
259
|
+
self.logger.debug(f"Copying processed CSV files to {base_path}")
|
|
260
|
+
|
|
261
|
+
# Remove existing CSV files in base_path
|
|
262
|
+
for item in os.listdir(base_path):
|
|
263
|
+
if item.endswith(".csv"):
|
|
264
|
+
item_path = os.path.join(base_path, item)
|
|
265
|
+
if os.path.isfile(item_path):
|
|
266
|
+
os.remove(item_path)
|
|
267
|
+
self.logger.debug(f"Removed existing file: {item}")
|
|
268
|
+
|
|
269
|
+
# Copy processed files to base_path
|
|
270
|
+
for file_path, filename in processed_files:
|
|
271
|
+
target_path = os.path.join(base_path, filename)
|
|
272
|
+
shutil.copy2(file_path, target_path)
|
|
273
|
+
self.logger.debug(f"Copied {filename} to {base_path}")
|
|
274
|
+
|
|
275
|
+
self.logger.info("CSV post-processing completed successfully")
|
|
276
|
+
|
|
277
|
+
def _run_task(self):
|
|
278
|
+
"""Execute the SFDmu task."""
|
|
279
|
+
# Validate source and target orgs
|
|
280
|
+
source_org_config = self._validate_org(self.options["source"])
|
|
281
|
+
target_org_config = self._validate_org(self.options["target"])
|
|
282
|
+
|
|
283
|
+
# Get SF org names
|
|
284
|
+
if source_org_config:
|
|
285
|
+
source_sf_org = self._get_sf_org_name(source_org_config)
|
|
286
|
+
else:
|
|
287
|
+
source_sf_org = "csvfile"
|
|
288
|
+
|
|
289
|
+
if target_org_config:
|
|
290
|
+
target_sf_org = self._get_sf_org_name(target_org_config)
|
|
291
|
+
else:
|
|
292
|
+
target_sf_org = "csvfile"
|
|
293
|
+
|
|
294
|
+
# Create execute directory and copy files
|
|
295
|
+
execute_path = self._create_execute_directory(self.options["path"])
|
|
296
|
+
self.logger.info(f"Created execute directory at {execute_path}")
|
|
297
|
+
|
|
298
|
+
# Apply namespace injection
|
|
299
|
+
self._inject_namespace_tokens(
|
|
300
|
+
execute_path, source_org_config, target_org_config
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Build and execute SFDmu command
|
|
304
|
+
# Use shell_quote to properly handle paths with spaces on Windows
|
|
305
|
+
command_parts = [
|
|
306
|
+
"-s",
|
|
307
|
+
source_sf_org,
|
|
308
|
+
"-u",
|
|
309
|
+
target_sf_org,
|
|
310
|
+
"-p",
|
|
311
|
+
execute_path,
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
# Append additional parameters if provided
|
|
315
|
+
if self.options.get("additional_params"):
|
|
316
|
+
# Split the additional_params string into individual arguments
|
|
317
|
+
# This handles cases like "-no-warnings -m -t error" -> ["-no-warnings", "-m", "-t", "error"]
|
|
318
|
+
additional_args = self.options["additional_params"].split()
|
|
319
|
+
# Quote each argument to handle spaces properly
|
|
320
|
+
command_parts.extend(additional_args)
|
|
321
|
+
|
|
322
|
+
# Join command parts into a single string for sarge (which uses shell=True)
|
|
323
|
+
command = "sf sfdmu run " + " ".join(command_parts)
|
|
324
|
+
self.logger.info(f"Executing: {command}")
|
|
325
|
+
|
|
326
|
+
# Determine if we should fail on error or just warn
|
|
327
|
+
return_always_success = self.options.get("return_always_success", False)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
p: sarge.Command = sfdx(
|
|
331
|
+
"sfdmu run",
|
|
332
|
+
log_note="Running SFDmu",
|
|
333
|
+
args=command_parts,
|
|
334
|
+
check_return=not return_always_success, # Don't check return if return_always_success is True
|
|
335
|
+
username=None,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
for line in p.stdout_text:
|
|
339
|
+
self.logger.info(line)
|
|
340
|
+
|
|
341
|
+
for line in p.stderr_text:
|
|
342
|
+
self.logger.error(line)
|
|
343
|
+
|
|
344
|
+
# Check if command failed when return_always_success is True
|
|
345
|
+
if return_always_success and p.returncode != 0:
|
|
346
|
+
self.logger.warning(
|
|
347
|
+
f"SFDmu command failed with exit code {p.returncode}, but return_always_success is True. "
|
|
348
|
+
"Task will continue and return success."
|
|
349
|
+
)
|
|
350
|
+
else:
|
|
351
|
+
self.logger.info("SFDmu task completed successfully")
|
|
352
|
+
except Exception as e:
|
|
353
|
+
if return_always_success:
|
|
354
|
+
self.logger.warning(
|
|
355
|
+
f"SFDmu command failed with error: {str(e)}, but return_always_success is True. "
|
|
356
|
+
"Task will continue and return success."
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
raise
|
|
360
|
+
|
|
361
|
+
# Post-process CSV files if target is csvfile
|
|
362
|
+
if self.options["target"] == "csvfile":
|
|
363
|
+
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())
|