Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 23 additions & 2 deletions lean/commands/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,15 @@ def _migrate_csharp_csproj(project_dir: Path) -> None:
default="{}",
help="Extra docker configuration as a JSON string. "
"For more information https://docker-py.readthedocs.io/en/stable/containers.html")
@option("--extra-docker-config-file",
type=PathParameter(exists=True, file_okay=True, dir_okay=False),
help="Path to a JSON file with extra docker configuration. "
"This is recommended over --extra-docker-config on Windows to avoid shell quote issues.")
@option("--docker-timeout",
type=int,
help="Docker client timeout in seconds (default: 60). "
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The help text for --docker-timeout is inconsistent across commands. This command uses "Docker client timeout in seconds" while other commands (research.py, optimize.py, deploy.py) use "Timeout in seconds for Docker operations".

For consistency and clarity, use the same wording across all commands:

help="Timeout in seconds for Docker operations (default: 60). "
Suggested change
help="Docker client timeout in seconds (default: 60). "
help="Timeout in seconds for Docker operations (default: 60). "

Copilot uses AI. Check for mistakes.
"Increase this for slow connections or large image pulls. "
"Can also be set via DOCKER_CLIENT_TIMEOUT environment variable.")
@option("--no-update",
is_flag=True,
default=False,
Expand All @@ -298,6 +307,8 @@ def backtest(project: Path,
addon_module: Optional[List[str]],
extra_config: Optional[Tuple[str, str]],
extra_docker_config: Optional[str],
extra_docker_config_file: Optional[Path],
docker_timeout: Optional[int],
no_update: bool,
parameter: List[Tuple[str, str]],
**kwargs) -> None:
Expand All @@ -316,9 +327,13 @@ def backtest(project: Path,
Alternatively you can set the default engine image for all commands using `lean config set engine-image <image>`.
"""
from datetime import datetime
from json import loads
from lean.components.util.json_parser import load_json_from_file_or_string

logger = container.logger

# Set Docker timeout if specified
if docker_timeout is not None:
container.docker_manager._timeout = docker_timeout
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directly accessing the private attribute _timeout breaks encapsulation. Consider adding a public setter method to DockerManager instead:

# In DockerManager class:
def set_timeout(self, timeout: int) -> None:
    """Set the timeout for Docker client operations."""
    self._timeout = timeout

Then use: container.docker_manager.set_timeout(docker_timeout)

Suggested change
container.docker_manager._timeout = docker_timeout
container.docker_manager.set_timeout(docker_timeout)

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Missing blank line after the timeout setting block for consistency. Other commands (research.py, optimize.py, deploy.py) all have a blank line after this block. Add a blank line after line 336 for consistency.

Suggested change
container.docker_manager._timeout = docker_timeout
container.docker_manager._timeout = docker_timeout

Copilot uses AI. Check for mistakes.
project_manager = container.project_manager
algorithm_file = project_manager.find_algorithm_file(Path(project))
lean_config_manager = container.lean_config_manager
Expand Down Expand Up @@ -402,6 +417,12 @@ def backtest(project: Path,
# Override existing parameters if any are provided via --parameter
lean_config["parameters"] = lean_config_manager.get_parameters(parameter)

# Parse extra Docker configuration from string or file
parsed_extra_docker_config = load_json_from_file_or_string(
json_string=extra_docker_config if extra_docker_config != "{}" else None,
json_file=extra_docker_config_file
)

lean_runner = container.lean_runner
lean_runner.run_lean(lean_config,
environment_name,
Expand All @@ -411,5 +432,5 @@ def backtest(project: Path,
debugging_method,
release,
detach,
loads(extra_docker_config),
parsed_extra_docker_config,
paths_to_mount)
22 changes: 20 additions & 2 deletions lean/commands/live/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ def _get_history_provider_name(data_provider_live_names: [str]) -> [str]:
default="{}",
help="Extra docker configuration as a JSON string. "
"For more information https://docker-py.readthedocs.io/en/stable/containers.html")
@option("--extra-docker-config-file",
type=PathParameter(exists=True, file_okay=True, dir_okay=False),
help="Path to a JSON file with extra docker configuration. "
"This is recommended over --extra-docker-config on Windows to avoid shell quote issues.")
@option("--docker-timeout",
type=int,
help="Timeout in seconds for Docker operations (default: 60). "
"Increase this for slow connections or large image pulls. "
"Can also be set via DOCKER_CLIENT_TIMEOUT environment variable.")
@option("--no-update",
is_flag=True,
default=False,
Expand All @@ -131,6 +140,8 @@ def deploy(project: Path,
addon_module: Optional[List[str]],
extra_config: Optional[Tuple[str, str]],
extra_docker_config: Optional[str],
extra_docker_config_file: Optional[Path],
docker_timeout: Optional[int],
no_update: bool,
**kwargs) -> None:
"""Start live trading a project locally using Docker.
Expand All @@ -155,9 +166,13 @@ def deploy(project: Path,
"""
from copy import copy
from datetime import datetime
from json import loads
from lean.components.util.json_parser import load_json_from_file_or_string

logger = container.logger

# Set Docker timeout if specified
if docker_timeout is not None:
container.docker_manager._timeout = docker_timeout
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directly accessing the private attribute _timeout breaks encapsulation. Consider adding a public setter method to DockerManager instead:

# In DockerManager class:
def set_timeout(self, timeout: int) -> None:
    """Set the timeout for Docker client operations."""
    self._timeout = timeout

Then use: container.docker_manager.set_timeout(docker_timeout)

Suggested change
container.docker_manager._timeout = docker_timeout
container.docker_manager.set_timeout(docker_timeout)

Copilot uses AI. Check for mistakes.

project_manager = container.project_manager
algorithm_file = project_manager.find_algorithm_file(Path(project))
Expand Down Expand Up @@ -323,5 +338,8 @@ def deploy(project: Path,
None,
release,
detach,
loads(extra_docker_config),
load_json_from_file_or_string(
json_string=extra_docker_config if extra_docker_config != "{}" else None,
json_file=extra_docker_config_file
),
paths_to_mount)
23 changes: 22 additions & 1 deletion lean/commands/optimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ def get_filename_timestamp(path: Path) -> datetime:
default="{}",
help="Extra docker configuration as a JSON string. "
"For more information https://docker-py.readthedocs.io/en/stable/containers.html")
@option("--extra-docker-config-file",
type=PathParameter(exists=True, file_okay=True, dir_okay=False),
help="Path to a JSON file with extra docker configuration. "
"This is recommended over --extra-docker-config on Windows to avoid shell quote issues.")
@option("--docker-timeout",
type=int,
help="Timeout in seconds for Docker operations (default: 60). "
"Increase this for slow connections or large image pulls. "
"Can also be set via DOCKER_CLIENT_TIMEOUT environment variable.")
@option("--no-update",
is_flag=True,
default=False,
Expand All @@ -164,6 +173,8 @@ def optimize(project: Path,
addon_module: Optional[List[str]],
extra_config: Optional[Tuple[str, str]],
extra_docker_config: Optional[str],
extra_docker_config_file: Optional[Path],
docker_timeout: Optional[int],
no_update: bool,
**kwargs) -> None:
"""Optimize a project's parameters locally using Docker.
Expand Down Expand Up @@ -207,8 +218,13 @@ def optimize(project: Path,
from docker.types import Mount
from re import findall, search
from os import cpu_count
from lean.components.util.json_parser import load_json_from_file_or_string
from math import floor

# Set Docker timeout if specified
if docker_timeout is not None:
container.docker_manager._timeout = docker_timeout
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directly accessing the private attribute _timeout breaks encapsulation. Consider adding a public setter method to DockerManager instead:

# In DockerManager class:
def set_timeout(self, timeout: int) -> None:
    """Set the timeout for Docker client operations."""
    self._timeout = timeout

Then use: container.docker_manager.set_timeout(docker_timeout)

Suggested change
container.docker_manager._timeout = docker_timeout
container.docker_manager.set_timeout(docker_timeout)

Copilot uses AI. Check for mistakes.

should_detach = detach and not estimate
environment_name = "backtesting"
project_manager = container.project_manager
Expand Down Expand Up @@ -343,7 +359,12 @@ def optimize(project: Path,
)

# Add known additional run options from the extra docker config
LeanRunner.parse_extra_docker_config(run_options, loads(extra_docker_config))
# Parse extra Docker configuration from string or file
parsed_extra_docker_config = load_json_from_file_or_string(
json_string=extra_docker_config if extra_docker_config != "{}" else None,
json_file=extra_docker_config_file
)
LeanRunner.parse_extra_docker_config(run_options, parsed_extra_docker_config)

project_manager.copy_code(algorithm_file.parent, output / "code")

Expand Down
24 changes: 22 additions & 2 deletions lean/commands/research.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ def _check_docker_output(chunk: str, port: int) -> None:
default="{}",
help="Extra docker configuration as a JSON string. "
"For more information https://docker-py.readthedocs.io/en/stable/containers.html")
@option("--extra-docker-config-file",
type=PathParameter(exists=True, file_okay=True, dir_okay=False),
help="Path to a JSON file with extra docker configuration. "
"This is recommended over --extra-docker-config on Windows to avoid shell quote issues.")
@option("--docker-timeout",
type=int,
help="Timeout in seconds for Docker operations (default: 60). "
"Increase this for slow connections or large image pulls. "
"Can also be set via DOCKER_CLIENT_TIMEOUT environment variable.")
@option("--no-update",
is_flag=True,
default=False,
Expand All @@ -86,6 +95,8 @@ def research(project: Path,
update: bool,
extra_config: Optional[Tuple[str, str]],
extra_docker_config: Optional[str],
extra_docker_config_file: Optional[Path],
docker_timeout: Optional[int],
no_update: bool,
**kwargs) -> None:
"""Run a Jupyter Lab environment locally using Docker.
Expand All @@ -96,9 +107,13 @@ def research(project: Path,
"""
from docker.types import Mount
from docker.errors import APIError
from json import loads
from lean.components.util.json_parser import load_json_from_file_or_string

logger = container.logger

# Set Docker timeout if specified
if docker_timeout is not None:
container.docker_manager._timeout = docker_timeout
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directly accessing the private attribute _timeout breaks encapsulation. Consider adding a public setter method to DockerManager instead:

# In DockerManager class:
def set_timeout(self, timeout: int) -> None:
    """Set the timeout for Docker client operations."""
    self._timeout = timeout

Then use: container.docker_manager.set_timeout(docker_timeout)

Suggested change
container.docker_manager._timeout = docker_timeout
container.docker_manager.set_timeout(docker_timeout)

Copilot uses AI. Check for mistakes.

project_manager = container.project_manager
algorithm_file = project_manager.find_algorithm_file(project, not_throw = True)
Expand Down Expand Up @@ -195,7 +210,12 @@ def research(project: Path,
run_options["commands"].append("./start.sh")

# Add known additional run options from the extra docker config
LeanRunner.parse_extra_docker_config(run_options, loads(extra_docker_config))
# Parse extra Docker configuration from string or file
parsed_extra_docker_config = load_json_from_file_or_string(
json_string=extra_docker_config if extra_docker_config != "{}" else None,
json_file=extra_docker_config_file
)
LeanRunner.parse_extra_docker_config(run_options, parsed_extra_docker_config)

try:
container.docker_manager.run_image(research_image, **run_options)
Expand Down
11 changes: 9 additions & 2 deletions lean/components/docker/docker_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,18 @@
class DockerManager:
"""The DockerManager contains methods to manage and run Docker images."""

def __init__(self, logger: Logger, temp_manager: TempManager, platform_manager: PlatformManager) -> None:
def __init__(self, logger: Logger, temp_manager: TempManager, platform_manager: PlatformManager, timeout: int = 60) -> None:
"""Creates a new DockerManager instance.

:param logger: the logger to use when printing messages
:param temp_manager: the TempManager instance used when creating temporary directories
:param platform_manager: the PlatformManager used when checking which operating system is in use
:param timeout: the timeout in seconds for Docker client operations (default: 60)
"""
self._logger = logger
self._temp_manager = temp_manager
self._platform_manager = platform_manager
self._timeout = timeout

def get_image_labels(self, image: str) -> str:
docker_image = self._get_docker_client().images.get(image)
Expand Down Expand Up @@ -570,7 +572,12 @@ def _get_docker_client(self):

try:
from docker import from_env
docker_client = from_env()
from os import environ

# Check for environment variable override
timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", self._timeout))
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for invalid DOCKER_CLIENT_TIMEOUT environment variable values. If the environment variable contains a non-integer value, int() will raise a ValueError that will be caught by the generic exception handler at line 581, resulting in a misleading "Docker is not running" error message.

Add explicit error handling:

try:
    timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", self._timeout))
except ValueError:
    timeout = self._timeout  # Fall back to instance timeout on invalid value
Suggested change
timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", self._timeout))
try:
timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", self._timeout))
except ValueError:
timeout = self._timeout # Fall back to instance timeout on invalid value

Copilot uses AI. Check for mistakes.

docker_client = from_env(timeout=timeout)
except Exception:
raise error

Expand Down
94 changes: 94 additions & 0 deletions lean/components/util/json_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# 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 Dict, Any, Optional
from json import loads, JSONDecodeError


def parse_json_safely(json_string: str) -> Dict[str, Any]:
"""
Attempts to parse a JSON string with multiple fallback strategies.
This function is designed to handle JSON strings that may have been
mangled by Windows shells (PowerShell/CMD) which strip or escape quotes.
:param json_string: The JSON string to parse
:return: Parsed dictionary
:raises ValueError: If all parsing attempts fail
"""
if not json_string or json_string.strip() == "":
return {}

# Try standard JSON parsing first
try:
return loads(json_string)
except JSONDecodeError as e:
original_error = str(e)

# Try fixing common Windows shell issues
# 1. Try adding back quotes that may have been stripped
attempts = [
json_string,
json_string.replace("'", '"'), # Single quotes to double quotes
'{"' + json_string.strip('{}').replace(':', '":').replace(',', ',"') + '}', # Add missing quotes
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This string manipulation logic is broken and will produce malformed JSON in many cases. For example, with input key1:value1,key2:value2, this will produce {"key1":value1","key2":"value2"} (extra quote after value1). The logic incorrectly assumes every colon and comma needs quote handling.

This fallback strategy should be removed or completely reimplemented with proper parsing logic. Consider using a more targeted approach that only handles specific known Windows shell issues rather than attempting generic quote repair.

Suggested change
'{"' + json_string.strip('{}').replace(':', '":').replace(',', ',"') + '}', # Add missing quotes

Copilot uses AI. Check for mistakes.
]

for attempt in attempts[1:]: # Skip first as we already tried it
try:
return loads(attempt)
except JSONDecodeError:
continue

# If all attempts fail, provide helpful error message
raise ValueError(
f"Failed to parse JSON configuration. Original error: {original_error}\n"
f"Input: {json_string}\n\n"
f"On Windows, JSON strings may be mangled by the shell. Consider using --extra-docker-config-file instead.\n"
f"Example: Create a file 'docker-config.json' with your configuration and use:\n"
f" --extra-docker-config-file docker-config.json"
)


def load_json_from_file_or_string(
json_string: Optional[str] = None,
json_file: Optional[Path] = None
) -> Dict[str, Any]:
"""
Loads JSON configuration from either a string or a file.
:param json_string: JSON string to parse (optional)
:param json_file: Path to JSON file (optional)
:return: Parsed dictionary
:raises ValueError: If both parameters are None or if parsing fails
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring states "raises ValueError: If both parameters are None or if parsing fails", but the function returns an empty dict {} when both parameters are None (line 94) instead of raising ValueError. This is inconsistent with the documented behavior.

Either update the implementation to raise an error:

if json_string is None and json_file is None:
    raise ValueError("Either json_string or json_file must be provided")
return {}

Or update the docstring to accurately reflect the current behavior:

:return: Parsed dictionary, or empty dict if both parameters are None

Copilot uses AI. Check for mistakes.
"""
if json_file is not None:
if not json_file.exists():
raise ValueError(f"Configuration file not found: {json_file}")

try:
with open(json_file, 'r', encoding='utf-8') as f:
content = f.read()
return loads(content)
except JSONDecodeError as e:
raise ValueError(
f"Failed to parse JSON from file {json_file}: {e}\n"
f"Please ensure the file contains valid JSON."
)
except Exception as e:
raise ValueError(f"Failed to read file {json_file}: {e}")

if json_string is not None:
return parse_json_safely(json_string)

return {}
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function silently prioritizes json_file over json_string when both are provided, which could confuse users. Consider adding validation to prevent both parameters from being specified simultaneously, or at least logging a warning.

if json_file is not None and json_string is not None:
    raise ValueError("Cannot specify both json_string and json_file. Please use only one.")

Alternatively, update the docstring to clearly document this precedence behavior.

Copilot uses AI. Check for mistakes.
5 changes: 4 additions & 1 deletion lean/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ def initialize(self,

self.docker_manager = docker_manager
if not self.docker_manager:
self.docker_manager = DockerManager(self.logger, self.temp_manager, self.platform_manager)
from os import environ
# Get timeout from environment variable, default to 60 seconds
timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", 60))
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling for invalid DOCKER_CLIENT_TIMEOUT environment variable values. If the environment variable contains a non-integer value, int() will raise a ValueError that isn't caught, causing the container initialization to fail with an unclear error.

Add error handling:

try:
    timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", 60))
except ValueError:
    timeout = 60  # Fall back to default on invalid value
Suggested change
timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", 60))
try:
timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", 60))
except ValueError:
timeout = 60 # Fall back to default on invalid value

Copilot uses AI. Check for mistakes.
self.docker_manager = DockerManager(self.logger, self.temp_manager, self.platform_manager, timeout)

self.project_manager = ProjectManager(self.logger,
self.project_config_manager,
Expand Down