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
@@ -213,7 +213,6 @@ class ExtractData(SqlAlchemyMixin, BaseSalesforceApiTask):
213
213
  IsPersonAccount_index = columns.index(mapping.fields["IsPersonAccount"])
214
214
 
215
215
  def strip_name_field(record):
216
- nonlocal Name_index, IsPersonAccount_index
217
216
  if record[IsPersonAccount_index].lower() == "true":
218
217
  record[Name_index] = ""
219
218
  return record
@@ -2,7 +2,7 @@ import re
2
2
  import typing as T
3
3
  from pathlib import Path
4
4
 
5
- from pydantic import Field, validator
5
+ from pydantic.v1 import Field, validator
6
6
 
7
7
  from cumulusci.core.enums import StrEnum
8
8
  from cumulusci.tasks.bulkdata.utils import DataApi
@@ -2,7 +2,7 @@ import collections
2
2
  import re
3
3
  import typing as T
4
4
 
5
- from pydantic import validator
5
+ from pydantic.v1 import validator
6
6
 
7
7
  from cumulusci.salesforce_api.org_schema import NOT_EXTRACTABLE, Field, Schema
8
8
  from cumulusci.utils.iterators import partition
@@ -1,7 +1,7 @@
1
1
  from io import StringIO
2
2
 
3
3
  import pytest
4
- from pydantic import ValidationError
4
+ from pydantic.v1 import ValidationError
5
5
 
6
6
  from cumulusci.tasks.bulkdata.extract_dataset_utils.extract_yml import (
7
7
  ExtractDeclaration,
@@ -7,8 +7,13 @@ from sqlalchemy import MetaData, create_engine
7
7
 
8
8
  from cumulusci.core.config import TaskConfig
9
9
  from cumulusci.core.exceptions import TaskOptionsError
10
- from cumulusci.core.utils import import_global
10
+ from cumulusci.core.utils import import_global, process_bool_arg
11
11
  from cumulusci.tasks.bulkdata import LoadData
12
+ from cumulusci.tasks.bulkdata.mapping_parser import (
13
+ parse_from_yaml,
14
+ validate_and_inject_mapping,
15
+ )
16
+ from cumulusci.tasks.bulkdata.step import DataOperationType
12
17
  from cumulusci.tasks.bulkdata.utils import generate_batches
13
18
  from cumulusci.tasks.salesforce import BaseSalesforceApiTask
14
19
 
@@ -79,6 +84,10 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
79
84
  "working_directory": {
80
85
  "description": "Store temporary files in working_directory for easier debugging."
81
86
  },
87
+ "validate_only": {
88
+ "description": "Boolean: if True, only validate the generated mapping against the org schema without loading data. "
89
+ "Defaults to False."
90
+ },
82
91
  **LoadData.task_options,
83
92
  }
84
93
  task_options["mapping"]["required"] = False
@@ -114,6 +123,7 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
114
123
 
115
124
  self.working_directory = self.options.get("working_directory", None)
116
125
  self.database_url = self.options.get("database_url")
126
+ self.validate_only = process_bool_arg(self.options.get("validate_only", False))
117
127
 
118
128
  if self.database_url:
119
129
  engine, metadata = self._setup_engine(self.database_url)
@@ -132,6 +142,16 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
132
142
  if working_directory:
133
143
  tempdir = Path(working_directory)
134
144
  tempdir.mkdir(exist_ok=True)
145
+
146
+ # Route to validation flow if validate_only is True
147
+ if self.validate_only:
148
+ return self._run_validation(
149
+ database_url=self.database_url,
150
+ tempdir=self.working_directory or tempdir,
151
+ mapping_file=self.mapping_file,
152
+ )
153
+
154
+ # Normal data generation and loading flow
135
155
  if self.batch_size:
136
156
  batches = generate_batches(self.num_records, self.batch_size)
137
157
  else:
@@ -186,6 +206,47 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
186
206
  total_batches: int,
187
207
  ) -> dict:
188
208
  """Generate a batch in database_url or a tempfile if it isn't specified."""
