Skip to content

Commit

Permalink
Merge pull request #151 from jhonabreul/feat-cloud-project-delete-com…
Browse files Browse the repository at this point in the history
…mand

Add delete project command
  • Loading branch information
Martin-Molinero authored Sep 15, 2022
2 parents e9e00f9 + 9fe0234 commit cad62c1
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 10 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ A locally-focused workflow (local development, local execution) with the CLI may
- [`lean create-project`](#lean-create-project)
- [`lean data download`](#lean-data-download)
- [`lean data generate`](#lean-data-generate)
- [`lean delete-project`](#lean-delete-project)
- [`lean init`](#lean-init)
- [`lean library add`](#lean-library-add)
- [`lean library remove`](#lean-library-remove)
Expand Down Expand Up @@ -632,6 +633,24 @@ Options:

_See code: [lean/commands/data/generate.py](lean/commands/data/generate.py)_

### `lean delete-project`

Delete a project locally and in the cloud if it exists.

```
Usage: lean delete-project [OPTIONS] PROJECT
Delete a project locally and in the cloud if it exists.
The project is selected by name or cloud id.
Options:
--verbose Enable debug logging
--help Show this message and exit.
```

_See code: [lean/commands/delete_project.py](lean/commands/delete_project.py)_

### `lean init`

Scaffold a Lean configuration file and data directory.
Expand Down
2 changes: 2 additions & 0 deletions lean/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from lean.commands.cloud import cloud
from lean.commands.config import config
from lean.commands.create_project import create_project
from lean.commands.delete_project import delete_project
from lean.commands.data import data
from lean.commands.init import init
from lean.commands.library import library
Expand Down Expand Up @@ -51,6 +52,7 @@ def lean() -> None:
lean.add_command(whoami)
lean.add_command(init)
lean.add_command(create_project)
lean.add_command(delete_project)
lean.add_command(backtest)
lean.add_command(optimize)
lean.add_command(research)
Expand Down
21 changes: 12 additions & 9 deletions lean/commands/cloud/pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,21 @@ def pull(project: Optional[str], pull_bootcamp: bool) -> None:
This command will not delete local files for which there is no counterpart in the cloud.
"""
# Parse which projects need to be pulled
project_id = None
if project is not None:
try:
project_id = int(project)
except ValueError:
pass

api_client = container.api_client()
all_projects = api_client.projects.get_all()
project_manager = container.project_manager()
projects_to_pull = project_manager.get_projects_by_name_or_id(all_projects, project_id or project)

# Parse which projects need to be pulled
if project is not None:
projects_to_pull = [p for p in all_projects if str(p.projectId) == project or p.name == project]
if len(projects_to_pull) == 0:
raise RuntimeError("No project with the given name or id exists in the cloud")
else:
projects_to_pull = all_projects
if not pull_bootcamp:
projects_to_pull = [p for p in projects_to_pull if not p.name.startswith("Boot Camp/")]
if project is None and not pull_bootcamp:
projects_to_pull = [p for p in projects_to_pull if not p.name.startswith("Boot Camp/")]

pull_manager = container.pull_manager()
pull_manager.pull_projects(projects_to_pull)
61 changes: 61 additions & 0 deletions lean/commands/delete_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from pathlib import Path

import click

from lean.click import LeanCommand
from lean.container import container


@click.command(cls=LeanCommand)
@click.argument("project", type=str)
def delete_project(project: str) -> None:
"""Delete a project locally and in the cloud if it exists.
The project is selected by name or cloud id.
"""
# Remove project from cloud
api_client = container.api_client()
all_projects = api_client.projects.get_all()
project_manager = container.project_manager()
logger = container.logger()

project_id = None
try:
project_id = int(project)
except ValueError:
pass

projects = []
try:
projects = project_manager.get_projects_by_name_or_id(all_projects, project_id or project)
except RuntimeError:
# If searching by id, we cannot try lo locate the project locally
if project_id is not None:
raise RuntimeError(f"The project with ID {project_id} was not found in the cloud.")

# The project might only be local, look for the path
logger.info(f"The project {project} was not found in the cloud. It will be removed locally if it exists.")

full_project = next(iter(projects), None)

if full_project is not None:
api_client.projects.delete(full_project.projectId)

# Remove project locally
project_path = full_project.name if full_project is not None else project
project_manager.delete_project(Path(project_path))

logger.info(f"Successfully deleted project '{project_path}'")
37 changes: 36 additions & 1 deletion lean/components/util/project_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import List
from typing import List, Optional, Union

import pkg_resources

Expand Down Expand Up @@ -157,6 +157,41 @@ def create_new_project(self, project_dir: Path, language: QCLanguage) -> None:
self._generate_csproj(project_dir)
self.generate_rider_config()

def delete_project(self, project_dir: Path) -> None:
"""Deletes a project directory.
Raises an error if the project directory does not exist.
:param project_dir: the directory of the project to delete
"""
try:
shutil.rmtree(project_dir)
except FileNotFoundError:
raise RuntimeError(f"Failed to delete project. Could not find the specified path {project_dir}.")

def get_projects_by_name_or_id(self, cloud_projects: List[QCProject],
project: Optional[Union[str, int]]) -> List[QCProject]:
"""Returns a list of all the projects in the cloud that match the given name or id.
:param cloud_projects: all projects fetched from the cloud
:param project: the name or id of the project
:return: a list of all the projects in the cloud that match the given name or id
"""
projects = []
search_by_id = isinstance(project, int)

if project is not None:
project_path = Path(project).as_posix() if not search_by_id else None
projects = [p for p in cloud_projects
if search_by_id and p.projectId == project or
not search_by_id and Path(p.name).as_posix() == project_path]
if len(projects) == 0:
raise RuntimeError("No project with the given name or id exists in the cloud")
else:
projects = cloud_projects

return projects

def _generate_python_library_projects_config(self) -> None:
"""Generates the required configuration to enable autocomplete on Python library projects."""
try:
Expand Down
117 changes: 117 additions & 0 deletions tests/commands/test_delete_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from pathlib import Path
from typing import List
from unittest import mock

import pytest
from click.testing import CliRunner

from lean.commands import lean
from lean.components.api.project_client import ProjectClient
from lean.models.api import QCProject
from tests.test_helpers import create_fake_lean_cli_directory, create_api_project


def assert_project_exists(path: str) -> None:
project_dir = (Path.cwd() / path)

assert project_dir.exists()
assert (project_dir / "main.py").exists()
assert (project_dir / "research.ipynb").exists()


def assert_project_does_not_exist(path: str) -> None:
project_dir = (Path.cwd() / path)
assert not project_dir.exists()


def create_cloud_projects(count: int = 10) -> List[QCProject]:
return [create_api_project(i, f"Python Project {i}") for i in range(1, count + 1)]


def test_delete_project_locally_that_does_not_have_cloud_counterpart() -> None:
create_fake_lean_cli_directory()

path = "Python Project"
assert_project_exists(path)

cloud_projects = create_cloud_projects()
assert not any(project.name == path for project in cloud_projects)

with mock.patch.object(ProjectClient, 'get_all', return_value=cloud_projects) as mock_get_all,\
mock.patch.object(ProjectClient, 'delete', return_value=None) as mock_delete:
result = CliRunner().invoke(lean, ["delete-project", path])
assert result.exit_code == 0

mock_get_all.assert_called_once()
mock_delete.assert_not_called()
assert_project_does_not_exist(path)


@pytest.mark.parametrize("name_or_id", ["Python Project", "11"])
def test_delete_project_deletes_in_cloud(name_or_id: str) -> None:
create_fake_lean_cli_directory()

path = "Python Project"
assert_project_exists(path)

cloud_projects = create_cloud_projects(10)
assert not any(project.name == path for project in cloud_projects)

cloud_project = create_api_project(len(cloud_projects) + 1, path)
cloud_projects.append(cloud_project)

with mock.patch.object(ProjectClient, 'get_all', return_value=cloud_projects) as mock_get_all,\
mock.patch.object(ProjectClient, 'delete', return_value=None) as mock_delete:
result = CliRunner().invoke(lean, ["delete-project", name_or_id])
assert result.exit_code == 0

mock_get_all.assert_called_once()
mock_delete.assert_called_once_with(cloud_project.projectId)
assert_project_does_not_exist(cloud_project.name)


def test_delete_project_aborts_when_path_does_not_exist() -> None:
create_fake_lean_cli_directory()

path = "Non Existing Project"
assert_project_does_not_exist(path)

with mock.patch.object(ProjectClient, 'get_all', return_value=create_cloud_projects()) as mock_get_all,\
mock.patch.object(ProjectClient, 'delete', return_value=None) as mock_delete:
result = CliRunner().invoke(lean, ["delete-project", path])
assert result.exit_code != 0

mock_get_all.assert_called_once()
mock_delete.assert_not_called()


def test_delete_project_by_id_aborts_when_not_found_in_cloud() -> None:
create_fake_lean_cli_directory()

path = "Python Project"
assert_project_exists(path)

cloud_projects = create_cloud_projects(10)
project_id = str(len(cloud_projects) + 1)

with mock.patch.object(ProjectClient, 'get_all', return_value=[]) as mock_get_all,\
mock.patch.object(ProjectClient, 'delete', return_value=None) as mock_delete:
result = CliRunner().invoke(lean, ["delete-project", project_id])
assert result.exit_code != 0

mock_get_all.assert_called_once()
mock_delete.assert_not_called()
assert_project_exists(path)

0 comments on commit cad62c1

Please sign in to comment.