Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use one lean cli root folder per organization #220

23 changes: 10 additions & 13 deletions README.md
Expand Up @@ -443,11 +443,9 @@ Usage: lean cloud push [OPTIONS]
This command will delete cloud files which don't have a local counterpart.

Options:
--project DIRECTORY Path to the local project to push (all local projects if not specified)
--organization-id TEXT ID of the organization where the project will be created in. This is ignored if the project
has already been created in the cloud
--verbose Enable debug logging
--help Show this message and exit.
--project DIRECTORY Path to the local project to push (all local projects if not specified)
--verbose Enable debug logging
--help Show this message and exit.
```

_See code: [lean/commands/cloud/push.py](lean/commands/cloud/push.py)_
Expand Down Expand Up @@ -578,18 +576,17 @@ Usage: lean data download [OPTIONS]

If --dataset is given the command runs in non-interactive mode. In this mode the CLI does not prompt for input or
confirmation but only halts when the agreement must be accepted. In non-interactive mode all options specific to the
selected dataset as well as --organization are required.
selected dataset are required.

See the following url for the data that can be purchased and downloaded with this command:
https://www.quantconnect.com/datasets

Options:
--dataset TEXT The name of the dataset to download non-interactively
--organization TEXT The name or id of the organization to purchase and download data with
--overwrite Overwrite existing local data
--lean-config FILE The Lean configuration file that should be used (defaults to the nearest lean.json)
--verbose Enable debug logging
--help Show this message and exit.
--dataset TEXT The name of the dataset to download non-interactively
--overwrite Overwrite existing local data
--lean-config FILE The Lean configuration file that should be used (defaults to the nearest lean.json)
--verbose Enable debug logging
--help Show this message and exit.
```

_See code: [lean/commands/data/download.py](lean/commands/data/download.py)_
Expand Down Expand Up @@ -677,6 +674,7 @@ Usage: lean init [OPTIONS]
Scaffold a Lean configuration file and data directory.