209
+ # Setup and generate data
210
+ subtask_options = self._setup_and_generate_data(
211
+ database_url=database_url,
212
+ tempdir=tempdir,
213
+ mapping_file=mapping_file,
214
+ num_records=batch_size,
215
+ batch_index=index,
216
+ )
217
+
218
+ # Load the data
219
+ return self._dataload(subtask_options)
220
+
221
+ def _setup_engine(self, database_url):
222
+ """Set up the database engine"""
223
+ engine = create_engine(database_url)
224
+
225
+ metadata = MetaData(engine)
226
+ metadata.reflect()
227
+ return engine, metadata
228
+
229
+ def _setup_and_generate_data(
230
+ self,
231
+ *,
232
+ database_url: Optional[str],
233
+ tempdir: Union[Path, str, None],
234
+ mapping_file: Union[Path, str, None],
235
+ num_records: Optional[int],
236
+ batch_index: int,
237
+ ) -> dict:
238
+ """Setup database and generate data, returning subtask options with mapping.
239
+
240
+ Args:
241
+ database_url: Database URL or None to create temp SQLite
242
+ tempdir: Temporary directory for generated files
243
+ mapping_file: Path to mapping file or None to generate
244
+ num_records: Number of records to generate
245
+ batch_index: Current batch number
246
+
247
+ Returns:
248
+ dict: subtask_options with mapping file path set
249
+ """
189
250
  if not database_url:
190
251
  sqlite_path = Path(tempdir) / "generated_data.db"
191
252
  database_url = f"sqlite:///{sqlite_path}"
@@ -197,28 +258,91 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
197
258
  "mapping": mapping_file,
198
259
  "reset_oids": False,
199
260
  "database_url": database_url,
200
- "num_records": batch_size,
201
- "current_batch_number": index,
261
+ "num_records": num_records,
262
+ "current_batch_number": batch_index,
202
263
  "working_directory": tempdir,
203
264
  }
204
265
 
205
- # some generator tasks can generate the mapping file instead of reading it
266
+ # Generate mapping file if needed
206
267
  if not subtask_options.get("mapping"):
207
268
  temp_mapping = Path(tempdir) / "temp_mapping.yml"
208
269
  mapping_file = self.options.get("generate_mapping_file", temp_mapping)
209
270
  subtask_options["generate_mapping_file"] = mapping_file
271
+
272
+ # Run data generation
210
273
  self._datagen(subtask_options)
274
+
211
275
  if not subtask_options.get("mapping"):
212
- subtask_options["mapping"] = mapping_file
213
- return self._dataload(subtask_options)
276
+ subtask_options["mapping"] = subtask_options["generate_mapping_file"]
214
277
 
215
- def _setup_engine(self, database_url):
216
- """Set up the database engine"""
217
- engine = create_engine(database_url)
278
+ return subtask_options
218
279
 
219
- metadata = MetaData(engine)
220
- metadata.reflect()
221
- return engine, metadata
280
+ def _run_validation(
281
+ self,
282
+ *,
283
+ database_url: Optional[str],
284
+ tempdir: Union[Path, str, None],
285
+ mapping_file: Union[Path, str, None],
286
+ ):
287
+ """Run validation flow: generate data once and validate mapping.
288
+
289
+ Args:
290
+ database_url: Database URL or None to create temp SQLite
291
+ tempdir: Temporary directory for generated files
292
+ mapping_file: Path to mapping file or None to generate
293
+
294
+ Returns:
295
+ dict: return_values with validation_result
296
+ """
297
+ # Setup and generate minimal data to create mapping
298
+ subtask_options = self._setup_and_generate_data(
299
+ database_url=database_url,
300
+ tempdir=tempdir,
301
+ mapping_file=mapping_file,
302
+ num_records=1, # Generate minimal data just to create mapping
303
+ batch_index=0,
304
+ )
305
+
306
+ # Validate the mapping
307
+ validation_result = self._validate_mapping(subtask_options)
308
+
309
+ self.return_values = {"validation_result": validation_result}
310
+ return self.return_values
311
+
312
+ def _validate_mapping(self, subtask_options):
313
+ """Validate the mapping against the org schema without loading data."""
314
+ mapping_file = subtask_options.get("mapping")
315
+ if not mapping_file:
316
+ raise TaskOptionsError("Mapping file path required for validation")
317
+
318
+ self.logger.info(f"Validating mapping file: {mapping_file}")
319
+ mapping = parse_from_yaml(mapping_file)
320
+
321
+ validation_result = validate_and_inject_mapping(
322
+ mapping=mapping,
323
+ sf=self.sf,
324
+ namespace=self.project_config.project__package__namespace,
325
+ data_operation=DataOperationType.INSERT,
326
+ inject_namespaces=self.options.get("inject_namespaces", False),
327
+ drop_missing=self.options.get("drop_missing_schema", False),
328
+ validate_only=True,
329
+ )
330
+
331
+ # Log summary message
332
+ self.logger.info("")
333
+ if validation_result and validation_result.has_errors():
334
+ self.logger.error("== Validation Failed ==")
335
+ self.logger.error(f" Errors: {len(validation_result.errors)}")
336
+ if validation_result.warnings:
337
+ self.logger.warning(f" Warnings: {len(validation_result.warnings)}")
338
+ elif validation_result and validation_result.warnings:
339
+ self.logger.warning("== Validation Successful (With Warnings) ==")
340
+ self.logger.warning(f" Warnings: {len(validation_result.warnings)}")
341
+ else:
342
+ self.logger.info("== Validation Successful ==")
343
+ self.logger.info("")
344
+
345
+ return validation_result
222
346
 
