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
cumulusci/__about__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "5.0.19"
1
+ __version__ = "5.0.35"
cumulusci/cli/logger.py CHANGED
@@ -32,14 +32,14 @@ def init_logger(debug=False):
32
32
  tracebacks_show_locals=debug,
33
33
  )
34
34
  )
35
- logger.setLevel(logging.DEBUG)
35
+ logger.setLevel(logging.DEBUG if debug else logging.INFO)
36
36
  logger.propagate = False
37
37
 
38
38
  if debug: # pragma: no cover
39
39
  # Referenced from:
40
40
  # https://github.com/urllib3/urllib3/blob/cd55f2fe98df4d499ab5c826433ee4995d3f6a60/src/urllib3/__init__.py#L48
41
41
  def add_rich_logger(
42
- module: str, level: int = logging.DEBUG
42
+ module: str, level: int = logging.DEBUG if debug else logging.INFO
43
43
  ) -> logging.StreamHandler:
44
44
  """Retrieve the logger for the given module.
45
45
  Remove all handlers from it, and add a single RichHandler."""
cumulusci/cli/service.py CHANGED
@@ -83,6 +83,25 @@ class ConnectServiceCommand(click.MultiCommand):
83
83
  services = self._get_services_config(runtime)
84
84
  return sorted(services.keys())
85
85
 
86
+ def invoke(self, ctx):
87
+ """Override to show available services instead of 'Missing command' error"""
88
+ try:
89
+ return super().invoke(ctx)
90
+ except click.UsageError as e:
91
+ if "Missing command" in str(e):
92
+ # No subcommand provided - list available services
93
+ services = self.list_commands(ctx)
94
+ if services:
95
+ click.echo("Available services:")
96
+ for service in services:
97
+ click.echo(f" {service}")
98
+ else:
99
+ click.echo("No services available to configure.")
100
+ return
101
+ else:
102
+ # Re-raise other usage errors
103
+ raise
104
+
86
105
  def _build_param(self, attribute: str, details: dict) -> click.Option:
87
106
  required = details.get("required", False)
88
107
  default_factory: Optional[Callable] = self._get_callable_default(
@@ -288,6 +307,7 @@ class ConnectServiceCommand(click.MultiCommand):
288
307
  cls=ConnectServiceCommand,
289
308
  name="connect",
290
309
  help="Connect an external service to CumulusCI",
310
+ no_args_is_help=False,
291
311
  )
292
312
  def service_connect():
293
313
  pass
cumulusci/cli/task.py CHANGED
@@ -2,6 +2,7 @@ import json
2
2
  from pathlib import Path
3
3
 
4
4
  import click
5
+ from dotenv import load_dotenv
5
6
  from rich.console import Console
6
7
  from rst2ansi import rst2ansi
7
8
 
@@ -126,6 +127,10 @@ class RunTaskCommand(click.MultiCommand):
126
127
  "help": "Drops into the Python debugger at task completion.",
127
128
  "is_flag": True,
128
129
  },
130
+ "loadenv": {
131
+ "help": "Loads environment variables from the .env file.",
132
+ "is_flag": True,
133
+ },
129
134
  }
130
135
 
131
136
  def list_commands(self, ctx):
@@ -151,6 +156,17 @@ class RunTaskCommand(click.MultiCommand):
151
156
 
152
157
  def run_task(*args, **kwargs):
153
158
  """Callback function that executes when the command fires."""
159
+ # Load environment variables FIRST, before any task processing
160
+ if kwargs.get("loadenv", None):
161
+ # Load .env file from the project root directory
162
+ env_path = (
163
+ Path(runtime.project_config.repo_root) / ".env"
164
+ if runtime.project_config
165
+ else None
166
+ )
167
+ if env_path:
168
+ load_dotenv(env_path)
169
+
154
170
  org, org_config = runtime.get_org(
155
171
  kwargs.pop("org", None), fail_if_missing=False
156
172
  )
@@ -168,6 +184,7 @@ class RunTaskCommand(click.MultiCommand):
168
184
  task_config.config["options"].update(options)