Options:
--organization TEXT The name or id of the organization the Lean CLI will be scaffolded for
-l, --language [python|csharp] The default language to use for new projects
--verbose Enable debug logging
--help Show this message and exit.
Expand Down Expand Up @@ -862,7 +860,6 @@ Options:
The data feed to use
--data-provider [Terminal Link|QuantConnect|Local]
Update the Lean configuration file to retrieve data from the given provider
--organization TEXT The name or id of the organization
--ib-user-name TEXT Your Interactive Brokers username
--ib-account TEXT Your Interactive Brokers account id
--ib-password TEXT Your Interactive Brokers password
Expand Down
6 changes: 3 additions & 3 deletions lean/commands/cloud/live/deploy.py
Expand Up @@ -22,7 +22,7 @@
from lean.models.json_module import LiveInitialStateInput
from lean.models.logger import Option
from lean.models.brokerages.cloud.cloud_brokerage import CloudBrokerage
from lean.models.configuration import InternalInputUserInput, OrganzationIdConfiguration
from lean.models.configuration import InternalInputUserInput
from lean.models.click_options import options_from_json
from lean.models.brokerages.cloud import all_cloud_brokerages
from lean.commands.cloud.live.live import live
Expand Down Expand Up @@ -146,7 +146,7 @@ def _configure_notifications(logger: Logger) -> Tuple[bool, bool, List[QCNotific

while True:
_log_notification_methods(notify_methods)
if not click.confirm("Do you want to add another notification method?", default=False):
if not confirm("Do you want to add another notification method?", default=False):
break
notify_methods.append(_prompt_notification_method())

Expand Down Expand Up @@ -244,7 +244,7 @@ def deploy(project: str,
essential_properties_value = {brokerage_instance.convert_variable_to_lean_key(prop) : kwargs[prop] for prop in essential_properties}
brokerage_instance.update_configs(essential_properties_value)
# now required properties can be fetched as per data provider from esssential properties
required_properties = [brokerage_instance.convert_lean_key_to_variable(prop) for prop in brokerage_instance.get_required_properties([OrganzationIdConfiguration, InternalInputUserInput])]
required_properties = [brokerage_instance.convert_lean_key_to_variable(prop) for prop in brokerage_instance.get_required_properties([InternalInputUserInput])]
ensure_options(required_properties)
required_properties_value = {brokerage_instance.convert_variable_to_lean_key(prop) : kwargs[prop] for prop in required_properties}
brokerage_instance.update_configs(required_properties_value)
Expand Down
6 changes: 4 additions & 2 deletions lean/commands/cloud/pull.py
Expand Up @@ -43,10 +43,12 @@ def pull(project: Optional[str], pull_bootcamp: bool) -> None:
projects_to_pull = []
all_projects = None

organization_id = container.organization_manager.try_get_working_organization_id()

if project_id is not None:
projects_to_pull.append(api_client.projects.get(project_id))
projects_to_pull.append(api_client.projects.get(project_id, organization_id))
else:
all_projects = api_client.projects.get_all()
all_projects = api_client.projects.get_all(organization_id)
project_manager = container.project_manager
projects_to_pull = project_manager.get_projects_by_name_or_id(all_projects, project_name)

Expand Down
14 changes: 5 additions & 9 deletions lean/commands/cloud/push.py
Expand Up @@ -23,13 +23,9 @@

@command(cls=LeanCommand)
@option("--project",
type=PathParameter(exists=True, file_okay=False, dir_okay=True),
help="Path to the local project to push (all local projects if not specified)")
@option("--organization-id",
type=str,
help="ID of the organization where the project will be created in. This is ignored if the project has "
"already been created in the cloud")
def push(project: Optional[Path], organization_id: Optional[str]) -> None:
type=PathParameter(exists=True, file_okay=False, dir_okay=True),
help="Path to the local project to push (all local projects if not specified)")
def push(project: Optional[Path]) -> None:
"""Push local projects to QuantConnect.

This command overrides the content of cloud files with the content of their respective local counterparts.
Expand All @@ -45,7 +41,7 @@ def push(project: Optional[Path], organization_id: Optional[str]) -> None:
if not project_config.file.exists():
raise RuntimeError(f"'{project}' is not a Lean project")

push_manager.push_project(project, organization_id)
push_manager.push_project(project)
else:
projects_to_push = [p.parent for p in Path.cwd().rglob(PROJECT_CONFIG_FILE_NAME)]
push_manager.push_projects(projects_to_push, organization_id)
push_manager.push_projects(projects_to_push)
79 changes: 22 additions & 57 deletions lean/commands/data/download.py
Expand Up @@ -153,22 +153,6 @@ def _display_products(organization: QCFullOrganization, products: List[Product])
logger.info(f"Organization balance: {organization.credit.balance:,.0f} QCC")


def _select_organization() -> QCFullOrganization:
"""Asks the user for the organization that should be used.

:return: the selected organization
"""
api_client = container.api_client

organizations = api_client.organizations.get_all()
options = [Option(id=organization.id, label=organization.name) for organization in organizations]

logger = container.logger
organization_id = logger.prompt_list("Select the organization to purchase and download data with", options)

return api_client.organizations.get(organization_id)


def _select_products_interactive(organization: QCFullOrganization, datasets: List[Dataset]) -> List[Product]:
"""Asks the user for the products that should be purchased and downloaded.

Expand Down Expand Up @@ -302,30 +286,16 @@ def _confirm_payment(organization: QCFullOrganization, products: List[Product])
confirm("Continue?", abort=True)


def _get_organization_by_name_or_id(user_input: str) -> QCFullOrganization:
"""Finds an organization by name or id.
def _get_organization() -> QCFullOrganization:
"""Gets the working organization

Raises an error if no organization with a matching name or id can be found.

:param user_input: the input given by the user
:return: the first organization with the given name or id
:return: The working organization in the current Lean CLI folder
"""
from re import match
api_client = container.api_client

if match("^[a-f0-9]{32}$", user_input) is not None:
try:
return api_client.organizations.get(user_input)
except:
pass

all_organizations = api_client.organizations.get_all()
selected_organization = next((o for o in all_organizations if o.id == user_input or o.name == user_input), None)

if selected_organization is None:
raise RuntimeError(f"You are not a member of an organization with name or id '{user_input}'")
organization_manager = container.organization_manager
organization_id = organization_manager.try_get_working_organization_id()

return api_client.organizations.get(selected_organization.id)
api_client = container.api_client
return api_client.organizations.get(organization_id)


def _select_products_non_interactive(organization: QCFullOrganization,
Expand Down Expand Up @@ -418,16 +388,12 @@ def _get_available_datasets(organization: QCFullOrganization) -> List[Dataset]:

return available_datasets


@command(cls=LeanCommand, requires_lean_config=True, allow_unknown_options=True)
@option("--dataset", type=str, help="The name of the dataset to download non-interactively")
@option("--organization", type=str, help="The name or id of the organization to purchase and download data with")
@option("--overwrite", is_flag=True, default=False, help="Overwrite existing local data")
@pass_context
def download(ctx: Context,
dataset: Optional[str],
organization: Optional[str],
overwrite: bool,
**kwargs) -> None:
def download(ctx: Context, dataset: Optional[str], overwrite: bool, **kwargs) -> None:
"""Purchase and download data from QuantConnect Datasets.

An interactive wizard will show to walk you through the process of selecting data,
Expand All @@ -436,29 +402,28 @@ def download(ctx: Context,

If --dataset is given the command runs in non-interactive mode.
In this mode the CLI does not prompt for input or confirmation but only halts when the agreement must be accepted.
In non-interactive mode all options specific to the selected dataset as well as --organization are required.
In non-interactive mode all options specific to the selected dataset are required.

\b
See the following url for the data that can be purchased and downloaded with this command:
https://www.quantconnect.com/datasets
"""
is_interactive = dataset is None and organization is None
organization = _get_organization()

is_interactive = dataset is None
if not is_interactive:
ensure_options(["dataset", "organization"])
selected_organization = _get_organization_by_name_or_id(organization)
datasets = _get_available_datasets(selected_organization)
products = _select_products_non_interactive(selected_organization, datasets, ctx)
ensure_options(["dataset"])
datasets = _get_available_datasets(organization)
products = _select_products_non_interactive(organization, datasets, ctx)
else:
selected_organization = _select_organization()
datasets = _get_available_datasets(selected_organization)
products = _select_products_interactive(selected_organization, datasets)
datasets = _get_available_datasets(organization)
products = _select_products_interactive(organization, datasets)

_confirm_organization_balance(selected_organization, products)
_verify_accept_agreement(selected_organization, is_interactive)
_confirm_organization_balance(organization, products)
_verify_accept_agreement(organization, is_interactive)

if is_interactive:
_confirm_payment(selected_organization, products)
_confirm_payment(organization, products)

all_data_files = _get_data_files(selected_organization, products)
container.data_downloader.download_files(all_data_files, overwrite, selected_organization.id)
all_data_files = _get_data_files(organization, products)
container.data_downloader.download_files(all_data_files, overwrite, organization.id)
4 changes: 3 additions & 1 deletion lean/commands/delete_project.py
Expand Up @@ -27,9 +27,11 @@ def delete_project(project: str) -> None:

The project is selected by name or cloud id.
"""
organization_id = container.organization_manager.try_get_working_organization_id()

# Remove project from cloud
api_client = container.api_client
all_projects = api_client.projects.get_all()
all_projects = api_client.projects.get_all(organization_id)
project_manager = container.project_manager
logger = container.logger

Expand Down
80 changes: 72 additions & 8 deletions lean/commands/init.py
Expand Up @@ -12,13 +12,61 @@
# limitations under the License.

from pathlib import Path
from typing import Optional, Tuple

from click import command, option, Choice, confirm, prompt

from lean.click import LeanCommand
from lean.constants import DEFAULT_DATA_DIRECTORY_NAME, DEFAULT_LEAN_CONFIG_FILE_NAME
from lean.container import container
from lean.models.errors import MoreInfoError
from lean.models.logger import Option


def _get_organization_id(user_input: str) -> Tuple[str, str]:
"""Get the id of a given organization if the user_input is an organization name.

Raises an error if no organization with a matching name or id can be found.

If the user_input is an id (and it exists), it will be returned.

:param user_input: the input given by the user
:return the organization id and name
"""
from re import match
api_client = container.api_client

if match("^[a-f0-9]{32}$", user_input) is not None:
# user input cloud be an id
try:
# We look up the organization to make sure the user is a member of it
organization = api_client.organizations.get(user_input)
return organization.id, organization.name
except:
pass

organizations = api_client.organizations.get_all()
organization = next((o for o in organizations if o.id == user_input or o.name == user_input), None)

if organization is None:
raise RuntimeError(f"You are not a member of an organization with name or id '{user_input}'")

return organization.id, organization.name


def _select_organization() -> Tuple[str, str]:
"""Asks the user for the organization that should be used.

:return: the selected organization id
"""
api_client = container.api_client

organizations = api_client.organizations.get_all()
options = [Option(id=organization.id, label=organization.name) for organization in organizations]

logger = container.logger
organization_id = logger.prompt_list("Select the organization to use for this Lean CLI instance", options)
return organization_id, next(iter(o.name for o in organizations))


def _download_repository(output_path: Path) -> None:
Expand Down Expand Up @@ -69,15 +117,28 @@ def _download_repository(output_path: Path) -> None:


@command(cls=LeanCommand)
@option("--organization", type=str, help="The name or id of the organization the Lean CLI will be scaffolded for")
@option("--language", "-l",
type=Choice(container.cli_config_manager.default_language.allowed_values, case_sensitive=False),
help="The default language to use for new projects")
def init(language: str) -> None:
type=Choice(container.cli_config_manager.default_language.allowed_values, case_sensitive=False),
help="The default language to use for new projects")
def init(organization: Optional[str], language: Optional[str]) -> None:
"""Scaffold a Lean configuration file and data directory."""

from shutil import copytree
from zipfile import ZipFile

# Select and set organization

if organization is not None:
organization_id, organization_name = _get_organization_id(organization)
else:
organization_id, organization_name = _select_organization()

logger = container.logger
logger.info(f'Using selected organization "{organization_name}"')

# Set default language

current_dir = Path.cwd()
data_dir = current_dir / DEFAULT_DATA_DIRECTORY_NAME
lean_config_path = current_dir / DEFAULT_LEAN_CONFIG_FILE_NAME
Expand All @@ -89,8 +150,6 @@ def init(language: str) -> None:
raise MoreInfoError(f"{relative_path} already exists, please run this command in an empty directory",
"https://www.lean.io/docs/v2/lean-cli/initialization/directory-structure#02-lean-init")

logger = container.logger

# Warn the user if the current directory is not empty
if next(current_dir.iterdir(), None) is not None:
logger.info("This command will create a Lean configuration file and data directory in the current directory")
Expand Down Expand Up @@ -119,12 +178,17 @@ def init(language: str) -> None:
with lean_config_path.open("w+", encoding="utf-8") as file:
file.write(config)

# Add the organization id to the lean config
organization_manager = container.organization_manager
organization_manager.configure_working_organization_id(organization_id)

# Prompt for some general configuration if not set yet
cli_config_manager = container.cli_config_manager
if cli_config_manager.default_language.get_value() is None:
default_language = language if language is not None else prompt("What should the default language for new projects be?",
default=cli_config_manager.default_language.default_value,
type=Choice(cli_config_manager.default_language.allowed_values))
default_language = language if language is not None else prompt(
"What should the default language for new projects be?",
default=cli_config_manager.default_language.default_value,
type=Choice(cli_config_manager.default_language.allowed_values))
cli_config_manager.default_language.set_value(default_language)

logger.info(f"""
Expand Down