223
347
  def _cleanup_object_tables(self, engine, metadata):
224
348
  """Delete all tables that do not relate to id->OID mapping"""
@@ -7,7 +7,7 @@ from logging import getLogger
7
7
  from pathlib import Path
8
8
  from typing import IO, Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
9
9
 
10
- from pydantic import Field, ValidationError, root_validator, validator
10
+ from pydantic.v1 import Field, ValidationError, root_validator, validator
11
11
  from simple_salesforce import Salesforce
12
12
  from typing_extensions import Literal
13
13
 
@@ -23,6 +23,28 @@ from cumulusci.utils.yaml.model_parser import CCIDictModel
23
23
  logger = getLogger(__name__)
24
24
 
25
25
 
26
+ class ValidationResult:
27
+ """Collects validation errors and warnings during mapping validation."""
28
+
29
+ def __init__(self):
30
+ self.errors = []
31
+ self.warnings = []
32
+
33
+ def add_error(self, message: str):
34
+ """Add an error message."""
35
+ self.errors.append(message)
36
+ logger.error(message)
37
+
38
+ def add_warning(self, message: str):
39
+ """Add a warning message."""
40
+ self.warnings.append(message)
41
+ logger.warning(message)
42
+
43
+ def has_errors(self) -> bool:
44
+ """Check if there are any errors."""
45
+ return len(self.errors) > 0
46
+
47
+
26
48
  class MappingLookup(CCIDictModel):
27
49
  "Lookup relationship between two tables."
28
50
  table: Union[str, List[str]] # Support for polymorphic lookups
@@ -382,6 +404,7 @@ class MappingStep(CCIDictModel):
382
404
  strip: Optional[Callable[[str], str]],
383
405
  drop_missing: bool,
384
406
  data_operation_type: DataOperationType,
407
+ validation_result: Optional["ValidationResult"] = None,
385
408
  ) -> bool:
386
409
  ret = True
387
410
 
@@ -405,9 +428,11 @@ class MappingStep(CCIDictModel):
405
428
 
406
429
  if inject and self._is_injectable(f) and inject(f) not in orig_fields:
407
430
  if f in describe and inject(f) in describe:
408
- logger.warning(
409
- f"Both {self.sf_object}.{f} and {self.sf_object}.{inject(f)} are present in the target org. Using {f}."
410
- )
431
+ message = f"Both {self.sf_object}.{f} and {self.sf_object}.{inject(f)} are present in the target org. Using {f}."
432
+ if validation_result:
433
+ validation_result.add_warning(message)
434
+ else:
435
+ logger.warning(message)
411
436
 
412
437
  f = replace_if_necessary(field_dict, f, inject(f))
413
438
  if strip:
@@ -417,9 +442,11 @@ class MappingStep(CCIDictModel):
417
442
  try:
418
443
  new_name = describe.canonical_key(f)
419
444
  except KeyError:
