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.
Files changed (123) 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/profiles.py +13 -9
  71. cumulusci/tasks/salesforce/sourcetracking.py +1 -1
  72. cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
  73. cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
  74. cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
  75. cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
  76. cumulusci/tasks/salesforce/tests/test_profiles.py +43 -3
  77. cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
  78. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
  79. cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
  80. cumulusci/tasks/salesforce/update_dependencies.py +2 -2
  81. cumulusci/tasks/salesforce/update_external_credential.py +562 -0
  82. cumulusci/tasks/salesforce/update_named_credential.py +441 -0
  83. cumulusci/tasks/salesforce/update_profile.py +17 -13
  84. cumulusci/tasks/salesforce/users/permsets.py +62 -5
  85. cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
  86. cumulusci/tasks/sfdmu/__init__.py +0 -0
  87. cumulusci/tasks/sfdmu/sfdmu.py +363 -0
  88. cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
  89. cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
  90. cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
  91. cumulusci/tasks/tests/test_create_package_version.py +716 -1
  92. cumulusci/tasks/tests/test_util.py +42 -0
  93. cumulusci/tasks/util.py +37 -1
  94. cumulusci/tasks/utility/copyContents.py +402 -0
  95. cumulusci/tasks/utility/credentialManager.py +256 -0
  96. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  97. cumulusci/tasks/utility/env_management.py +1 -1
  98. cumulusci/tasks/utility/secretsToEnv.py +135 -0
  99. cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
  100. cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
  101. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  102. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
  103. cumulusci/tests/test_integration_infrastructure.py +3 -1
  104. cumulusci/tests/test_utils.py +70 -6
  105. cumulusci/utils/__init__.py +54 -9
  106. cumulusci/utils/classutils.py +5 -2
  107. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  108. cumulusci/utils/options.py +23 -1
  109. cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
  110. cumulusci/utils/yaml/cumulusci_yml.py +7 -3
  111. cumulusci/utils/yaml/model_parser.py +2 -2
  112. cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
  113. cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
  114. cumulusci/vcs/base.py +23 -15
  115. cumulusci/vcs/bootstrap.py +5 -4
  116. cumulusci/vcs/utils/list_modified_files.py +189 -0
  117. cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
  118. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
  119. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +123 -98
  120. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
  121. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
  122. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
  123. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.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(src=self.options["src"], dst=self.options["dest"])
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