diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e59473a5..db74534d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,7 +40,7 @@ jobs: # Static analysis tools - name: Static Code Analysis - if: runner.os == 'Linux' && matrix.python-version != '3.8' + if: runner.os == 'Linux' run: | pip install mypy==1.19.1 flake8==7.3.0 python static_analysis.py @@ -48,6 +48,12 @@ jobs: - name: Run tests run: python -m pytest -s -rs + - name: Check README + if: runner.os == 'Linux' + run: | + python scripts/readme.py + git diff --exit-code README.md + release: runs-on: ubuntu-latest diff --git a/lean/click.py b/lean/click.py index 0eb4e86d..128c3040 100644 --- a/lean/click.py +++ b/lean/click.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Optional, List, Callable -from click import Command, Context, Parameter, ParamType, Option as ClickOption +from click import Choice, Command, Context, Parameter, ParamType, Option as ClickOption from click.decorators import FC, option from lean.constants import DEFAULT_LEAN_CONFIG_FILE_NAME, CONTAINER_LABEL_LEAN_VERSION_NAME @@ -305,6 +305,20 @@ def _parse_config_option(self, ctx: Context, param: Parameter, value: Optional[P +class CaseInsensitiveChoice(Choice): + """A click.Choice subclass that matches case-insensitively but displays original casing in help text.""" + + def __init__(self, choices, **kwargs): + super().__init__(choices, case_sensitive=False, **kwargs) + + def get_metavar(self, param, ctx=None) -> str: + import enum + choices_str = "|".join(c.value if isinstance(c, enum.Enum) else str(c) for c in self.choices) + if param is not None and param.required and param.param_type_name == "argument": + return f"{{{choices_str}}}" + return f"[{choices_str}]" + + class PathParameter(ParamType): """A limited version of click.Path which uses pathlib.Path.""" @@ -352,7 +366,7 @@ class DateParameter(ParamType): name = "date" - def get_metavar(self, param: Parameter) -> str: + def get_metavar(self, param: Parameter, ctx=None) -> str: return "[yyyyMMdd]" def convert(self, value: str, param: Parameter, ctx: Context): diff --git a/lean/commands/backtest.py b/lean/commands/backtest.py index 98d6fb3e..bf9a8cac 100644 --- a/lean/commands/backtest.py +++ b/lean/commands/backtest.py @@ -13,9 +13,9 @@ from pathlib import Path from typing import List, Optional, Tuple -from click import command, option, argument, Choice +from click import command, option, argument -from lean.click import LeanCommand, PathParameter, backtest_parameter_option +from lean.click import LeanCommand, PathParameter, backtest_parameter_option, CaseInsensitiveChoice from lean.constants import DEFAULT_ENGINE_IMAGE, LEAN_ROOT_PATH from lean.container import container, Logger from lean.models.utils import DebuggingMethod @@ -234,10 +234,10 @@ def _migrate_csharp_csproj(project_dir: Path) -> None: default=False, help="Run the backtest in a detached Docker container and return immediately") @option("--debug", - type=Choice(["pycharm", "ptvsd", "debugpy", "vsdbg", "rider", "local-platform"], case_sensitive=False), + type=CaseInsensitiveChoice(["pycharm", "ptvsd", "debugpy", "vsdbg", "rider", "local-platform"]), help="Enable a certain debugging method (see --help for more information)") @option("--data-provider-historical", - type=Choice([dp.get_name() for dp in cli_data_downloaders], case_sensitive=False), + type=CaseInsensitiveChoice([dp.get_name() for dp in cli_data_downloaders]), default="Local", help="Update the Lean configuration file to retrieve data from the given historical provider") @options_from_json(get_configs_for_options("backtest")) diff --git a/lean/commands/cloud/live/deploy.py b/lean/commands/cloud/live/deploy.py index 1fee429c..c667ae40 100644 --- a/lean/commands/cloud/live/deploy.py +++ b/lean/commands/cloud/live/deploy.py @@ -12,8 +12,8 @@ # limitations under the License. from typing import List, Tuple, Optional -from click import prompt, option, argument, Choice, confirm -from lean.click import LeanCommand, ensure_options +from click import prompt, option, argument, confirm +from lean.click import LeanCommand, ensure_options, CaseInsensitiveChoice from lean.components.api.api_client import APIClient from lean.components.util.json_modules_handler import non_interactive_config_build_for_name, \ interactive_config_build @@ -166,10 +166,10 @@ def _configure_auto_restart(logger: Logger) -> bool: @live.command(cls=LeanCommand, default_command=True, name="deploy") @argument("project", type=str) @option("--brokerage", - type=Choice([b.get_name() for b in cloud_brokerages], case_sensitive=False), + type=CaseInsensitiveChoice([b.get_name() for b in cloud_brokerages]), help="The brokerage to use") @option("--data-provider-live", - type=Choice([d.get_name() for d in cloud_data_queue_handlers], case_sensitive=False), + type=CaseInsensitiveChoice([d.get_name() for d in cloud_data_queue_handlers]), multiple=True, help="The live data provider to use") @options_from_json(get_configs_for_options("live-cloud")) diff --git a/lean/commands/cloud/optimize.py b/lean/commands/cloud/optimize.py index d66334ae..cc7efeea 100644 --- a/lean/commands/cloud/optimize.py +++ b/lean/commands/cloud/optimize.py @@ -13,9 +13,9 @@ from typing import List, Optional, Tuple -from click import command, option, Choice, argument, confirm +from click import command, option, argument, confirm -from lean.click import LeanCommand, ensure_options +from lean.click import LeanCommand, ensure_options, CaseInsensitiveChoice from lean.components.config.optimizer_config_manager import NodeType, available_nodes from lean.container import container from lean.models.api import QCOptimizationBacktest, QCProject, QCCompileWithLogs, QCFullOrganization @@ -156,7 +156,7 @@ def _display_estimate(cloud_project: QCProject, type=str, help="The target statistic of the optimization") @option("--target-direction", - type=Choice(["min", "max"], case_sensitive=False), + type=CaseInsensitiveChoice(["min", "max"]), help="Whether the target must be minimized or maximized") @option("--parameter", type=(str, float, float, float), @@ -167,7 +167,7 @@ def _display_estimate(cloud_project: QCProject, multiple=True, help="The 'statistic operator value' pairs configuring the constraints of the optimization") @option("--node", - type=Choice([node.name for node in available_nodes], case_sensitive=False), + type=CaseInsensitiveChoice([node.name for node in available_nodes]), help="The node type to run the optimization on") @option("--parallel-nodes", type=int, diff --git a/lean/commands/create_project.py b/lean/commands/create_project.py index 2bd9e9bd..6f2d5d4d 100644 --- a/lean/commands/create_project.py +++ b/lean/commands/create_project.py @@ -12,9 +12,9 @@ # limitations under the License. from pathlib import Path -from click import Choice, option, argument +from click import option, argument -from lean.click import LeanCommand +from lean.click import LeanCommand, CaseInsensitiveChoice from lean.commands import lean from lean.container import container from lean.models.api import QCLanguage @@ -398,7 +398,7 @@ def _not_identifier_char(text): @lean.command(cls=LeanCommand, name="project-create", aliases=["create-project"]) @argument("name", type=str) @option("--language", "-l", - type=Choice(container.cli_config_manager.default_language.allowed_values, case_sensitive=False), + type=CaseInsensitiveChoice(container.cli_config_manager.default_language.allowed_values), help="The language of the project to create") def create_project(name: str, language: str) -> None: """Create a new project containing starter code. diff --git a/lean/commands/data/download.py b/lean/commands/data/download.py index 6b21f402..1657acc2 100644 --- a/lean/commands/data/download.py +++ b/lean/commands/data/download.py @@ -15,8 +15,8 @@ from docker.types import Mount from typing import Any, Dict, Iterable, List, Optional -from click import command, option, confirm, pass_context, Context, Choice, prompt -from lean.click import LeanCommand, ensure_options +from click import command, option, confirm, pass_context, Context, prompt +from lean.click import LeanCommand, ensure_options, CaseInsensitiveChoice from lean.components.util.json_modules_handler import config_build_for_name from lean.constants import DEFAULT_ENGINE_IMAGE from lean.container import container @@ -500,8 +500,8 @@ def _configure_date_option(date_value: str, option_id: str, option_label: str) - return date_option.configure_non_interactive(date_value) -class QCDataTypeCustomChoice(Choice): - def get_metavar(self, param) -> str: +class QCDataTypeCustomChoice(CaseInsensitiveChoice): + def get_metavar(self, param, ctx=None) -> str: choices_str = "|".join(QCDataType.get_all_members_except('Open Interest')) # Use square braces to indicate an option or optional argument. @@ -521,7 +521,7 @@ def _replace_data_type(ctx, param, value): @command(cls=LeanCommand, requires_lean_config=True, allow_unknown_options=True, name="download") @option("--data-provider-historical", - type=Choice([data_downloader.get_name() for data_downloader in cli_data_downloaders], case_sensitive=False), + type=CaseInsensitiveChoice([data_downloader.get_name() for data_downloader in cli_data_downloaders]), help="The name of the downloader data provider.") @options_from_json(get_configs_for_options("download")) @option("--dataset", type=str, help="The name of the dataset to download non-interactively") @@ -530,11 +530,11 @@ def _replace_data_type(ctx, param, value): @option("--yes", "-y", "auto_confirm", is_flag=True, default=False, help="Automatically confirm payment confirmation prompts") @option("--data-type", callback=_replace_data_type, - type=QCDataTypeCustomChoice(QCDataType.get_all_members(), case_sensitive=False), + type=QCDataTypeCustomChoice(QCDataType.get_all_members()), help="Specify the type of historical data") -@option("--resolution", type=Choice(QCResolution.get_all_members(), case_sensitive=False), +@option("--resolution", type=CaseInsensitiveChoice(QCResolution.get_all_members()), help="Specify the resolution of the historical data") -@option("--security-type", type=Choice(QCSecurityType.get_all_members(), case_sensitive=False), +@option("--security-type", type=CaseInsensitiveChoice(QCSecurityType.get_all_members()), help="Specify the security type of the historical data") @option("--market", type=str, help="Specify the market name for tickers (e.g., 'USA', 'NYMEX', 'Binance')" diff --git a/lean/commands/data/generate.py b/lean/commands/data/generate.py index 86a4b66d..3346f4f4 100644 --- a/lean/commands/data/generate.py +++ b/lean/commands/data/generate.py @@ -14,9 +14,9 @@ from datetime import datetime from typing import Optional -from click import command, option, Choice, IntRange +from click import command, option, IntRange -from lean.click import DateParameter, LeanCommand, ensure_options +from lean.click import DateParameter, LeanCommand, ensure_options, CaseInsensitiveChoice from lean.constants import DEFAULT_ENGINE_IMAGE from lean.container import container @@ -39,15 +39,15 @@ default="", help="Comma separated list of tickers to use for generated data") @option("--security-type", - type=Choice(["Equity", "Forex", "Cfd", "Future", "Crypto", "Option"], case_sensitive=False), + type=CaseInsensitiveChoice(["Equity", "Forex", "Cfd", "Future", "Crypto", "Option"]), default="Equity", help="The security type to generate data for (defaults to Equity)") @option("--resolution", - type=Choice(["Tick", "Second", "Minute", "Hour", "Daily"], case_sensitive=False), + type=CaseInsensitiveChoice(["Tick", "Second", "Minute", "Hour", "Daily"]), default="Minute", help="The resolution of the generated data (defaults to Minute)") @option("--data-density", - type=Choice(["Dense", "Sparse", "VerySparse"], case_sensitive=False), + type=CaseInsensitiveChoice(["Dense", "Sparse", "VerySparse"]), default="Dense", help="The density of the generated data (defaults to Dense)") @option("--include-coarse", @@ -98,7 +98,7 @@ help="The stochastic process, and returns new pricing engine to run calculations for that option " "(defaults to BaroneAdesiWhaleyApproximationEngine)") @option("--volatility-model-resolution", - type=Choice(["Tick", "Second", "Minute", "Hour", "Daily"], case_sensitive=False), + type=CaseInsensitiveChoice(["Tick", "Second", "Minute", "Hour", "Daily"]), default="Daily", help="The volatility model period span (defaults to Daily)") @option("--chain-symbol-count", diff --git a/lean/commands/init.py b/lean/commands/init.py index 89dd4a76..461d0dc3 100644 --- a/lean/commands/init.py +++ b/lean/commands/init.py @@ -16,7 +16,7 @@ from click import command, option, Choice, confirm, prompt -from lean.click import LeanCommand +from lean.click import LeanCommand, CaseInsensitiveChoice from lean.commands.login import get_credentials, validate_credentials, get_lean_config_credentials from lean.constants import DEFAULT_DATA_DIRECTORY_NAME, DEFAULT_LEAN_CONFIG_FILE_NAME from lean.container import container @@ -121,7 +121,7 @@ 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), + type=CaseInsensitiveChoice(container.cli_config_manager.default_language.allowed_values), 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.""" diff --git a/lean/commands/live/deploy.py b/lean/commands/live/deploy.py index be7e8867..bd2efd8c 100644 --- a/lean/commands/live/deploy.py +++ b/lean/commands/live/deploy.py @@ -13,8 +13,8 @@ from pathlib import Path from typing import List, Optional, Tuple -from click import option, argument, Choice -from lean.click import LeanCommand, PathParameter +from click import option, argument +from lean.click import LeanCommand, PathParameter, CaseInsensitiveChoice from lean.components.util.name_rename import rename_internal_config_to_user_friendly_format from lean.constants import DEFAULT_ENGINE_IMAGE from lean.container import container @@ -64,14 +64,14 @@ def _get_history_provider_name(data_provider_live_names: [str]) -> [str]: default=False, help="Run the live deployment in a detached Docker container and return immediately") @option("--brokerage", - type=Choice([b.get_name() for b in cli_brokerages], case_sensitive=False), + type=CaseInsensitiveChoice([b.get_name() for b in cli_brokerages]), help="The brokerage to use") @option("--data-provider-live", - type=Choice([d.get_name() for d in cli_data_queue_handlers], case_sensitive=False), + type=CaseInsensitiveChoice([d.get_name() for d in cli_data_queue_handlers]), multiple=True, help="The live data provider to use") @option("--data-provider-historical", - type=Choice([dp.get_name() for dp in cli_data_downloaders if dp.get_id() != "TerminalLinkBrokerage"], case_sensitive=False), + type=CaseInsensitiveChoice([dp.get_name() for dp in cli_data_downloaders if dp.get_id() != "TerminalLinkBrokerage"]), help="Update the Lean configuration file to retrieve data from the given historical provider") @options_from_json(get_configs_for_options("live-cli")) @option("--release", diff --git a/lean/commands/optimize.py b/lean/commands/optimize.py index 3e659082..7e63e67e 100644 --- a/lean/commands/optimize.py +++ b/lean/commands/optimize.py @@ -15,9 +15,9 @@ from typing import Optional, List, Tuple from datetime import datetime, timedelta -from click import command, argument, option, Choice, IntRange +from click import command, argument, option, IntRange -from lean.click import LeanCommand, PathParameter, ensure_options +from lean.click import LeanCommand, PathParameter, ensure_options, CaseInsensitiveChoice from lean.components.docker.lean_runner import LeanRunner from lean.constants import DEFAULT_ENGINE_IMAGE from lean.container import container @@ -81,13 +81,13 @@ def get_filename_timestamp(path: Path) -> datetime: type=PathParameter(exists=True, file_okay=True, dir_okay=False), help=f"The optimizer configuration file that should be used") @option("--strategy", - type=Choice(["Grid Search", "Euler Search"], case_sensitive=False), + type=CaseInsensitiveChoice(["Grid Search", "Euler Search"]), help="The optimization strategy to use") @option("--target", type=str, help="The target statistic of the optimization") @option("--target-direction", - type=Choice(["min", "max"], case_sensitive=False), + type=CaseInsensitiveChoice(["min", "max"]), help="Whether the target must be minimized or maximized") @option("--parameter", type=(str, float, float, float), @@ -98,7 +98,7 @@ def get_filename_timestamp(path: Path) -> datetime: multiple=True, help="The 'statistic operator value' pairs configuring the constraints of the optimization") @option("--data-provider-historical", - type=Choice([dp.get_name() for dp in cli_data_downloaders], case_sensitive=False), + type=CaseInsensitiveChoice([dp.get_name() for dp in cli_data_downloaders]), default="Local", help="Update the Lean configuration file to retrieve data from the given historical provider") @option("--download-data", diff --git a/lean/commands/research.py b/lean/commands/research.py index 018c618c..49480205 100644 --- a/lean/commands/research.py +++ b/lean/commands/research.py @@ -13,8 +13,8 @@ from pathlib import Path from typing import Optional, Tuple -from click import command, argument, option, Choice -from lean.click import LeanCommand, PathParameter +from click import command, argument, option +from lean.click import LeanCommand, PathParameter, CaseInsensitiveChoice from lean.components.docker.lean_runner import LeanRunner from lean.constants import DEFAULT_RESEARCH_IMAGE, LEAN_ROOT_PATH from lean.container import container @@ -38,7 +38,7 @@ def _check_docker_output(chunk: str, port: int) -> None: @argument("project", type=PathParameter(exists=True, file_okay=False, dir_okay=True)) @option("--port", type=int, default=8888, help="The port to run Jupyter Lab on (defaults to 8888)") @option("--data-provider-historical", - type=Choice([dp.get_name() for dp in cli_data_downloaders], case_sensitive=False), + type=CaseInsensitiveChoice([dp.get_name() for dp in cli_data_downloaders]), default="Local", help="Update the Lean configuration file to retrieve data from the given historical provider") @options_from_json(get_configs_for_options("research")) diff --git a/lean/models/click_options.py b/lean/models/click_options.py index 65526334..5531395c 100644 --- a/lean/models/click_options.py +++ b/lean/models/click_options.py @@ -13,8 +13,8 @@ from typing import List, Dict -from click import option, Choice -from lean.click import PathParameter +from click import option +from lean.click import PathParameter, CaseInsensitiveChoice from lean.models.cli import cli_brokerages, cli_data_downloaders, cli_data_queue_handlers from lean.models.cloud import cloud_brokerages, cloud_data_queue_handlers from lean.models.configuration import Configuration, InfoConfiguration, InternalInputUserInput @@ -59,7 +59,7 @@ def get_click_option_type(configuration: Configuration): # Skip validation if no predefined choices in config and user provided input manually if not configuration._choices: return str - return Choice(configuration._choices, case_sensitive=False) + return CaseInsensitiveChoice(configuration._choices) elif configuration._input_method == "prompt": return configuration.get_input_type() elif configuration._input_method == "prompt-password": diff --git a/lean/models/configuration.py b/lean/models/configuration.py index 67b79e05..e7a61e27 100644 --- a/lean/models/configuration.py +++ b/lean/models/configuration.py @@ -13,7 +13,8 @@ from pathlib import Path from typing import Any, Dict, List -from click import prompt, Choice +from click import prompt +from lean.click import CaseInsensitiveChoice from abc import ABC, abstractmethod from lean.components.util.logger import Logger from lean.click import PathParameter @@ -294,7 +295,7 @@ def ask_user_for_input(self, default_value, logger: Logger, hide_input: bool = F return prompt( self._prompt_info, default_value, - type=Choice(self._choices, case_sensitive=False) + type=CaseInsensitiveChoice(self._choices) ) diff --git a/scripts/readme.py b/scripts/readme.py index 61071cde..c6402247 100644 --- a/scripts/readme.py +++ b/scripts/readme.py @@ -24,6 +24,7 @@ from click import Command, Group from click.testing import CliRunner +from pydantic import ConfigDict from lean.commands import lean from lean.models.pydantic import WrappedBaseModel @@ -31,12 +32,11 @@ from lean.container import container class NamedCommand(WrappedBaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + name: str command: Command - class Config: - arbitrary_types_allowed = True - def get_commands(group: Group, parent_names: List[str] = []) -> List[NamedCommand]: """Returns all lean commands by name.