420
- logger.warning(
421
- f"Field {self.sf_object}.{f} does not exist or is not visible to the current user."
422
- )
445
+ message = f"Field {self.sf_object}.{f} does not exist or is not visible to the current user."
446
+ if validation_result:
447
+ validation_result.add_warning(message)
448
+ else:
449
+ logger.warning(message)
423
450
  else:
424
451
  del field_dict[f]
425
452
  field_dict[new_name] = entry
@@ -434,9 +461,11 @@ class MappingStep(CCIDictModel):
434
461
  error_in_f = False
435
462
 
436
463
  if f not in describe:
437
- logger.warning(
438
- f"Field {self.sf_object}.{f} does not exist or is not visible to the current user."
439
- )
464
+ message = f"Field {self.sf_object}.{f} does not exist or is not visible to the current user."
465
+ if validation_result:
466
+ validation_result.add_warning(message)
467
+ else:
468
+ logger.warning(message)
440
469
  error_in_f = True
441
470
  elif not self._check_field_permission(
442
471
  describe,
@@ -446,10 +475,14 @@ class MappingStep(CCIDictModel):
446
475
  relevant_permissions = self._get_required_permission_types(
447
476
  relevant_operation
448
477
  )
449
- logger.warning(
478
+ message = (
450
479
  f"Field {self.sf_object}.{f} does not have the correct permissions "
451
480
  + f"{relevant_permissions} for this operation."
452
481
  )
482
+ if validation_result:
483
+ validation_result.add_warning(message)
484
+ else:
485
+ logger.warning(message)
453
486
  error_in_f = True
454
487
 
455
488
  if error_in_f:
@@ -466,6 +499,7 @@ class MappingStep(CCIDictModel):
466
499
  inject: Optional[Callable[[str], str]],
467
500
  strip: Optional[Callable[[str], str]],
468
501
  data_operation_type: DataOperationType,
502
+ validation_result: Optional["ValidationResult"] = None,
469
503
  ) -> bool:
470
504
  # Determine whether we need to inject or strip our sObject.
471
505
 
@@ -478,23 +512,29 @@ class MappingStep(CCIDictModel):
478
512
  try:
479
513
  self.sf_object = global_describe.canonical_key(self.sf_object)
480
514
  except KeyError:
481
- logger.warning(
482
- f"sObject {self.sf_object} does not exist or is not visible to the current user."
483
- )
515
+ message = f"sObject {self.sf_object} does not exist or is not visible to the current user."
516
+ if validation_result:
517
+ validation_result.add_warning(message)
518
+ else:
519
+ logger.warning(message)
484
520
  return False
485
521
 
486
522
  # Validate our access to this sObject.
487
523
  if not self._check_object_permission(
488
524
  global_describe, self.sf_object, data_operation_type
489
525
  ):
490
- logger.warning(
491
- f"sObject {self.sf_object} does not have the correct permissions for {data_operation_type}."
492
- )
526
+ message = f"sObject {self.sf_object} does not have the correct permissions for {data_operation_type}."
527
+ if validation_result:
528
+ validation_result.add_warning(message)
529
+ else:
530
+ logger.warning(message)
493
531
  return False
494
532
 
495
533
  return True
496
534
 
497
- def check_required(self, fields_describe):
535
+ def check_required(
536
+ self, fields_describe, validation_result: Optional["ValidationResult"] = None
537
+ ):
498
538
  required_fields = set()
499
539
  for field in fields_describe:
500
540
  defaulted = (
@@ -508,9 +548,11 @@ class MappingStep(CCIDictModel):
508
548
  set(self.fields.keys()) | set(self.lookups)
509
549
  )
510
550
  if len(missing_fields) > 0:
511
- logger.error(
512
- f"One or more required fields are missing for loading on {self.sf_object} :{missing_fields}"
513
- )
551
+ message = f"One or more required fields are missing for loading on {self.sf_object} :{missing_fields}"
552
+ if validation_result:
553
+ validation_result.add_error(message)
554
+ else:
555
+ logger.error(message)
514
556
  return False
515
557
  else:
516
558
  return True
@@ -523,6 +565,7 @@ class MappingStep(CCIDictModel):
523
565
  inject_namespaces: bool = False,
524
566
  drop_missing: bool = False,
525
567
  is_load: bool = False,
568
+ validation_result: Optional["ValidationResult"] = None,
526
569
  ):
