cumulusci-plus 5.0.19__py3-none-any.whl → 5.0.35__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cumulusci/__about__.py +1 -1
- cumulusci/cli/logger.py +2 -2
- cumulusci/cli/service.py +20 -0
- cumulusci/cli/task.py +17 -0
- cumulusci/cli/tests/test_error.py +3 -1
- cumulusci/cli/tests/test_flow.py +279 -2
- cumulusci/cli/tests/test_service.py +15 -12
- cumulusci/cli/tests/test_task.py +88 -2
- cumulusci/cli/tests/utils.py +1 -4
- cumulusci/core/config/base_task_flow_config.py +26 -1
- cumulusci/core/config/project_config.py +2 -20
- cumulusci/core/config/tests/test_config_expensive.py +9 -3
- cumulusci/core/config/universal_config.py +3 -4
- cumulusci/core/dependencies/base.py +1 -1
- cumulusci/core/dependencies/dependencies.py +1 -1
- cumulusci/core/dependencies/github.py +1 -2
- cumulusci/core/dependencies/resolvers.py +1 -1
- cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
- cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
- cumulusci/core/flowrunner.py +90 -6
- cumulusci/core/github.py +1 -1
- cumulusci/core/sfdx.py +3 -1
- cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
- cumulusci/core/source_transforms/transforms.py +1 -1
- cumulusci/core/tasks.py +13 -2
- cumulusci/core/tests/test_flowrunner.py +100 -0
- cumulusci/core/tests/test_tasks.py +65 -0
- cumulusci/core/utils.py +3 -1
- cumulusci/core/versions.py +1 -1
- cumulusci/cumulusci.yml +55 -0
- cumulusci/oauth/client.py +1 -1
- cumulusci/plugins/plugin_base.py +5 -3
- cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
- cumulusci/salesforce_api/rest_deploy.py +1 -1
- cumulusci/schema/cumulusci.jsonschema.json +64 -0
- cumulusci/tasks/apex/anon.py +1 -1
- cumulusci/tasks/apex/testrunner.py +416 -142
- cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
- cumulusci/tasks/bulkdata/extract.py +0 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
- cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
- cumulusci/tasks/bulkdata/select_utils.py +1 -1
- cumulusci/tasks/bulkdata/snowfakery.py +100 -25
- cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
- cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
- cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
- cumulusci/tasks/bulkdata/tests/test_select_utils.py +26 -0
- cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
- cumulusci/tasks/create_package_version.py +190 -16
- cumulusci/tasks/datadictionary.py +1 -1
- cumulusci/tasks/metadata_etl/base.py +7 -3
- cumulusci/tasks/metadata_etl/layouts.py +1 -1
- cumulusci/tasks/metadata_etl/permissions.py +1 -1
- cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
- cumulusci/tasks/push/README.md +15 -17
- cumulusci/tasks/release_notes/README.md +13 -13
- cumulusci/tasks/release_notes/generator.py +13 -8
- cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
- cumulusci/tasks/salesforce/Deploy.py +53 -2
- cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
- cumulusci/tasks/salesforce/__init__.py +1 -0
- cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
- cumulusci/tasks/salesforce/composite.py +1 -1
- cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
- cumulusci/tasks/salesforce/enable_prediction.py +5 -1
- cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
- cumulusci/tasks/salesforce/profiles.py +13 -9
- cumulusci/tasks/salesforce/sourcetracking.py +1 -1
- cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
- cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
- cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
- cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
- cumulusci/tasks/salesforce/tests/test_profiles.py +43 -3
- cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
- cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
- cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
- cumulusci/tasks/salesforce/update_dependencies.py +2 -2
- cumulusci/tasks/salesforce/update_external_credential.py +562 -0
- cumulusci/tasks/salesforce/update_named_credential.py +441 -0
- cumulusci/tasks/salesforce/update_profile.py +17 -13
- cumulusci/tasks/salesforce/users/permsets.py +62 -5
- cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
- cumulusci/tasks/sfdmu/__init__.py +0 -0
- cumulusci/tasks/sfdmu/sfdmu.py +363 -0
- cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
- cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
- cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
- cumulusci/tasks/tests/test_create_package_version.py +716 -1
- cumulusci/tasks/tests/test_util.py +42 -0
- cumulusci/tasks/util.py +37 -1
- cumulusci/tasks/utility/copyContents.py +402 -0
- cumulusci/tasks/utility/credentialManager.py +256 -0
- cumulusci/tasks/utility/directoryRecreator.py +30 -0
- cumulusci/tasks/utility/env_management.py +1 -1
- cumulusci/tasks/utility/secretsToEnv.py +135 -0
- cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
- cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
- cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
- cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
- cumulusci/tests/test_integration_infrastructure.py +3 -1
- cumulusci/tests/test_utils.py +70 -6
- cumulusci/utils/__init__.py +54 -9
- cumulusci/utils/classutils.py +5 -2
- cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
- cumulusci/utils/options.py +23 -1
- cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
- cumulusci/utils/yaml/cumulusci_yml.py +7 -3
- cumulusci/utils/yaml/model_parser.py +2 -2
- cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
- cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
- cumulusci/vcs/base.py +23 -15
- cumulusci/vcs/bootstrap.py +5 -4
- cumulusci/vcs/utils/list_modified_files.py +189 -0
- cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +123 -98
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/LICENSE +0 -0
cumulusci/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "5.0.
|
|
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(
|
|
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")
|
cumulusci/cli/tests/test_flow.py
CHANGED
|
@@ -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
|
|
110
|
-
"cumulusci.core.config.base_config.STRICT_GETATTR", False
|
|
111
|
-
|
|
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
|
|
136
|
-
"cumulusci.core.config.base_config.STRICT_GETATTR", False
|
|
137
|
-
|
|
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
|
|
163
|
-
"cumulusci.core.config.base_config.STRICT_GETATTR", False
|
|
164
|
-
|
|
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
|
|
cumulusci/cli/tests/test_task.py
CHANGED
|
@@ -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) ==
|
|
131
|
+
assert len(opts) == 5
|
|
130
132
|
|
|
131
133
|
opts = task.RunTaskCommand()._get_default_command_options(is_salesforce_task=True)
|
|
132
|
-
assert len(opts) ==
|
|
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
|
cumulusci/cli/tests/utils.py
CHANGED
|
@@ -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
|
-
|
|
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
|
+
)
|