169
185
 
170
186
  try:
187
+
171
188
  task = task_class(
172
189
  task_config.project_config, task_config, org_config=org_config
173
190
  )
@@ -98,7 +98,9 @@ Environment Info: Rossian / x68_46
98
98
  )
99
99
  webbrowser_open.assert_called_once_with(expected_gist_url)
100
100
 
101
- @pytest.mark.skipif(sys.version_info > (3, 11), reason="requires python3.10 or higher")
101
+ @pytest.mark.skipif(
102
+ sys.version_info > (3, 11), reason="requires python3.10 or higher"
103
+ )
102
104
  @mock.patch("cumulusci.cli.error.platform")
103
105
  @mock.patch("cumulusci.cli.error.sys")
104
106
  @mock.patch("cumulusci.cli.error.datetime")
@@ -4,9 +4,10 @@ import click
4
4
  import pytest
5
5
 
6
6
  from cumulusci.cli.runtime import CliRuntime
7
- from cumulusci.core.config import FlowConfig
7
+ from cumulusci.core.config import FlowConfig, OrgConfig
8
8
  from cumulusci.core.exceptions import CumulusCIException, FlowNotFoundError
9
- from cumulusci.core.flowrunner import FlowCoordinator
9
+ from cumulusci.core.flowrunner import FlowCoordinator, FlowStepSpec, StepSpec
10
+ from cumulusci.tests.util import create_project_config
10
11
 
11
12
  from .. import flow
12
13
  from .utils import DummyTask, run_click_command
@@ -274,3 +275,279 @@ def test_flow_run__org_delete_error(echo):
274
275
  echo.assert_any_call(
275
276
  "Scratch org deletion failed. Ignoring the error below to complete the flow:"
276
277
  )