527
570
  """Process the schema elements in this step.
528
571
 
@@ -554,7 +597,9 @@ class MappingStep(CCIDictModel):
554
597
  global_describe = CaseInsensitiveDict(
555
598
  {entry["name"]: entry for entry in sf.describe()["sobjects"]}
556
599
  )
557
- if not self._validate_sobject(global_describe, inject, strip, operation):
600
+ if not self._validate_sobject(
601
+ global_describe, inject, strip, operation, validation_result
602
+ ):
558
603
  # Don't attempt to validate field permissions if the object doesn't exist.
559
604
  return False
560
605
 
@@ -562,16 +607,31 @@ class MappingStep(CCIDictModel):
562
607
  # By this point, we know the attribute is valid.
563
608
  describe = self.describe_data(sf)
564
609
  fields_correct = self._validate_field_dict(
565
- describe, self.fields, inject, strip, drop_missing, operation
610
+ describe,
611
+ self.fields,
612
+ inject,
613
+ strip,
614
+ drop_missing,
615
+ operation,
616
+ validation_result,
566
617
  )
567
618
 
568
619
  lookups_correct = self._validate_field_dict(
569
- describe, self.lookups, inject, strip, drop_missing, operation
620
+ describe,
621
+ self.lookups,
622
+ inject,
623
+ strip,
624
+ drop_missing,
625
+ operation,
626
+ validation_result,
570
627
  )
571
628
 
572
629
  if is_load:
573
- # Show warning logs for unspecified required fields
574
- self.check_required(describe)
630
+ # Check for unspecified required fields
631
+ required_fields_present = self.check_required(describe, validation_result)
632
+ # Only block if drop_missing is False, otherwise just warn
633
+ if not required_fields_present and not drop_missing:
634
+ return False
575
635
 
576
636
  if not (fields_correct and lookups_correct):
577
637
  return False
@@ -587,6 +647,7 @@ class MappingStep(CCIDictModel):
587
647
  strip,
588
648
  drop_missing=False,
589
649
  data_operation_type=operation,
650
+ validation_result=validation_result,
590
651
  ):
591
652
  return False
592
653
  self.update_key = tuple(update_keys.keys())
@@ -638,7 +699,11 @@ def parse_from_yaml(source: Union[str, Path, IO]) -> Dict:
638
699
  return MappingSteps.parse_from_yaml(source)
639
700
 
640
701
 
641
- def _infer_and_validate_lookups(mapping: Dict, sf: Salesforce):
702
+ def _infer_and_validate_lookups(
703
+ mapping: Dict,
704
+ sf: Salesforce,
705
+ validation_result: Optional["ValidationResult"] = None,
706
+ ):
642
707
  """Validate that all the lookup tables mentioned are valid references
643
708
  to the lookup. Also verify that the mapping for the tables are mentioned
