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.
- cumulusci/__about__.py +1 -1
- cumulusci/cli/logger.py +2 -2
- cumulusci/cli/service.py +20 -0
- cumulusci/cli/task.py +19 -3
- cumulusci/cli/tests/test_error.py +3 -1
- cumulusci/cli/tests/test_flow.py +279 -2
- cumulusci/cli/tests/test_org.py +5 -0
- cumulusci/cli/tests/test_service.py +15 -12
- cumulusci/cli/tests/test_task.py +122 -2
- cumulusci/cli/tests/utils.py +1 -4
- cumulusci/core/config/__init__.py +1 -0
- cumulusci/core/config/base_task_flow_config.py +26 -1
- cumulusci/core/config/org_config.py +2 -1
- cumulusci/core/config/project_config.py +14 -20
- cumulusci/core/config/scratch_org_config.py +12 -0
- cumulusci/core/config/tests/test_config.py +1 -0
- cumulusci/core/config/tests/test_config_expensive.py +9 -3
- cumulusci/core/config/universal_config.py +3 -4
- cumulusci/core/dependencies/base.py +5 -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 +73 -1
- 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 +69 -0
- cumulusci/tasks/apex/anon.py +1 -1
- cumulusci/tasks/apex/testrunner.py +421 -144
- 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 +46 -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/__init__.py +2 -0
- cumulusci/tasks/metadata_etl/applications.py +256 -0
- 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/metadata_etl/tests/test_applications.py +710 -0
- 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/insert_record.py +18 -19
- 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_enable_prediction.py +4 -2
- cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
- cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
- cumulusci/tasks/salesforce/tests/test_update_external_auth_identity_provider.py +927 -0
- cumulusci/tasks/salesforce/tests/test_update_external_credential.py +1427 -0
- cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
- cumulusci/tasks/salesforce/tests/test_update_record.py +512 -0
- cumulusci/tasks/salesforce/update_dependencies.py +2 -2
- cumulusci/tasks/salesforce/update_external_auth_identity_provider.py +551 -0
- cumulusci/tasks/salesforce/update_external_credential.py +647 -0
- cumulusci/tasks/salesforce/update_named_credential.py +441 -0
- cumulusci/tasks/salesforce/update_profile.py +17 -13
- cumulusci/tasks/salesforce/update_record.py +217 -0
- 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 +376 -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 +302 -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 +1150 -0
- cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
- cumulusci/tasks/utility/tests/test_secretsToEnv.py +1118 -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 +8 -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.21.dist-info → cumulusci_plus-5.0.43.dist-info}/METADATA +11 -10
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/RECORD +135 -104
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/WHEEL +1 -1
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/entry_points.txt +0 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/AUTHORS.rst +0 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/LICENSE +0 -0
|
@@ -182,6 +182,48 @@ class TestUtilTasks:
|
|
|
182
182
|
|
|
183
183
|
assert os.path.exists(dest)
|
|
184
184
|
|
|
185
|
+
@pytest.mark.skipif(os.name != "posix", reason="Only run on POSIX systems")
|
|
186
|
+
def test_CopyFileVars(self):
|
|
187
|
+
os.environ["TMPDIR"] = "/tmp"
|
|
188
|
+
src_expanded = os.path.expandvars(os.path.join("$TMPDIR", "src"))
|
|
189
|
+
with open(src_expanded, "w"):
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
src = os.path.join("&TMPDIR&", "src")
|
|
193
|
+
dest = os.path.join("&TMPDIR&", "dest")
|
|
194
|
+
|
|
195
|
+
task_config = TaskConfig({"options": {"src": src, "dest": dest}})
|
|
196
|
+
task = util.CopyFile(self.project_config, task_config, self.org_config)
|
|
197
|
+
task()
|
|
198
|
+
|
|
199
|
+
assert os.path.exists(os.path.expandvars(os.path.join("$TMPDIR", "dest")))
|
|
200
|
+
|
|
201
|
+
def test_CopyFileVars_Windows(self):
|
|
202
|
+
"""Test CopyFile environment variable replacement on Windows."""
|
|
203
|
+
with mock.patch("os.name", "nt"): # Mock Windows
|
|
204
|
+
src = os.path.join("&TMPDIR&", "src")
|
|
205
|
+
dest = os.path.join("&TMPDIR&", "dest")
|
|
206
|
+
|
|
207
|
+
task_config = TaskConfig({"options": {"src": src, "dest": dest}})
|
|
208
|
+
task = util.CopyFile(self.project_config, task_config, self.org_config)
|
|
209
|
+
|
|
210
|
+
# On Windows, &TMPDIR& should become %TMPDIR%
|
|
211
|
+
assert task.options["src"] == os.path.join("%TMPDIR%", "src")
|
|
212
|
+
assert task.options["dest"] == os.path.join("%TMPDIR%", "dest")
|
|
213
|
+
|
|
214
|
+
def test_CopyFileVars_POSIX(self):
|
|
215
|
+
"""Test CopyFile environment variable replacement on POSIX."""
|
|
216
|
+
with mock.patch("os.name", "posix"): # Mock POSIX
|
|
217
|
+
src = os.path.join("&TMPDIR&", "src")
|
|
218
|
+
dest = os.path.join("&TMPDIR&", "dest")
|
|
219
|
+
|
|
220
|
+
task_config = TaskConfig({"options": {"src": src, "dest": dest}})
|
|
221
|
+
task = util.CopyFile(self.project_config, task_config, self.org_config)
|
|
222
|
+
|
|
223
|
+
# On POSIX, &TMPDIR& should become $TMPDIR
|
|
224
|
+
assert task.options["src"] == os.path.join("$TMPDIR", "src")
|
|
225
|
+
assert task.options["dest"] == os.path.join("$TMPDIR", "dest")
|
|
226
|
+
|
|
185
227
|
def test_LogLine(self):
|
|
186
228
|
task_config = TaskConfig({"options": {"level": "debug", "line": "test"}})
|
|
187
229
|
task = util.LogLine(self.project_config, task_config, self.org_config)
|
cumulusci/tasks/util.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import glob
|
|
2
2
|
import os
|
|
3
|
+
import re
|
|
3
4
|
import shutil
|
|
4
5
|
import time
|
|
5
6
|
|
|
@@ -220,9 +221,44 @@ class CopyFile(BaseTask):
|
|
|
220
221
|
},
|
|
221
222
|
}
|
|
222
223
|
|
|
224
|
+
def _init_options(self, kwargs):
|
|
225
|
+
super(CopyFile, self)._init_options(kwargs)
|
|
226
|
+
self.options["src"] = self.replace_env_vars(self.options["src"])
|
|
227
|
+
self.options["dest"] = self.replace_env_vars(self.options["dest"])
|
|
228
|
+
|
|
223
229
|
def _run_task(self):
|
|
224
230
|
self.logger.info("Copying file {src} to {dest}".format(**self.options))
|
|
225
|
-
shutil.copyfile(
|
|
231
|
+
shutil.copyfile(
|
|
232
|
+
src=os.path.expandvars(self.options["src"]),
|
|
233
|
+
dst=os.path.expandvars(self.options["dest"]),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def replace_env_vars(self, text):
|
|
237
|
+
"""
|
|
238
|
+
Environment variable replacement that handles:
|
|
239
|
+
- &VAR& -> $VAR (POSIX) or %VAR% (Windows)
|
|
240
|
+
"""
|
|
241
|
+
if not text:
|
|
242
|
+
return text
|
|
243
|
+
|
|
244
|
+
pattern = r"\&([A-Za-z_][A-Za-z0-9_]*)\&"
|
|
245
|
+
if os.name == "posix":
|
|
246
|
+
# POSIX: Convert &VAR& to $VAR
|
|
247
|
+
replacement = r"$\1"
|
|
248
|
+
else:
|
|
249
|
+
# Windows: Convert &VAR$ to %VAR%
|
|
250
|
+
replacement = r"%\1%"
|
|
251
|
+
|
|
252
|
+
return re.sub(pattern, replacement, text)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class LoadDotEnv(BaseTask):
|
|
256
|
+
def _run_task(self):
|
|
257
|
+
from dotenv import load_dotenv
|
|
258
|
+
|
|
259
|
+
load_dotenv()
|
|
260
|
+
|
|
261
|
+
self.logger.info("Loaded .env file")
|
|
226
262
|
|
|
227
263
|
|
|
228
264
|
class LogLine(BaseTask):
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Standalone script to test directory merging and copying logic for unpackaged metadata.
|
|
4
|
+
|
|
5
|
+
This script consolidates metadata from multiple directories into a temporary directory,
|
|
6
|
+
merging overlapping directory structures.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import glob
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
from logging import Logger
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
16
|
+
|
|
17
|
+
from cumulusci.core.tasks import BaseTask
|
|
18
|
+
from cumulusci.utils.options import CCIOptions, Field
|
|
19
|
+
|
|
20
|
+
IGNORE_FILES = [".gitkeep", ".DS_Store"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def merge_directory_contents(src_dir: str, dest_dir: str, overwrite: bool = False):
|
|
24
|
+
"""
|
|
25
|
+
Recursively merge contents from src_dir into dest_dir.
|
|
26
|
+
If a file exists in both, the source file overwrites the destination.
|
|
27
|
+
"""
|
|
28
|
+
for item in os.listdir(src_dir):
|
|
29
|
+
src_item = os.path.join(src_dir, item)
|
|
30
|
+
dest_item = os.path.join(dest_dir, item)
|
|
31
|
+
|
|
32
|
+
if os.path.isdir(src_item):
|
|
33
|
+
if os.path.exists(dest_item) and os.path.isdir(dest_item):
|
|
34
|
+
# Recursively merge subdirectories
|
|
35
|
+
merge_directory_contents(src_item, dest_item)
|
|
36
|
+
else:
|
|
37
|
+
# Copy directory if it doesn't exist or replace if it's a file
|
|
38
|
+
if os.path.exists(dest_item) and overwrite:
|
|
39
|
+
if os.path.isdir(dest_item):
|
|
40
|
+
shutil.rmtree(dest_item)
|
|
41
|
+
else:
|
|
42
|
+
os.remove(dest_item)
|
|
43
|
+
shutil.copytree(src_item, dest_item)
|
|
44
|
+
else:
|
|
45
|
+
# Copy file, overwriting if it exists
|
|
46
|
+
if os.path.exists(dest_item) and os.path.isdir(dest_item) and overwrite:
|
|
47
|
+
shutil.rmtree(dest_item)
|
|
48
|
+
shutil.copy2(src_item, dest_item)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def copy_item_to_destination(source_item: str, dest_item: str, overwrite: bool = False):
|
|
52
|
+
"""
|
|
53
|
+
Copy a file or directory to destination, merging if destination exists.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
source_item: Source file or directory path
|
|
57
|
+
dest_item: Destination file or directory path
|
|
58
|
+
"""
|
|
59
|
+
if os.path.isdir(source_item):
|
|
60
|
+
if os.path.exists(dest_item) and os.path.isdir(dest_item):
|
|
61
|
+
merge_directory_contents(source_item, dest_item, overwrite)
|
|
62
|
+
else:
|
|
63
|
+
# Remove destination if it exists (file or wrong type)
|
|
64
|
+
if os.path.exists(dest_item):
|
|
65
|
+
if os.path.isdir(dest_item) and overwrite:
|
|
66
|
+
shutil.rmtree(dest_item)
|
|
67
|
+
else:
|
|
68
|
+
os.remove(dest_item)
|
|
69
|
+
shutil.copytree(source_item, dest_item)
|
|
70
|
+
else:
|
|
71
|
+
# Remove destination if it's a directory
|
|
72
|
+
if os.path.exists(dest_item) and os.path.isdir(dest_item) and overwrite:
|
|
73
|
+
shutil.rmtree(dest_item)
|
|
74
|
+
shutil.copy2(source_item, dest_item)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def copy_directory_contents(
|
|
78
|
+
source_dir: str, dest_dir: str, extract_src: bool = False, overwrite: bool = False
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
Copy all contents from source_dir to dest_dir.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
source_dir: Source directory path
|
|
85
|
+
dest_dir: Destination directory path
|
|
86
|
+
extract_src: If True and source_dir contains 'src', copy src contents to dest_dir/src
|
|
87
|
+
"""
|
|
88
|
+
if extract_src:
|
|
89
|
+
src_path = os.path.join(source_dir, "src")
|
|
90
|
+
if os.path.exists(src_path) and os.path.isdir(src_path):
|
|
91
|
+
# Copy src directory contents directly to dest_dir/src
|
|
92
|
+
temp_src_dir = os.path.join(dest_dir, "src")
|
|
93
|
+
os.makedirs(temp_src_dir, exist_ok=True)
|
|
94
|
+
for item in os.listdir(src_path):
|
|
95
|
+
source_item = os.path.join(src_path, item)
|
|
96
|
+
dest_item = os.path.join(temp_src_dir, item)
|
|
97
|
+
copy_item_to_destination(source_item, dest_item, overwrite)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# No src directory or extract_src is False, copy everything directly to dest_dir
|
|
101
|
+
for item in os.listdir(source_dir):
|
|
102
|
+
source_item = os.path.join(source_dir, item)
|
|
103
|
+
dest_item = os.path.join(dest_dir, item)
|
|
104
|
+
copy_item_to_destination(source_item, dest_item, overwrite)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_file_pattern(pattern: str, source_dir: str) -> List[str]:
|
|
108
|
+
"""
|
|
109
|
+
Resolve a file pattern to a list of matching files.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
pattern: Glob pattern or file path
|
|
113
|
+
source_dir: Base directory for resolving relative patterns
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of matched file paths
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ValueError: If pattern doesn't match any files
|
|
120
|
+
"""
|
|
121
|
+
pattern_path = os.path.join(source_dir, pattern)
|
|
122
|
+
matched_files = glob.glob(pattern_path, recursive=True)
|
|
123
|
+
|
|
124
|
+
if not matched_files:
|
|
125
|
+
# If no glob match, treat as literal file path
|
|
126
|
+
if os.path.exists(pattern_path):
|
|
127
|
+
matched_files = [pattern_path]
|
|
128
|
+
else:
|
|
129
|
+
matched_files = []
|
|
130
|
+
|
|
131
|
+
# Normalize paths to use OS-native separators (fixes Windows path separator issues)
|
|
132
|
+
return [os.path.normpath(path) for path in matched_files]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def copy_matched_files(matched_files: List[str], source_dir: str, dest_dir: str):
|
|
136
|
+
"""
|
|
137
|
+
Copy matched files to destination, preserving relative structure.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
matched_files: List of file paths to copy
|
|
141
|
+
source_dir: Source base directory for calculating relative paths
|
|
142
|
+
dest_dir: Destination base directory
|
|
143
|
+
"""
|
|
144
|
+
for matched_file in matched_files:
|
|
145
|
+
# Calculate relative path from source_dir
|
|
146
|
+
rel_path = os.path.relpath(matched_file, source_dir)
|
|
147
|
+
dest_file = os.path.join(dest_dir, rel_path)
|
|
148
|
+
os.makedirs(os.path.dirname(dest_file), exist_ok=True)
|
|
149
|
+
copy_item_to_destination(matched_file, dest_file)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def clean_temp_directory(temp_dir: str):
|
|
153
|
+
"""
|
|
154
|
+
Clean up a temporary directory.
|
|
155
|
+
Args:
|
|
156
|
+
temp_dir: Path to the temporary directory
|
|
157
|
+
"""
|
|
158
|
+
if os.path.exists(temp_dir):
|
|
159
|
+
shutil.rmtree(temp_dir)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def validate_directory(path: str, path_name: str = "path"):
|
|
163
|
+
"""
|
|
164
|
+
Validate that a path exists and is a directory.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
path: Path to validate
|
|
168
|
+
path_name: Name of the path for error messages
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ValueError: If path doesn't exist or is not a directory
|
|
172
|
+
"""
|
|
173
|
+
if not os.path.exists(path):
|
|
174
|
+
raise ValueError(f"{path_name} does not exist: {path}")
|
|
175
|
+
if not os.path.isdir(path):
|
|
176
|
+
raise ValueError(f"{path_name} is not a directory: {path}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def consolidate_metadata(
|
|
180
|
+
metadata_path: Union[str, List[str], Dict[str, Union[str, List[str]]]],
|
|
181
|
+
base_path: str = None,
|
|
182
|
+
logger: Optional[Logger] = None,
|
|
183
|
+
) -> Tuple[str, int]:
|
|
184
|
+
"""
|
|
185
|
+
Consolidate metadata from various sources into a temporary directory.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
metadata_path: Can be:
|
|
189
|
+
1. string: path to a directory (relative to base_path)
|
|
190
|
+
2. list of strings: list of paths to directories
|
|
191
|
+
3. dict: dict with keys as directory names and values as file patterns
|
|
192
|
+
base_path: Base path for resolving relative paths. Defaults to current directory.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Path to the temporary directory containing consolidated metadata
|
|
196
|
+
|
|
197
|
+
unpackaged_metadata_path supported formats:
|
|
198
|
+
# 1. string: path to a directory
|
|
199
|
+
# Example:
|
|
200
|
+
# unpackaged_metadata_path: "unpackaged/pre"
|
|
201
|
+
|
|
202
|
+
# 2. list of strings: list of paths to directories
|
|
203
|
+
# Example:
|
|
204
|
+
# unpackaged_metadata_path:
|
|
205
|
+
# - "unpackaged/pre"
|
|
206
|
+
# - "unpackaged/post"
|
|
207
|
+
|
|
208
|
+
# 3. dict: dict with keys as directory names and values as relative filepaths to the directory
|
|
209
|
+
# Example:
|
|
210
|
+
# unpackaged_metadata_path:
|
|
211
|
+
# "unpackaged/pre": "*.*"
|
|
212
|
+
# "unpackaged/post": "src/objects/Account/fields/Name.field-meta.xml"
|
|
213
|
+
# "unpackaged/default":
|
|
214
|
+
# - "src/objects/Account/fields/Name.field-meta.xml"
|
|
215
|
+
# - "src/objects/Account/fields/Description.field-meta.xml"
|
|
216
|
+
"""
|
|
217
|
+
if base_path is None:
|
|
218
|
+
base_path = os.getcwd()
|
|
219
|
+
|
|
220
|
+
# Create a temporary directory to consolidate all metadata
|
|
221
|
+
temp_dir = tempfile.mkdtemp(prefix="metadata_consolidate_")
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
if isinstance(metadata_path, str):
|
|
225
|
+
# Format 1: Single directory path
|
|
226
|
+
source_path = (
|
|
227
|
+
os.path.join(base_path, metadata_path)
|
|
228
|
+
if not os.path.isabs(metadata_path)
|
|
229
|
+
else metadata_path
|
|
230
|
+
)
|
|
231
|
+
validate_directory(source_path, "Unpackaged metadata path")
|
|
232
|
+
|
|
233
|
+
# Copy entire directory to temp
|
|
234
|
+
copy_directory_contents(source_path, temp_dir)
|
|
235
|
+
|
|
236
|
+
elif isinstance(metadata_path, list):
|
|
237
|
+
# Format 2: List of directory paths
|
|
238
|
+
for path_item in metadata_path:
|
|
239
|
+
source_path = (
|
|
240
|
+
os.path.join(base_path, path_item)
|
|
241
|
+
if not os.path.isabs(path_item)
|
|
242
|
+
else path_item
|
|
243
|
+
)
|
|
244
|
+
validate_directory(source_path, "Unpackaged metadata path")
|
|
245
|
+
|
|
246
|
+
# Copy all contents directly to temp folder, merging directories
|
|
247
|
+
copy_directory_contents(source_path, temp_dir)
|
|
248
|
+
|
|
249
|
+
elif isinstance(metadata_path, dict):
|
|
250
|
+
# Format 3: Dict with directory keys and file pattern/value lists
|
|
251
|
+
# For dict format, merge all src directories directly into temp_dir/src
|
|
252
|
+
for dir_key, file_patterns in metadata_path.items():
|
|
253
|
+
source_dir = (
|
|
254
|
+
os.path.join(base_path, dir_key)
|
|
255
|
+
if not os.path.isabs(dir_key)
|
|
256
|
+
else dir_key
|
|
257
|
+
)
|
|
258
|
+
validate_directory(source_dir, "Unpackaged metadata directory")
|
|
259
|
+
|
|
260
|
+
# Handle different value types
|
|
261
|
+
if isinstance(file_patterns, str):
|
|
262
|
+
# Single pattern or file path
|
|
263
|
+
if file_patterns == "*.*" or file_patterns == "*":
|
|
264
|
+
# Copy all files from source directory, extracting src if present
|
|
265
|
+
copy_directory_contents(source_dir, temp_dir, extract_src=True)
|
|
266
|
+
else:
|
|
267
|
+
# Treat as glob pattern or specific file path
|
|
268
|
+
matched_files = resolve_file_pattern(file_patterns, source_dir)
|
|
269
|
+
if logger and not matched_files:
|
|
270
|
+
logger.warning(
|
|
271
|
+
f"File pattern does not match any files: {file_patterns}"
|
|
272
|
+
)
|
|
273
|
+
continue
|
|
274
|
+
copy_matched_files(matched_files, source_dir, temp_dir)
|
|
275
|
+
|
|
276
|
+
elif isinstance(file_patterns, list):
|
|
277
|
+
# List of file paths/patterns
|
|
278
|
+
for pattern in file_patterns:
|
|
279
|
+
matched_files = resolve_file_pattern(pattern, source_dir)
|
|
280
|
+
if logger and not matched_files:
|
|
281
|
+
logger.warning(
|
|
282
|
+
f"File pattern does not match any files: {pattern}"
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
copy_matched_files(matched_files, source_dir, temp_dir)
|
|
286
|
+
else:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"Invalid file pattern type for directory {dir_key}: {type(file_patterns)}"
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
raise ValueError(f"Invalid unpackaged metadata path: {metadata_path}")
|
|
292
|
+
|
|
293
|
+
# Count the files in the final_metadata_path and log the count, ignore .gitkeep files
|
|
294
|
+
file_count = len(
|
|
295
|
+
[
|
|
296
|
+
p
|
|
297
|
+
for p in Path(temp_dir).rglob("*")
|
|
298
|
+
if p.name not in IGNORE_FILES and p.is_file()
|
|
299
|
+
]
|
|
300
|
+
)
|
|
301
|
+
if logger:
|
|
302
|
+
logger.info(
|
|
303
|
+
f"Found {file_count} files in the consolidated metadata path, ignoring .gitkeep files: {temp_dir}"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return temp_dir, file_count
|
|
307
|
+
|
|
308
|
+
except Exception:
|
|
309
|
+
# Clean up temp directory on error
|
|
310
|
+
clean_temp_directory(temp_dir)
|
|
311
|
+
raise
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def print_directory_tree(
|
|
315
|
+
path: str,
|
|
316
|
+
prefix: str = "",
|
|
317
|
+
max_depth: int = 10,
|
|
318
|
+
current_depth: int = 0,
|
|
319
|
+
logger: Logger = None,
|
|
320
|
+
):
|
|
321
|
+
"""Print a directory tree structure."""
|
|
322
|
+
if current_depth >= max_depth:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
items = sorted(os.listdir(path))
|
|
327
|
+
for i, item in enumerate(items):
|
|
328
|
+
item_path = os.path.join(path, item)
|
|
329
|
+
is_last = i == len(items) - 1
|
|
330
|
+
current_prefix = "└── " if is_last else "├── "
|
|
331
|
+
if logger:
|
|
332
|
+
logger.info(f"{prefix}{current_prefix}{item}")
|
|
333
|
+
else:
|
|
334
|
+
print(f"{prefix}{current_prefix}{item}")
|
|
335
|
+
|
|
336
|
+
if os.path.isdir(item_path):
|
|
337
|
+
extension = " " if is_last else "│ "
|
|
338
|
+
print_directory_tree(
|
|
339
|
+
item_path, prefix + extension, max_depth, current_depth + 1, logger
|
|
340
|
+
)
|
|
341
|
+
except PermissionError:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
"""
|
|
346
|
+
CumulusCI task to consolidate unpackaged metadata from multiple sources.
|
|
347
|
+
|
|
348
|
+
This task reads the unpackaged_metadata_path configuration from project config
|
|
349
|
+
and consolidates all metadata into a single temporary directory.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class ConsolidateUnpackagedMetadata(BaseTask):
|
|
354
|
+
"""Consolidate unpackaged metadata from multiple sources into a single directory.
|
|
355
|
+
|
|
356
|
+
This task reads the `project__package__unpackaged_metadata_path` configuration
|
|
357
|
+
and consolidates all metadata according to the specified format (string, list, or dict).
|
|
358
|
+
|
|
359
|
+
The consolidated directory path is returned in `return_values['path']`.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
class Options(CCIOptions):
|
|
363
|
+
base_path: str = Field(
|
|
364
|
+
None,
|
|
365
|
+
description="Base path for resolving relative paths. Defaults to repo_root.",
|
|
366
|
+
)
|
|
367
|
+
keep_temp: bool = Field(
|
|
368
|
+
False, description="Keep temporary directory after execution."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
parsed_options: Options
|
|
372
|
+
|
|
373
|
+
def _run_task(self):
|
|
374
|
+
"""Execute the consolidation task."""
|
|
375
|
+
# Get unpackaged_metadata_path from project config
|
|
376
|
+
metadata_path = self.project_config.project__package__unpackaged_metadata_path
|
|
377
|
+
|
|
378
|
+
if not metadata_path:
|
|
379
|
+
self.logger.warning(
|
|
380
|
+
"No unpackaged_metadata_path configured. Skipping consolidation."
|
|
381
|
+
)
|
|
382
|
+
self.return_values["path"] = None
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# Determine base path
|
|
386
|
+
base_path = self.parsed_options.base_path
|
|
387
|
+
if base_path is None:
|
|
388
|
+
base_path = self.project_config.repo_root
|
|
389
|
+
|
|
390
|
+
self.logger.info(f"Consolidating unpackaged metadata from: {metadata_path}")
|
|
391
|
+
self.logger.info(f"Using base path: {base_path}")
|
|
392
|
+
|
|
393
|
+
# Consolidate metadata
|
|
394
|
+
consolidated_path, _ = consolidate_metadata(
|
|
395
|
+
metadata_path, base_path, logger=self.logger
|
|
396
|
+
)
|
|
397
|
+
print_directory_tree(consolidated_path, logger=self.logger)
|
|
398
|
+
|
|
399
|
+
if not self.parsed_options.keep_temp:
|
|
400
|
+
clean_temp_directory(consolidated_path)
|
|
401
|
+
|
|
402
|
+
return consolidated_path
|