From caf182d0ff5e2683e4cdfeeba147d5231d2f744f Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:38:27 +0000 Subject: [PATCH] fix: stop cloud commands when the requested push fails `lean cloud backtest --push`, `lean cloud optimize --push` and `lean cloud live deploy --push` logged a push failure as a warning and then ran the previous cloud files, exiting 0. PushManager.push_projects now reports whether every project was pushed, and CloudProjectManager raises a PushFailedError when a requested push fails, so the command exits nonzero before compiling. Co-Authored-By: Claude Opus 5.5 --- .../components/cloud/cloud_project_manager.py | 7 ++- lean/components/cloud/push_manager.py | 10 ++++- lean/models/errors.py | 11 +++++ tests/commands/cloud/test_backtest.py | 27 +++++++++++ .../cloud/test_cloud_project_manager.py | 45 +++++++++++++++++++ tests/components/util/test_push_manager.py | 33 +++++++++++++- 6 files changed, 128 insertions(+), 5 deletions(-) diff --git a/lean/components/cloud/cloud_project_manager.py b/lean/components/cloud/cloud_project_manager.py index b151a48c..3f9f0480 100644 --- a/lean/components/cloud/cloud_project_manager.py +++ b/lean/components/cloud/cloud_project_manager.py @@ -21,6 +21,7 @@ from lean.components.util.path_manager import PathManager from lean.components.util.project_manager import ProjectManager from lean.models.api import QCProject +from lean.models.errors import PushFailedError class CloudProjectManager: @@ -68,7 +69,8 @@ def get_cloud_project(self, input: str, push: bool) -> QCProject: local_path = Path.cwd() / input if self._project_config_manager.try_get_project_config(local_path): if push: - self._push_manager.push_projects([local_path]) + if not self._push_manager.push_projects([local_path]): + raise PushFailedError(input) cloud_id = self._project_config_manager.get_project_config(local_path).get("cloud-id") if cloud_id is None: @@ -92,7 +94,8 @@ def get_cloud_project(self, input: str, push: bool) -> QCProject: if push: local_path = self._project_manager.get_local_project_path(cloud_project.name, cloud_project.projectId) if local_path.exists(): - self._push_manager.push_projects([local_path]) + if not self._push_manager.push_projects([local_path]): + raise PushFailedError(input) return self._api_client.projects.get(cloud_project.projectId, organization_id) return cloud_project diff --git a/lean/components/cloud/push_manager.py b/lean/components/cloud/push_manager.py index d3df19bc..e993867f 100644 --- a/lean/components/cloud/push_manager.py +++ b/lean/components/cloud/push_manager.py @@ -56,17 +56,20 @@ def push_project(self, project: Path, encryption_action: Optional[ActionType]=No libraries = self._project_manager.get_project_libraries(project) self.push_projects([project], libraries, encryption_action, encryption_key, force) - def push_projects(self, projects_to_push: List[Path], associated_libraries_to_push: Optional[List[Path]]=[], encryption_action: Optional[ActionType]=None, encryption_key: Optional[Path]=None, force: Optional[bool]=False) -> None: + def push_projects(self, projects_to_push: List[Path], associated_libraries_to_push: Optional[List[Path]]=[], encryption_action: Optional[ActionType]=None, encryption_key: Optional[Path]=None, force: Optional[bool]=False) -> bool: """Pushes the given projects from the local drive to the cloud. It will also push every library referenced by each project and add or remove references. :param projects_to_push: a list of directories containing the local projects that need to be pushed + :return: True if every project and library was pushed, False if any of them could not be pushed """ all_projects_to_push = associated_libraries_to_push + projects_to_push if len(all_projects_to_push) == 0: - return + return True + + all_pushed = True organization_id = self._organization_manager.try_get_working_organization_id() @@ -85,6 +88,9 @@ def push_projects(self, projects_to_push: List[Path], associated_libraries_to_pu self._logger.warn(f"Cannot push '{relative_path}': {ex}") if "write permission" in str(ex).lower(): self._logger.info("Please pull any required changes and push with --force") + all_pushed = False + + return all_pushed def _get_local_libraries_cloud_ids(self, project_dir: Path) -> List[int]: project_config = self._project_config_manager.get_project_config(project_dir) diff --git a/lean/models/errors.py b/lean/models/errors.py index cb477a12..9a726b90 100644 --- a/lean/models/errors.py +++ b/lean/models/errors.py @@ -60,3 +60,14 @@ def __init__(self, response=None) -> None: "https://www.lean.io/docs/v2/lean-cli/initialization/authenticating-accounts#02-Log-In") self.response = response + + +class PushFailedError(Exception): + """An error indicating that local changes the user asked to push could not be pushed to the cloud.""" + + def __init__(self, project: str) -> None: + """Creates a new PushFailedError instance. + + :param project: the name or id of the project that could not be pushed + """ + super().__init__(f"Cannot push '{project}' to the cloud, aborting so the cloud does not run outdated files") diff --git a/tests/commands/cloud/test_backtest.py b/tests/commands/cloud/test_backtest.py index 30402c92..4831fbb8 100644 --- a/tests/commands/cloud/test_backtest.py +++ b/tests/commands/cloud/test_backtest.py @@ -12,11 +12,14 @@ # limitations under the License. from datetime import datetime +from pathlib import Path from unittest import mock from click.testing import CliRunner from lean.commands import lean +from lean.container import container +from lean.models.errors import PushFailedError from lean.models.api import QCBacktest from tests.test_helpers import create_api_project, create_fake_lean_cli_directory from tests.conftest import initialize_container @@ -200,6 +203,30 @@ def test_cloud_backtest_pushes_nothing_when_project_does_not_exist_locally() -> push_manager.push_projects.assert_not_called() +def test_cloud_backtest_aborts_when_push_of_project_with_cloud_id_fails() -> None: + create_fake_lean_cli_directory() + + project = create_api_project(1, "Python Project") + + api_client = mock.Mock() + api_client.projects.get_all.return_value = [project] + api_client.projects.get.return_value = project + api_client.projects.update.side_effect = RuntimeError("You don't have write permission for this project") + + cloud_runner = mock.Mock() + initialize_container(api_client_to_use=api_client, cloud_runner_to_use=cloud_runner) + container.project_config_manager.get_project_config(Path.cwd() / "Python Project").set("cloud-id", project.projectId) + + result = CliRunner().invoke(lean, ["cloud", "backtest", "Python Project", "--push"]) + + assert result.exit_code != 0 + assert isinstance(result.exception, PushFailedError) + + api_client.projects.update.assert_called_once() + cloud_runner.compile_project.assert_not_called() + cloud_runner.run_backtest.assert_not_called() + + def test_cloud_backtest_aborts_when_backtest_fails() -> None: create_fake_lean_cli_directory() diff --git a/tests/components/cloud/test_cloud_project_manager.py b/tests/components/cloud/test_cloud_project_manager.py index fbf0fa57..a170a73c 100644 --- a/tests/components/cloud/test_cloud_project_manager.py +++ b/tests/components/cloud/test_cloud_project_manager.py @@ -14,7 +14,10 @@ from pathlib import Path from unittest import mock +import pytest + from lean.container import container +from lean.models.errors import PushFailedError from tests.test_helpers import create_api_project, create_fake_lean_cli_directory from tests.conftest import initialize_container @@ -49,3 +52,45 @@ def test_get_cloud_project_pushing_new_project(): api_client.projects.get.assert_called_with(cloud_project.projectId, "abc") push_manager.push_projects.assert_called_once_with([Path.cwd() / "Python Project"]) + + +def test_get_cloud_project_raises_when_push_of_project_with_cloud_id_fails() -> None: + create_fake_lean_cli_directory() + + cloud_project = create_api_project(20, "Python Project") + + api_client = mock.Mock() + api_client.projects.get.return_value = cloud_project + + push_manager = mock.Mock() + push_manager.push_projects = mock.Mock(return_value=False) + + initialize_container(api_client_to_use=api_client, push_manager_to_use=push_manager) + container.project_config_manager.get_project_config(Path.cwd() / "Python Project").set("cloud-id", cloud_project.projectId) + + with pytest.raises(PushFailedError): + container.cloud_project_manager.get_cloud_project("Python Project", push=True) + + push_manager.push_projects.assert_called_once_with([Path.cwd() / "Python Project"]) + api_client.projects.get.assert_not_called() + + +def test_get_cloud_project_raises_when_push_of_project_given_by_id_fails() -> None: + create_fake_lean_cli_directory() + + cloud_project = create_api_project(20, "Python Project") + + api_client = mock.Mock() + api_client.projects.get_all.return_value = [cloud_project] + + push_manager = mock.Mock() + push_manager.push_projects = mock.Mock(return_value=False) + + initialize_container(api_client_to_use=api_client, push_manager_to_use=push_manager) + container.project_config_manager.get_project_config(Path.cwd() / "Python Project").set("cloud-id", cloud_project.projectId) + + with pytest.raises(PushFailedError): + container.cloud_project_manager.get_cloud_project(str(cloud_project.projectId), push=True) + + push_manager.push_projects.assert_called_once_with([Path.cwd() / "Python Project"]) + api_client.projects.get.assert_not_called() diff --git a/tests/components/util/test_push_manager.py b/tests/components/util/test_push_manager.py index f3090d85..355cde01 100644 --- a/tests/components/util/test_push_manager.py +++ b/tests/components/util/test_push_manager.py @@ -527,4 +527,35 @@ def test_push_projects_updates_name_locally_if_updated_by_cloud() -> None: push_manager.push_projects([project_path]) assert not (Path.cwd() / project_path).exists() - assert (Path.cwd() / project_name_renamed_by_cloud).exists() \ No newline at end of file + assert (Path.cwd() / project_name_renamed_by_cloud).exists() + +def test_push_projects_returns_true_when_every_project_is_pushed() -> None: + create_fake_lean_cli_directory() + + project_path = Path.cwd() / "Python Project" + cloud_project = create_api_project(1, project_path.name) + container.project_config_manager.get_project_config(project_path).set("cloud-id", cloud_project.projectId) + + api_client = mock.Mock() + api_client.projects.get = mock.MagicMock(return_value=cloud_project) + + push_manager = _create_push_manager(api_client, container.project_manager) + + assert push_manager.push_projects([project_path]) + api_client.projects.update.assert_called_once() + + +def test_push_projects_returns_false_when_a_project_with_cloud_id_cannot_be_pushed() -> None: + create_fake_lean_cli_directory() + + project_path = Path.cwd() / "Python Project" + cloud_project = create_api_project(1, project_path.name) + container.project_config_manager.get_project_config(project_path).set("cloud-id", cloud_project.projectId) + + api_client = mock.Mock() + api_client.projects.get = mock.MagicMock(return_value=cloud_project) + api_client.projects.update = mock.MagicMock(side_effect=RuntimeError("You don't have write permission")) + + push_manager = _create_push_manager(api_client, container.project_manager) + + assert not push_manager.push_projects([project_path])