278
+
279
+
280
+ # Tests for new FlowStepSpec and flow skipping functionality
281
+
282
+
283
+ class TestFlowStepSpec:
284
+ """Test the FlowStepSpec class functionality."""
285
+
286
+ def test_flowstep_spec_creation(self):
287
+ """Test that FlowStepSpec can be created with proper inheritance."""
288
+ project_config = create_project_config("TestOwner", "TestRepo")
289
+
290
+ flow_step = FlowStepSpec(
291
+ task_config={"test": "value"},
292
+ step_num="1.0",
293
+ task_name="test_flow",
294
+ task_class=None,
295
+ project_config=project_config,
296
+ allow_failure=False,
297
+ when="org_config.username == 'test@example.com'",
298
+ )
299
+
300
+ assert isinstance(flow_step, StepSpec)
301
+ assert isinstance(flow_step, FlowStepSpec)
302
+ assert flow_step.task_name == "test_flow"
303
+ assert flow_step.when == "org_config.username == 'test@example.com'"
304
+ assert flow_step.task_config == {"test": "value"}
305
+
306
+
307
+ class TestEvaluationMethods:
308
+ """Test the evaluation methods for flow and task skipping."""
309
+
310
+ def setup_method(self):
311
+ """Set up test fixtures."""
312
+ self.project_config = create_project_config("TestOwner", "TestRepo")
313
+ self.org_config = OrgConfig(
314
+ {"username": "test@example.com"}, "test", mock.Mock()
315
+ )
316
+ self.org_config.refresh_oauth_token = mock.Mock()
317
+
318
+ def test_evaluate_flow_step_with_true_condition(self):
319
+ """Test _evaluate_flow_step with a condition that evaluates to True."""
320
+ flow_config = FlowConfig({"description": "Test Flow", "steps": {}})
321
+ flow_config.project_config = self.project_config
322
+ coordinator = FlowCoordinator(self.project_config, flow_config)
323
+ coordinator.org_config = self.org_config
324
+
325
+ step = FlowStepSpec(
326
+ task_config={},
327
+ step_num="1.0",
328
+ task_name="test_flow",
329
+ task_class=None,
330
+ project_config=self.project_config,
331
+ allow_failure=False,
332
+ when="org_config.username == 'test@example.com'",
333
+ )
334
+
335
+ result = coordinator._evaluate_flow_step(step)
336
+ assert result is True
337
+
338
+ def test_evaluate_flow_step_with_false_condition(self):
339
+ """Test _evaluate_flow_step with a condition that evaluates to False."""
340
+ flow_config = FlowConfig({"description": "Test Flow", "steps": {}})
341
+ flow_config.project_config = self.project_config
342
+ coordinator = FlowCoordinator(self.project_config, flow_config)
343
+ coordinator.org_config = self.org_config
344
+
345
+ step = FlowStepSpec(
346
+ task_config={},
347
+ step_num="1.0",
348
+ task_name="test_flow",
349
+ task_class=None,
350
+ project_config=self.project_config,
351
+ allow_failure=False,
352
+ when="org_config.username == 'wrong@example.com'",
353
+ )
354
+
355
+ result = coordinator._evaluate_flow_step(step)
356
+ assert result is False
357
+
358
+ def test_evaluate_flow_step_without_when_condition(self):
359
+ """Test _evaluate_flow_step without a when condition."""
360
+ flow_config = FlowConfig({"description": "Test Flow", "steps": {}})
361
+ flow_config.project_config = self.project_config
362
+ coordinator = FlowCoordinator(self.project_config, flow_config)
363
+ coordinator.org_config = self.org_config
364
+
365
+ step = FlowStepSpec(
366
+ task_config={},
367
+ step_num="1.0",
368
+ task_name="test_flow",
369
+ task_class=None,
370
+ project_config=self.project_config,
371
+ allow_failure=False,
372
+ when=None,
373
+ )
374
+
375
+ result = coordinator._evaluate_flow_step(step)
376
+ assert result is True
377
+
378
+ def test_is_task_in_skipped_flow_true(self):
379
+ """Test _is_task_in_skipped_flow returns True when task is in skipped flow."""
380
+ flow_config = FlowConfig({"description": "Test Flow", "steps": {}})
381
+ flow_config.project_config = self.project_config
382
+ coordinator = FlowCoordinator(self.project_config, flow_config)
383
+ coordinator.org_config = self.org_config
384
+
385
+ skipped_flows_set = {"skipped_flow", "another_flow"}
386
+ task_path = "skipped_flow.sub_task"
387
+
388
+ result = coordinator._is_task_in_skipped_flow(task_path, skipped_flows_set)
389
+ assert result is True
390
+
391
+ def test_is_task_in_skipped_flow_false(self):
392
+ """Test _is_task_in_skipped_flow returns False when task is not in skipped flow."""
393
+ flow_config = FlowConfig({"description": "Test Flow", "steps": {}})
394
+ flow_config.project_config = self.project_config
395
+ coordinator = FlowCoordinator(self.project_config, flow_config)
396
+ coordinator.org_config = self.org_config
397
+
398
+ skipped_flows_set = {"skipped_flow", "another_flow"}
399
+ task_path = "normal_flow.sub_task"
400
+
401
+ result = coordinator._is_task_in_skipped_flow(task_path, skipped_flows_set)
402
+ assert result is False
403
+
404
+ def test_is_task_in_skipped_flow_empty_set(self):
405
+ """Test _is_task_in_skipped_flow with empty skipped flows set."""
406
+ flow_config = FlowConfig({"description": "Test Flow", "steps": {}})
407
+ flow_config.project_config = self.project_config
408
+ coordinator = FlowCoordinator(self.project_config, flow_config)
409
+ coordinator.org_config = self.org_config
410
+
411
+ skipped_flows_set = set()
412
+ task_path = "any_flow.sub_task"
413
+
414
+ result = coordinator._is_task_in_skipped_flow(task_path, skipped_flows_set)
415
+ assert result is False
416
+
417
+
418
+ class TestExpressionCaching:
419
+ """Test Jinja2 expression caching functionality."""
420
+
421
+ def setup_method(self):
422
+ """Set up test fixtures."""
423
+ self.project_config = create_project_config("TestOwner", "TestRepo")
424
+ self.org_config = OrgConfig(
425
+ {"username": "test@example.com"}, "test", mock.Mock()
426
+ )
427
+ self.org_config.refresh_oauth_token = mock.Mock()
428
+
429
+ def test_expression_caching_reuse(self):
430
+ """Test that compiled expressions are cached and reused."""
431
+ flow_config = FlowConfig({"description": "Test Flow", "steps": {}})
432
+ flow_config.project_config = self.project_config
433
+ coordinator = FlowCoordinator(self.project_config, flow_config)
434
+ coordinator.org_config = self.org_config
435
+
436
+ # Clear any existing cache
437
+ coordinator._expression_cache = {}
438
+
439
+ step1 = FlowStepSpec(
440
+ task_config={},
441
+ step_num="1.0",
442
+ task_name="test_flow1",
443
+ task_class=None,
444
+ project_config=self.project_config,
445
+ allow_failure=False,
446
+ when="org_config.username == 'test@example.com'",
447
+ )
448
+
449
+ step2 = FlowStepSpec(
450
+ task_config={},
451
+ step_num="2.0",
452
+ task_name="test_flow2",
453
+ task_class=None,
454
+ project_config=self.project_config,
455
+ allow_failure=False,
456
+ when="org_config.username == 'test@example.com'",
457
+ )
458
+
459
+ # First evaluation should compile and cache the expression
460
+ result1 = coordinator._evaluate_flow_step(step1)
461
+ assert result1 is True
462
+ assert len(coordinator._expression_cache) == 1
463
+
464
+ # Second evaluation should use cached expression
465
+ result2 = coordinator._evaluate_flow_step(step2)
466
+ assert result2 is True
467
+ assert (
468
+ len(coordinator._expression_cache) == 1
469
+ ) # Still only one cached expression
470
+
471
+ def test_expression_caching_different_expressions(self):
472
+ """Test that different expressions are cached separately."""
473
+ flow_config = FlowConfig({"description": "Test Flow", "steps": {}})
474
+ flow_config.project_config = self.project_config
475
+ coordinator = FlowCoordinator(self.project_config, flow_config)
476
+ coordinator.org_config = self.org_config
477
+
478
+ # Clear any existing cache
479
+ coordinator._expression_cache = {}
480
+
481
+ step1 = FlowStepSpec(
482
+ task_config={},
483
+ step_num="1.0",
484
+ task_name="test_flow1",
485
+ task_class=None,
486
+ project_config=self.project_config,
487
+ allow_failure=False,
488
+ when="org_config.username == 'test@example.com'",
489
+ )
490
+
491
+ step2 = FlowStepSpec(
492
+ task_config={},
493
+ step_num="2.0",
494
+ task_name="test_flow2",
495
+ task_class=None,
496
+ project_config=self.project_config,
497
+ allow_failure=False,
498
+ when="org_config.username == 'wrong@example.com'",
499
+ )
500
+
501
+ # Evaluate both steps
502
+ coordinator._evaluate_flow_step(step1)
503
+ coordinator._evaluate_flow_step(step2)
504
+
505
+ # Should have two different cached expressions
506
+ assert len(coordinator._expression_cache) == 2
507
+
508
+
509
+ class TestPerformanceImprovements:
510
+ """Test that performance improvements work correctly."""
511
+
512
+ def setup_method(self):
513
+ """Set up test fixtures."""
514
+ self.project_config = create_project_config("TestOwner", "TestRepo")
515
+ self.org_config = OrgConfig(
516
+ {"username": "test@example.com"}, "test", mock.Mock()
517
+ )
518
+ self.org_config.refresh_oauth_token = mock.Mock()
519
+
520
+ def test_context_reuse(self):
521
+ """Test that Jinja2 context is reused when possible."""
522
+ flow_config = FlowConfig({"description": "Test Flow", "steps": {}})
523
+ flow_config.project_config = self.project_config
524
+ coordinator = FlowCoordinator(self.project_config, flow_config)
525
+ coordinator.org_config = self.org_config
526
+
527
+ # Clear any existing context
528
+ coordinator._jinja2_context = None
529
+ coordinator._context_project_config = None
530
+ coordinator._context_org_config = None
531
+
532
+ step = FlowStepSpec(
533
+ task_config={},
534
+ step_num="1.0",
535
+ task_name="test_flow",
536
+ task_class=None,
537
+ project_config=self.project_config,
538
+ allow_failure=False,
539
+ when="org_config.username == 'test@example.com'",
540
+ )
541
+
542
+ # First evaluation should create context
543
+ result1 = coordinator._evaluate_flow_step(step)
544
+ assert result1 is True
545
+ assert coordinator._jinja2_context is not None
546
+ assert coordinator._context_project_config == self.project_config
547
+ assert coordinator._context_org_config == self.org_config
548
+
549
+ # Second evaluation should reuse context
550
+ original_context = coordinator._jinja2_context
551
+ result2 = coordinator._evaluate_flow_step(step)
552
+ assert result2 is True
553
+ assert coordinator._jinja2_context is original_context # Same object reused
@@ -106,9 +106,10 @@ def test_service_connect__attr_with_default_value():
106
106
  # but input of an empty line accepts the default.
