Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,7 @@ Options:
--image TEXT The LEAN engine image to use (defaults to quantconnect/lean:latest)
--update Pull the LEAN engine image before running the Downloader Data Provider
--no-update Use the local LEAN engine image instead of pulling the latest version
--project INTEGER The cloud project ID to use for brokerage OAuth authentication
--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.
Expand Down
11 changes: 11 additions & 0 deletions lean/commands/data/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ def _replace_data_type(ctx, param, value):
is_flag=True,
default=False,
help="Use the local LEAN engine image instead of pulling the latest version")
@option("--project",
type=int,
help="The cloud project ID to use for brokerage OAuth authentication")
@pass_context
def download(ctx: Context,
data_provider_historical: Optional[str],
Expand All @@ -576,6 +579,7 @@ def download(ctx: Context,
image: Optional[str],
update: bool,
no_update: bool,
project: Optional[int],
**kwargs) -> None:
"""Purchase and download data directly from QuantConnect or download from supported data providers

Expand Down Expand Up @@ -678,6 +682,13 @@ def download(ctx: Context,

engine_image, container_module_version, project_config = container.manage_docker_image(image, update, no_update)

# OAuth downloaders need a real cloud project ID for the Auth0 URL, but `data download`
# runs outside any project. Take it from --project or prompt instead of the -1 the API rejects.
if data_downloader_provider.requires_auth():
lean_config["project-id"] = data_downloader_provider.get_project_id(
project if project is not None else lean_config["project-id"],
require_project_id=True)

data_downloader_provider = config_build_for_name(lean_config, data_downloader_provider.get_name(),
cli_data_downloaders, kwargs, logger, interactive=True)
data_downloader_provider.ensure_module_installed(organization.id, container_module_version)
Expand Down
7 changes: 7 additions & 0 deletions lean/models/json_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ def get_user_name(self, lean_config: Dict[str, Any], configuration, user_provide
lean_config[user_name_key] = user_name
return user_name

def requires_auth(self) -> bool:
"""Returns whether this module uses OAuth (Auth0) authentication.

:return: True if any of the module's configurations is an AuthConfiguration
"""
return any(isinstance(config, AuthConfiguration) for config in self._lean_configs)

def get_project_id(self, default_project_id: int, require_project_id: bool) -> int:
"""Retrieve the project ID, prompting the user if required and default is invalid.

Expand Down
59 changes: 59 additions & 0 deletions tests/commands/data/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,65 @@ def test_download_data_non_interactive_wrong_data_type(wrong_data_type: str):
assert wrong_data_type in error_msg


def _run_oauth_download(extra_run_command: List[str], cli_input: str = "\n"):
"""Runs `lean data download` for an OAuth data provider, returning the project_id passed to Auth0."""
for data_provider in cli_data_downloaders:
data_provider.__setattr__("_specifications_url", "")

create_fake_lean_cli_directory()
# A data download runs outside any project, so the project id is -1
lean_config_path = Path.cwd() / "lean.json"
lean_config_path.write_text(json.dumps({"data-folder": "data", "organization-id": "abc", "project-id": -1}))

container = initialize_container()

captured_project_ids = []

def _fake_get_authorization(auth0_client, brokerage_id, logger, project_id, *args, **kwargs):
captured_project_ids.append(project_id)
auth = MagicMock()
auth.get_authorization_config_without_account.return_value = {}
auth.get_account_ids.return_value = []
return auth

with mock.patch.object(container.lean_runner, "get_basic_docker_config_without_algo",
return_value={"commands": [], "mounts": []}), \
mock.patch.object(container.api_client.data, "download_public_file_json",
return_value=_get_data_provider_config()), \
mock.patch.object(container.api_client.organizations, "get", return_value=create_api_organization()), \
mock.patch("lean.models.json_module.get_authorization", side_effect=_fake_get_authorization):
run_parameters = [
"data", "download",
"--data-provider-historical", "Alpaca",
"--data-type", "Trade",
"--resolution", "Hour",
"--security-type", "Equity",
"--ticker", "AAPL",
"--start", "20240101",
"--end", "20240202",
"--market", "USA",
"--alpaca-environment", "paper",
]
run_parameters += extra_run_command
result = CliRunner().invoke(lean, run_parameters, input=cli_input)

return result, captured_project_ids


def test_download_oauth_provider_uses_provided_project_id():
result, captured_project_ids = _run_oauth_download(["--project", "12345"])

assert result.exit_code == 0
assert captured_project_ids == [12345]


def test_download_oauth_provider_prompts_for_project_id_when_missing():
result, captured_project_ids = _run_oauth_download([], cli_input="54321\n")

assert result.exit_code == 0
assert captured_project_ids == [54321]


def test_non_interactive_bulk_select():
# TODO
pass
Expand Down
4 changes: 2 additions & 2 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ def test_lean_shows_help_when_called_without_arguments() -> None:
result = CliRunner().invoke(lean, [])

assert result.exit_code == 0
assert "Usage: lean [OPTIONS] COMMAND [ARGS]..." in result.output
assert "Usage: lean [OPTIONS]" in result.output


def test_lean_shows_help_when_called_with_help_option() -> None:
result = CliRunner().invoke(lean, ["--help"])

assert result.exit_code == 0
assert "Usage: lean [OPTIONS] COMMAND [ARGS]..." in result.output
assert "Usage: lean [OPTIONS]" in result.output


def test_lean_shows_error_when_running_unknown_command() -> None:
Expand Down
Loading