From 3507f038b12ad5e8f8ede30c8794f68f8051a034 Mon Sep 17 00:00:00 2001 From: "Corey M. Francis" Date: Fri, 5 Dec 2025 14:15:39 -0700 Subject: [PATCH 1/2] Fix issues #544 and #364: Add --extra-docker-config-file and --docker-timeout options Issue #544: Fix Windows Argument Parsing for --extra-docker-config - Added --extra-docker-config-file option to all commands that support --extra-docker-config - Created lean/components/util/json_parser.py with robust JSON parsing - Handles Windows shell quote mangling with fallback strategies - Provides helpful error messages for JSON parsing failures - Updated commands: backtest, research, optimize, live/deploy Issue #364: Add Docker Client Timeout Configuration - Added timeout parameter to DockerManager.__init__() with default of 60 seconds - Modified _get_docker_client() to use configurable timeout - Added --docker-timeout option to all relevant commands - Supports DOCKER_CLIENT_TIMEOUT environment variable - Updated container.py to read environment variable on initialization - Updated commands: backtest, research, optimize, live/deploy All changes are backward compatible - existing usage continues to work. Tested with Python 3.12 - all tests pass successfully. --- lean/commands/backtest.py | 25 ++++++- lean/commands/live/deploy.py | 22 +++++- lean/commands/optimize.py | 23 +++++- lean/commands/research.py | 24 +++++- lean/components/docker/docker_manager.py | 11 ++- lean/components/util/json_parser.py | 94 ++++++++++++++++++++++++ lean/container.py | 5 +- 7 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 lean/components/util/json_parser.py diff --git a/lean/commands/backtest.py b/lean/commands/backtest.py index 98d6fb3e..b5c6f133 100644 --- a/lean/commands/backtest.py +++ b/lean/commands/backtest.py @@ -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). " + "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, @@ -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: @@ -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 `. """ 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 project_manager = container.project_manager algorithm_file = project_manager.find_algorithm_file(Path(project)) lean_config_manager = container.lean_config_manager @@ -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, @@ -411,5 +432,5 @@ def backtest(project: Path, debugging_method, release, detach, - loads(extra_docker_config), + parsed_extra_docker_config, paths_to_mount) diff --git a/lean/commands/live/deploy.py b/lean/commands/live/deploy.py index de7576ed..f9cce4eb 100644 --- a/lean/commands/live/deploy.py +++ b/lean/commands/live/deploy.py @@ -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, @@ -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. @@ -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 project_manager = container.project_manager algorithm_file = project_manager.find_algorithm_file(Path(project)) @@ -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) diff --git a/lean/commands/optimize.py b/lean/commands/optimize.py index 8b686bee..d172c410 100644 --- a/lean/commands/optimize.py +++ b/lean/commands/optimize.py @@ -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, @@ -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. @@ -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 + should_detach = detach and not estimate environment_name = "backtesting" project_manager = container.project_manager @@ -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") diff --git a/lean/commands/research.py b/lean/commands/research.py index 018c618c..e3c345f4 100644 --- a/lean/commands/research.py +++ b/lean/commands/research.py @@ -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, @@ -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. @@ -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 project_manager = container.project_manager algorithm_file = project_manager.find_algorithm_file(project, not_throw = True) @@ -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) diff --git a/lean/components/docker/docker_manager.py b/lean/components/docker/docker_manager.py index dbb95391..38c0b744 100644 --- a/lean/components/docker/docker_manager.py +++ b/lean/components/docker/docker_manager.py @@ -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) @@ -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)) + + docker_client = from_env(timeout=timeout) except Exception: raise error diff --git a/lean/components/util/json_parser.py b/lean/components/util/json_parser.py new file mode 100644 index 00000000..4c2f1a43 --- /dev/null +++ b/lean/components/util/json_parser.py @@ -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 + ] + + 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 + """ + 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 {} \ No newline at end of file diff --git a/lean/container.py b/lean/container.py index bc2df0b6..dd752b7e 100644 --- a/lean/container.py +++ b/lean/container.py @@ -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)) + self.docker_manager = DockerManager(self.logger, self.temp_manager, self.platform_manager, timeout) self.project_manager = ProjectManager(self.logger, self.project_config_manager, From 336d386709b4e40f79366d8b6fe271ac34673dd3 Mon Sep 17 00:00:00 2001 From: "Corey M. Francis" Date: Fri, 5 Dec 2025 14:29:57 -0700 Subject: [PATCH 2/2] Address PR review feedback: Fix encapsulation, error handling, and code quality - Remove broken JSON parsing fallback logic that produced malformed JSON - Add set_timeout() method to DockerManager for proper encapsulation - Fix docstring inconsistency in load_json_from_file_or_string() - Add error handling for invalid DOCKER_CLIENT_TIMEOUT environment variable - Make help text consistent across all commands - Document precedence when both json_file and json_string are provided - Fix formatting consistency (add blank lines) --- lean/commands/backtest.py | 5 +- lean/commands/live/deploy.py | 4 +- lean/commands/optimize.py | 2 +- lean/commands/research.py | 4 +- lean/components/docker/docker_manager.py | 13 +- lean/components/util/json_parser.py | 188 +++++++++++------------ 6 files changed, 114 insertions(+), 102 deletions(-) diff --git a/lean/commands/backtest.py b/lean/commands/backtest.py index b5c6f133..40ba8eba 100644 --- a/lean/commands/backtest.py +++ b/lean/commands/backtest.py @@ -284,7 +284,7 @@ def _migrate_csharp_csproj(project_dir: Path) -> None: "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). " + 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", @@ -333,7 +333,8 @@ def backtest(project: Path, # Set Docker timeout if specified if docker_timeout is not None: - container.docker_manager._timeout = docker_timeout + container.docker_manager.set_timeout(docker_timeout) + project_manager = container.project_manager algorithm_file = project_manager.find_algorithm_file(Path(project)) lean_config_manager = container.lean_config_manager diff --git a/lean/commands/live/deploy.py b/lean/commands/live/deploy.py index f9cce4eb..788a375d 100644 --- a/lean/commands/live/deploy.py +++ b/lean/commands/live/deploy.py @@ -172,8 +172,8 @@ def deploy(project: Path, # Set Docker timeout if specified if docker_timeout is not None: - container.docker_manager._timeout = docker_timeout - + container.docker_manager.set_timeout(docker_timeout) + project_manager = container.project_manager algorithm_file = project_manager.find_algorithm_file(Path(project)) diff --git a/lean/commands/optimize.py b/lean/commands/optimize.py index d172c410..bb5680bc 100644 --- a/lean/commands/optimize.py +++ b/lean/commands/optimize.py @@ -223,7 +223,7 @@ def optimize(project: Path, # Set Docker timeout if specified if docker_timeout is not None: - container.docker_manager._timeout = docker_timeout + container.docker_manager.set_timeout(docker_timeout) should_detach = detach and not estimate environment_name = "backtesting" diff --git a/lean/commands/research.py b/lean/commands/research.py index e3c345f4..9242dfc6 100644 --- a/lean/commands/research.py +++ b/lean/commands/research.py @@ -113,8 +113,8 @@ def research(project: Path, # Set Docker timeout if specified if docker_timeout is not None: - container.docker_manager._timeout = docker_timeout - + container.docker_manager.set_timeout(docker_timeout) + project_manager = container.project_manager algorithm_file = project_manager.find_algorithm_file(project, not_throw = True) diff --git a/lean/components/docker/docker_manager.py b/lean/components/docker/docker_manager.py index 38c0b744..ff948cf4 100644 --- a/lean/components/docker/docker_manager.py +++ b/lean/components/docker/docker_manager.py @@ -40,6 +40,13 @@ def __init__(self, logger: Logger, temp_manager: TempManager, platform_manager: self._platform_manager = platform_manager self._timeout = timeout + def set_timeout(self, timeout: int) -> None: + """Set the timeout for Docker client operations. + + :param timeout: The timeout in seconds for Docker client operations + """ + self._timeout = timeout + def get_image_labels(self, image: str) -> str: docker_image = self._get_docker_client().images.get(image) return docker_image.labels.items() @@ -575,7 +582,11 @@ def _get_docker_client(self): from os import environ # Check for environment variable override - timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", self._timeout)) + try: + timeout = int(environ.get("DOCKER_CLIENT_TIMEOUT", self._timeout)) + except ValueError: + # Fall back to instance timeout on invalid value + timeout = self._timeout docker_client = from_env(timeout=timeout) except Exception: diff --git a/lean/components/util/json_parser.py b/lean/components/util/json_parser.py index 4c2f1a43..05c7268e 100644 --- a/lean/components/util/json_parser.py +++ b/lean/components/util/json_parser.py @@ -1,94 +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 - ] - - 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 - """ - 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 {} \ No newline at end of file +# 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 + # Try single quotes to double quotes (common Windows PowerShell issue) + try: + return loads(json_string.replace("'", '"')) + except JSONDecodeError: + pass + + # 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. + + If both json_file and json_string are provided, json_file takes precedence. + + :param json_string: JSON string to parse (optional) + :param json_file: Path to JSON file (optional) + :return: Parsed dictionary, or empty dict if both parameters are None + :raises ValueError: If parsing fails or if file doesn't exist + """ + # Validate that both parameters aren't provided (though we allow it, file takes precedence) + if json_file is not None and json_string is not None: + # Log a warning would be ideal, but we'll prioritize file as documented + pass + + 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 {}