107
107
  assert "attr (example) [PRESET]: " in result.output
108
108
  service_config = runtime.keychain.get_service("test", "test-alias")
109
- with mock.patch(
110
- "cumulusci.core.config.base_config.STRICT_GETATTR", False
111
- ), pytest.warns(DeprecationWarning, match="attr"):
109
+ with (
110
+ mock.patch("cumulusci.core.config.base_config.STRICT_GETATTR", False),
111
+ pytest.warns(DeprecationWarning, match="attr"),
112
+ ):
112
113
  assert service_config.lookup("attr") == "PRESET"
113
114
  assert service_config.attr == "PRESET"
114
115
 
@@ -132,9 +133,10 @@ def test_service_connect__attr_with_default_factory():
132
133
 
133
134
  # The service should have the attribute value returned by the default factory.
134
135
  service_config = runtime.keychain.get_service("test", "test-alias")
135
- with mock.patch(
136
- "cumulusci.core.config.base_config.STRICT_GETATTR", False
137
- ), pytest.warns(DeprecationWarning, match="attr"):
136
+ with (
137
+ mock.patch("cumulusci.core.config.base_config.STRICT_GETATTR", False),
138
+ pytest.warns(DeprecationWarning, match="attr"),
139
+ ):
138
140
  assert service_config.lookup("attr") == "CALCULATED"