644
709
  before they are mentioned in the lookups"""
@@ -673,14 +738,18 @@ def _infer_and_validate_lookups(mapping: Dict, sf: Salesforce):
673
738
  if sf_object in reference_to_objects:
674
739
  target_objects.append(sf_object)
675
740
  else:
676
- logger.error(
677
- f"The lookup {sf_object} is not a valid lookup for {lookup_name} in sf_object: {m.sf_object}"
678
- )
741
+ message = f"The lookup {sf_object} is not a valid lookup for {lookup_name} in sf_object: {m.sf_object}"
742
+ if validation_result:
743
+ validation_result.add_error(message)
744
+ else:
745
+ logger.error(message)
679
746
  fail = True
680
747
  except KeyError:
681
- logger.error(
682
- f"The table {table} does not exist in the mapping file"
683
- )
748
+ message = f"The table {table} does not exist in the mapping file"
749
+ if validation_result:
750
+ validation_result.add_error(message)
751
+ else:
752
+ logger.error(message)
684
753
  fail = True
685
754
 
686
755
  if fail:
@@ -701,14 +770,18 @@ def _infer_and_validate_lookups(mapping: Dict, sf: Salesforce):
701
770
  list(sf_objects.values()).index(t) for t in target_objects
702
771
  ]
703
772
  if not all([target_index < idx for target_index in target_indices]):
704
- logger.error(
773
+ message = (
705
774
  f"All included target objects ({','.join(target_objects)}) for the field {m.sf_object}.{lookup_name} "
706
775
  f"must precede {m.sf_object} in the mapping."
707
776
  )
777
+ if validation_result:
778
+ validation_result.add_error(message)
779
+ else:
780
+ logger.error(message)
708
781
  fail = True
709
782
  continue
710
783
 
711
- if fail:
784
+ if fail and validation_result is None:
712
785
  raise BulkDataException(
713
786
  "One or more relationship errors blocked the operation."
714
787
  )
@@ -723,23 +796,36 @@ def validate_and_inject_mapping(
723
796
  inject_namespaces: bool,
724
797
  drop_missing: bool,
725
798
  org_has_person_accounts_enabled: bool = False,
726
- ):
799
+ validate_only: bool = False,
800
+ ) -> Optional[ValidationResult]:
727
801
  # Check if operation is load or extract
728
802
  is_load = True if data_operation == DataOperationType.INSERT else False
729
803
 
804
+ # Create ValidationResult if validate_only is True
805
+ validation_result = ValidationResult() if validate_only else None
806
+
730
807
  should_continue = [
731
808
  m.validate_and_inject_namespace(
732
- sf, namespace, data_operation, inject_namespaces, drop_missing, is_load
809
+ sf,
810
+ namespace,
811
+ data_operation,
812
+ inject_namespaces,
813
+ drop_missing,
814
+ is_load,
815
+ validation_result,
733
816
  )
734
817
  for m in mapping.values()
735
818
  ]
736
819
 
737
820
  if not drop_missing and not all(should_continue):
738
- raise BulkDataException(
739
- "One or more schema or permissions errors blocked the operation.\n"
740
- "If you would like to attempt the load regardless, you can specify "
741
- "'--drop_missing_schema True' on the command option and ensure all required fields are included in the mapping file."
742
- )
821
+ if validate_only and validation_result:
822
+ return validation_result
823
+ else:
824
+ raise BulkDataException(
825
+ "One or more schema or permissions errors blocked the operation.\n"
826
+ "If you would like to attempt the load regardless, you can specify "
827
+ "'--drop_missing_schema True' on the command option and ensure all required fields are included in the mapping file."
828
+ )
743
829
 
744
830
  if drop_missing:
745
831
  # Drop any steps with sObjects that are not present.
@@ -767,15 +853,22 @@ def validate_and_inject_mapping(
767
853
  # Make sure this didn't cause the operation to be invalid
768
854
  # by dropping a required field.
769
855
  if not describe[field]["nillable"]:
770
- raise BulkDataException(
856
+ message = (
771
857
  f"{m.sf_object}.{field} is a required field, but the target object "
772
858
  f"{describe[field]['referenceTo']} was removed from the operation "
773
859
  "due to missing permissions."
774
860
  )
861
+ if validate_only and validation_result:
862
+ validation_result.add_error(message)
863
+ return validation_result
864
+ else:
865
+ raise BulkDataException(message)
775
866
 
776
867
  # Infer/validate lookups
777
868
  if is_load:
778
- _infer_and_validate_lookups(mapping, sf)
869
+ _infer_and_validate_lookups(mapping, sf, validation_result)
870
+ if validate_only and validation_result:
871
+ return validation_result
779
872
 
780
873
  # If the org has person accounts enable, add a field mapping to track "IsPersonAccount".
781
874
  # IsPersonAccount field values are used to properly load person account records.
@@ -784,6 +877,8 @@ def validate_and_inject_mapping(
784
877
  if step["sf_object"] in ("Account", "Contact"):
785
878
  step["fields"]["IsPersonAccount"] = "IsPersonAccount"
786
879
 
880
+ return validation_result
881
+
787
882
 
788
883
  def _inject_or_strip_name(name, transform, global_describe):
789
884
  if not transform:
@@ -4,7 +4,7 @@ import re
4
4
  import typing as T
5
5
  from enum import Enum
6
6
 
7
- from pydantic import Field, root_validator, validator
7
+ from pydantic.v1 import Field, root_validator, validator
8
8
 
9
9
  from cumulusci.core.enums import StrEnum
10
10
  from cumulusci.tasks.bulkdata.utils import CaseInsensitiveDict