139
141
  assert service_config.attr == "CALCULATED"
140
142
 
@@ -155,13 +157,14 @@ def test_service_connect__alias_already_exists():
155
157
  "test-type",
156
158
  "already-exists",
157
159
  runtime=runtime,
158
- input="new\ny\n",
160
+ input="new\ny\nn\n",
159
161
  )
160
162
 
161
163
  service_config = runtime.keychain.get_service("test-type", "already-exists")
162
- with mock.patch(
163
- "cumulusci.core.config.base_config.STRICT_GETATTR", False
164
- ), pytest.warns(DeprecationWarning, match="attr"):
164
+ with (
165
+ mock.patch("cumulusci.core.config.base_config.STRICT_GETATTR", False),
166
+ pytest.warns(DeprecationWarning, match="attr"),
167
+ ):
165
168
  assert service_config.lookup("attr") == "new"
166
169
  assert service_config.attr == "new"
167
170
 
@@ -536,7 +539,7 @@ def test_service_connect__connected_app():
536
539
  "connect",
537
540
  "connected_app",
538
541
  "new",
539
- input="\n\nID\nSECRET\n",
542
+ input="\n\nID\nSECRET\nn\n",
540
543
  runtime=runtime,
541
544
  )
542
545
 
@@ -559,7 +562,7 @@ def test_service_connect__connected_app__with_cli_options():
559
562
  "new",
560
563
  "--login_url",
561
564
  "https://custom",
562
- input="\nID\nSECRET\n", # not prompted for login_url
565
+ input="\nID\nSECRET\nn\n", # not prompted for login_url
563
566
  runtime=runtime,
564
567
  )
565
568
 
@@ -1,6 +1,8 @@
1
1
  import contextlib
2
2
  import io
3
3
  import json
4
+ import tempfile
5
+ from pathlib import Path
4
6
  from unittest.mock import Mock, patch
5
7
 
6
8
  import click
@@ -126,10 +128,10 @@ def test_format_help(runtime):
126
128
 
127
129
  def test_get_default_command_options():
128
130
  opts = task.RunTaskCommand()._get_default_command_options(is_salesforce_task=False)
129
- assert len(opts) == 4
131
+ assert len(opts) == 5
130
132
 
131
133
  opts = task.RunTaskCommand()._get_default_command_options(is_salesforce_task=True)
132
- assert len(opts) == 5
134
+ assert len(opts) == 6
133
135
  assert any([o.name == "org" for o in opts])
134
136
 
135
137
 
@@ -264,3 +266,87 @@ class SetTrace(Exception):
264
266
  class DummyDerivedTask(DummyTask):
265
267
  def _run_task(self):
266
268
  click.echo(f"<{self.__class__}>\n\tcolor: {self.options['color']}")
269
+
270
+
271
+ @patch("cumulusci.cli.task.load_dotenv")
272
+ def test_task_run__loadenv_with_project_root(load_dotenv, runtime):
273
+ """Test that loadenv loads .env file from project root when project exists."""
274
+ DummyTask._run_task = Mock()
275
+
276
+ # Create a temporary directory for the test
277
+ with tempfile.TemporaryDirectory() as temp_dir:
278
+ runtime.project_config._repo_info = {"root": temp_dir}
279
+
280
+ multi_cmd = task.RunTaskCommand()
281
+ with click.Context(multi_cmd, obj=runtime) as ctx:
282
+ cmd = multi_cmd.get_command(ctx, "dummy-task")
283
+ cmd.callback(runtime, "dummy-task", color="blue", loadenv=True)
284
+
285
+ # Verify load_dotenv was called with the correct path
286
+ expected_path = Path(temp_dir) / ".env"
287
+ load_dotenv.assert_called_once_with(expected_path)
288
+ DummyTask._run_task.assert_called_once()
289
+
290
+
291
+ @patch("cumulusci.cli.task.load_dotenv")
292
+ def test_task_run__loadenv_false(load_dotenv, runtime):
293
+ """Test that loadenv does not call load_dotenv when loadenv=False."""
294
+ DummyTask._run_task = Mock()
295
+
296
+ multi_cmd = task.RunTaskCommand()
297
+ with click.Context(multi_cmd, obj=runtime) as ctx:
298
+ cmd = multi_cmd.get_command(ctx, "dummy-task")
299
+ cmd.callback(runtime, "dummy-task", color="blue", loadenv=False)
300
+
301
+ # Verify load_dotenv was not called
302
+ load_dotenv.assert_not_called()
303
+ DummyTask._run_task.assert_called_once()
304
+
305
+
306
+ @patch("cumulusci.cli.task.load_dotenv")
307
+ def test_task_run__loadenv_not_provided(load_dotenv, runtime):
308
+ """Test that loadenv does not call load_dotenv when loadenv is not provided."""
309
+ DummyTask._run_task = Mock()
310
+
311
+ multi_cmd = task.RunTaskCommand()
312
+ with click.Context(multi_cmd, obj=runtime) as ctx:
313
+ cmd = multi_cmd.get_command(ctx, "dummy-task")
314
+ cmd.callback(runtime, "dummy-task", color="blue")
315
+
316
+ # Verify load_dotenv was not called
317
+ load_dotenv.assert_not_called()
318
+ DummyTask._run_task.assert_called_once()
319
+
320
+
321
+ @patch("cumulusci.cli.task.load_dotenv")
322
+ def test_task_run__loadenv_none_value(load_dotenv, runtime):
323
+ """Test that loadenv does not call load_dotenv when loadenv=None."""
324
+ DummyTask._run_task = Mock()
325
+
326
+ multi_cmd = task.RunTaskCommand()
327
+ with click.Context(multi_cmd, obj=runtime) as ctx:
328
+ cmd = multi_cmd.get_command(ctx, "dummy-task")
329
+ cmd.callback(runtime, "dummy-task", color="blue", loadenv=None)
330
+
331
+ # Verify load_dotenv was not called
332
+ load_dotenv.assert_not_called()
333
+ DummyTask._run_task.assert_called_once()
334
+
335
+
336
+ def test_get_default_command_options_includes_loadenv():
337
+ """Test that the loadenv option is included in default command options."""
338
+ opts = task.RunTaskCommand()._get_default_command_options(is_salesforce_task=False)
339
+
340
+ # Should have 5 global options including loadenv
341
+ assert len(opts) == 5
342
+
343
+ # Find the loadenv option
344
+ loadenv_opt = None
345
+ for opt in opts:
346
+ if hasattr(opt, "name") and opt.name == "loadenv":
347
+ loadenv_opt = opt
348
+ break
349
+
350
+ assert loadenv_opt is not None
351
+ assert loadenv_opt.is_flag is True
352
+ assert "Loads environment variables from the .env file" in loadenv_opt.help
@@ -17,10 +17,7 @@ def run_click_command(cmd, *args, **kw):
17
17
 
18
18
  def run_cli_command(*args, runtime=None, input=None, **kw):
19
19
  """Run a click command with arg parsing and injected CCI runtime object."""
20
- if tuple(map(int, click.__version__.split("."))) >= (8, 1, 0):
21
- runner = CliRunner()
22
- else:
23
- runner = CliRunner(mix_stderr=False)
20
+ runner = CliRunner()
24
21
  result = runner.invoke(
25
22
  cli,
26
23
  args,
@@ -1,5 +1,5 @@
1
1
  from difflib import get_close_matches
2
- from typing import Any, Dict, List
2
+ from typing import Any, Dict, List, cast
3
3
 
4
4
  from cumulusci.core.config import BaseConfig, FlowConfig, TaskConfig
5
5
  from cumulusci.core.exceptions import (
@@ -7,6 +7,8 @@ from cumulusci.core.exceptions import (
7
7
  FlowNotFoundError,
8
8
  TaskNotFoundError,
9
9
  )
10
+ from cumulusci.core.utils import merge_config
11
+ from cumulusci.plugins.plugin_loader import load_plugins
10
12
 
11
13
 
12
14
  def list_infos(infos: dict) -> List[Dict[str, str]]:
@@ -30,6 +32,7 @@ class BaseTaskFlowConfig(BaseConfig):
30
32
 
31
33
  tasks: dict
32
34
  flows: dict
35
+ config_plugins: dict = {}
33
36
 
34
37
  def list_tasks(self) -> List[Dict[str, str]]:
35
38
  """Returns a list of task info dictionaries with keys 'name' and 'description'"""
@@ -80,3 +83,25 @@ class BaseTaskFlowConfig(BaseConfig):
80
83
  return f'. Did you mean "{match_list[0]}"?'
81
84
  else:
82
85
  return ""
86
+
87
+ def _load_plugins_config(self):
88
+ """Loads the plugin configurations"""
89
+ plugins = load_plugins()
90
+ self.config_plugins = {}
91
+ for plugin in plugins:
92
+ if plugin.plugin_project_config:
93
+ self.config_plugins.update(plugin.plugin_project_config)
94
+ plugin.teardown() # clean up the plugin
95
+
96
+ def merge_base_config(self, base_config: dict) -> dict:
97
+ """Merges the base config with the plugin configurations"""
98
+ self._load_plugins_config()
99
+ return cast(
100
+ dict,
101
+ merge_config(
102
+ {
103
+ **base_config,
104
+ "plugins_config": self.config_plugins,
105
+ }
106
+ ),
107
+ )