Repository: QuantConnect/lean-cli (python)

Issue:
lean init Error: User not found: None
Hello and thanks for the great tool!

When attempting to follow the instructions from the home page https://www.lean.io/cli/ on a fresh install I get

```
> lean init --verbose
Context information:
  Python version: 3.11.6 (tags/v3.11.6:8b6ee5b, Oct  2 2023, 14:57:12) [MSC v.1935 64 bit (AMD64)]
  OS: Windows-10-10.0.22631-SP0
  Lean CLI version: 1.0.199
  .NET version:
  VS Code version: 1.91.1-f1e16e1e6214d7c44d078b1f0607b2388f29d729-x64
  VS Code installed versions: ckolkman.vscode-postgres@1.4.3
                              dbaeumer.vscode-eslint@3.0.10
                              equinusocio.vsc-material-theme@34.4.0
                              equinusocio.vsc-material-theme-icons@3.6.1
                              jakebecker.elixir-ls@0.23.0
                              ms-python.debugpy@2024.8.0
                              ms-python.python@2024.12.2
                              ms-python.vscode-pylance@2024.8.1
                              ms-toolsai.jupyter@2024.6.0
                              ms-toolsai.jupyter-keymap@1.1.2
                              ms-toolsai.jupyter-renderers@1.0.19
                              ms-toolsai.vscode-jupyter-cell-tags@0.1.9
                              ms-toolsai.vscode-jupyter-slideshow@0.1.6
                              ms-vscode-remote.remote-wsl@0.88.2
                              phoenixframework.phoenix@0.1.2
                              rust-lang.rust-analyzer@0.3.2062
                              tamasfe.even-better-toml@0.19.2
                              vue.volar@1.8.27
--> POST https://www.quantconnect.com/api/v2/organizations/list
Request response: {"errors":["User not found: None"],"success":false}
Request was not successful, status code 200, body:
{"errors":["User not found: None"],"success":false}
Traceback (most recent call last):
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\lean\main.py", line 94, in main
    lean.main(standalone_mode=False)
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\click\core.py", line 1078, in main
    rv = self.invoke(ctx)
         ^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\click\core.py", line 1688, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\lean\click.py", line 190, in invoke
    result = super().invoke(ctx)
             ^^^^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\click\core.py", line 1434, in invoke
    return ctx.invoke(self.callback, **ctx.params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\click\core.py", line 783, in invoke
    return __callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\lean\commands\init.py", line 136, in init
    organization_id, organization_name = _select_organization()
                                         ^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\lean\commands\init.py", line 65, in _select_organization
    organizations = api_client.organizations.get_all()
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\lean\components\api\organization_client.py", line 49, in get_all
    data = self._api.post("organizations/list")
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\lean\components\api\api_client.py", line 103, in post
    return self._request("post", endpoint, options)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\lean\components\api\api_client.py", line 166, in _request
    return self._parse_response(response)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\FooUser\AppData\Local\Programs\Python\Python311\Lib\site-packages\lean\components\api\api_client.py", line 190, in _parse_response
    raise RequestFailedError(response, "\n".join(data["errors"]))
lean.models.errors.RequestFailedError: User not found: None
Error: User not found: None
```

This seems to be tied to me not having a user/being logged in to QC.

I did a bit of digging and found this was introduced here https://github.com/QuantConnect/lean-cli/commit/b0604527a4ebc61bc8f049bc32073e8e6a0123b8

I assume this isn't intended, as from what I can tell non cloud based commands are supposed to work fine. I've worked around it on a local fork and haven't run into any unexpected behaviour yet.

Pull request:
Feature: Login process in Initialization of lean folder
### Description
This PR introduces enhancements to the Lean CLI authentication process. Specifically, it modifies the `lean init` command to prompt users for authentication if their credentials are not available or invalid. Instead of throwing an exception when no user is found, the system now initiates the login process and stores the credentials in the Lean configuration file.

Key changes include:

1. **Retrieve Credentials**: The `get_lean_config_credentials` function is used to fetch existing credentials from the Lean configuration.
2. **Prompt for Login**: If credentials are missing or invalid, the `get_credentials` function is called to prompt the user for their user ID and API token.
3. **Validate Credentials**: The `validate_credentials` function ensures that the provided credentials are valid and correctly authenticated.
4. **Enhanced Error Handling**: The system now guides users through the login process instead of throwing an error, improving user experience and ensuring proper credential storage in the Lean configuration.

### Related Issue
Closes #483 

### Motivation and Context
Previously, if the `lean init` command was run without valid credentials, the system would raise an exception with the message Error: User not found: None, which was not user-friendly and could be confusing.

This update improves the user experience by:

- Automatically prompting for login credentials if they are not found.
- Storing valid credentials in the Lean configuration to avoid repeated logins.
- Providing clear guidance to users on obtaining and configuring their credentials.

By integrating these changes, we aim to streamline the setup process and reduce friction for users getting started with the Lean CLI.

### How Has This Been Tested?
#### Manual Testing
- Ran the `lean login` command to ensure that the login process is functioning as expected and has not introduced any issues.
- Executed the `lean init` command to verify that it correctly prompts for login credentials when they are missing or invalid, and that it successfully initiates the login process.

![image](https://github.com/user-attachments/assets/7b1c912d-7a6d-4b48-a0a9-ab6ad6cc3553)


Patch:
diff --git a/README.md b/README.md
index 8df023e..42bb67d 100644
--- a/README.md
+++ b/README.md
@@ -176,7 +176,7 @@ Options:
   --iqfeed-username TEXT          Your IQFeed username
   --iqfeed-password TEXT          Your IQFeed password
   --iqfeed-version TEXT           The product version of your IQFeed developer account
-  --iqfeed-host TEXT              The IQFeed host address
+  --iqfeed-host TEXT              The IQFeed host address (Optional).
   --polygon-api-key TEXT          Your Polygon.io API Key
   --factset-auth-config-file FILE
                                   The path to the FactSet authentication configuration file
@@ -207,8 +207,6 @@ Options:
                                   The Open FIGI API key to use for mapping options
   --bybit-api-key TEXT            Your Bybit API key
   --bybit-api-secret TEXT         Your Bybit API secret
-  --bybit-vip-level [VIP0|VIP1|VIP2|VIP3|VIP4|VIP5|SupremeVIP|Pro1|Pro2|Pro3|Pro4|Pro5]
-                                  Your Bybit VIP Level
   --trade-station-environment [live|paper]
                                   Whether Live or Paper environment should be used
   --trade-station-account-type [Cash|Margin|Futures|DVP]
@@ -859,7 +857,7 @@ Options:
   --iqfeed-username TEXT          Your IQFeed username
   --iqfeed-password TEXT          Your IQFeed password
   --iqfeed-version TEXT           The product version of your IQFeed developer account
-  --iqfeed-host TEXT              The IQFeed host address
+  --iqfeed-host TEXT              The IQFeed host address (Optional).
   --polygon-api-key TEXT          Your Polygon.io API Key
   --factset-auth-config-file FILE
                                   The path to the FactSet authentication configuration file
@@ -890,8 +888,6 @@ Options:
                                   The Open FIGI API key to use for mapping options
   --bybit-api-key TEXT            Your Bybit API key
   --bybit-api-secret TEXT         Your Bybit API secret
-  --bybit-vip-level [VIP0|VIP1|VIP2|VIP3|VIP4|VIP5|SupremeVIP|Pro1|Pro2|Pro3|Pro4|Pro5]
-                                  Your Bybit VIP Level
   --trade-station-environment [live|paper]
                                   Whether Live or Paper environment should be used
   --trade-station-account-type [Cash|Margin|Futures|DVP]
@@ -1362,7 +1358,7 @@ Options:
   --iqfeed-username TEXT          Your IQFeed username
   --iqfeed-password TEXT          Your IQFeed password
   --iqfeed-version TEXT           The product version of your IQFeed developer account
-  --iqfeed-host TEXT              The IQFeed host address
+  --iqfeed-host TEXT              The IQFeed host address (Optional).
   --polygon-api-key TEXT          Your Polygon.io API Key
   --iex-cloud-api-key TEXT        Your iexcloud.io API token publishable key
   --iex-price-plan [Launch|Grow|Enterprise]
@@ -1730,7 +1726,7 @@ Options:
   --iqfeed-username TEXT          Your IQFeed username
   --iqfeed-password TEXT          Your IQFeed password
   --iqfeed-version TEXT           The product version of your IQFeed developer account
-  --iqfeed-host TEXT              The IQFeed host address
+  --iqfeed-host TEXT              The IQFeed host address (Optional).
   --polygon-api-key TEXT          Your Polygon.io API Key
   --factset-auth-config-file FILE
                                   The path to the FactSet authentication configuration file
@@ -1761,8 +1757,6 @@ Options:
                                   The Open FIGI API key to use for mapping options
   --bybit-api-key TEXT            Your Bybit API key
   --bybit-api-secret TEXT         Your Bybit API secret
-  --bybit-vip-level [VIP0|VIP1|VIP2|VIP3|VIP4|VIP5|SupremeVIP|Pro1|Pro2|Pro3|Pro4|Pro5]
-                                  Your Bybit VIP Level
   --trade-station-environment [live|paper]
                                   Whether Live or Paper environment should be used
   --trade-station-account-type [Cash|Margin|Futures|DVP]
@@ -1903,7 +1897,7 @@ Options:
   --iqfeed-username TEXT          Your IQFeed username
   --iqfeed-password TEXT          Your IQFeed password
   --iqfeed-version TEXT           The product version of your IQFeed developer account
-  --iqfeed-host TEXT              The IQFeed host address
+  --iqfeed-host TEXT              The IQFeed host address (Optional).
   --polygon-api-key TEXT          Your Polygon.io API Key
   --factset-auth-config-file FILE
                                   The path to the FactSet authentication configuration file
@@ -1934,8 +1928,6 @@ Options:
                                   The Open FIGI API key to use for mapping options
   --bybit-api-key TEXT            Your Bybit API key
   --bybit-api-secret TEXT         Your Bybit API secret
-  --bybit-vip-level [VIP0|VIP1|VIP2|VIP3|VIP4|VIP5|SupremeVIP|Pro1|Pro2|Pro3|Pro4|Pro5]
-                                  Your Bybit VIP Level
   --trade-station-environment [live|paper]
                                   Whether Live or Paper environment should be used
   --trade-station-account-type [Cash|Margin|Futures|DVP]
diff --git a/lean/commands/data/download.py b/lean/commands/data/download.py
index 77bd447..8cc88ef 100644
--- a/lean/commands/data/download.py
+++ b/lean/commands/data/download.py
@@ -18,7 +18,7 @@ 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 lean.components.util.json_modules_handler import config_build_for_name
-from lean.constants import DEFAULT_ENGINE_IMAGE
+from lean.constants import DEFAULT_ENGINE_IMAGE, CONTAINER_LABEL_LEAN_VERSION_NAME
 from lean.container import container
 from lean.models.api import QCDataInformation, QCDataVendor, QCFullOrganization, QCDatasetDelivery, QCResolution, QCSecurityType, QCDataType
 from lean.models.click_options import get_configs_for_options, options_from_json
@@ -675,13 +675,6 @@ def download(ctx: Context,
         logger = container.logger
         lean_config = container.lean_config_manager.get_complete_lean_config(None, None, None)
 
-        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.lean_config_manager.set_properties(data_downloader_provider.get_settings())
-        # mounting additional data_downloader config files
-        paths_to_mount = data_downloader_provider.get_paths_to_mount()
-
         engine_image = container.cli_config_manager.get_engine_image(image)
 
         if str(engine_image) != DEFAULT_ENGINE_IMAGE:
@@ -690,6 +683,16 @@ def download(ctx: Context,
 
         container.update_manager.pull_docker_image_if_necessary(engine_image, update, no_update)
 
+        container_module_version = container.docker_manager.get_image_label(engine_image,
+                                                                            CONTAINER_LABEL_LEAN_VERSION_NAME, None)
+
+        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)
+        container.lean_config_manager.set_properties(data_downloader_provider.get_settings())
+        # mounting additional data_downloader config files
+        paths_to_mount = data_downloader_provider.get_paths_to_mount()
+
         downloader_data_provider_path_dll = "/Lean/DownloaderDataProvider/bin/Debug"
 
         run_options = container.lean_runner.get_basic_docker_config_without_algo(lean_config,
diff --git a/lean/commands/init.py b/lean/commands/init.py
index 45a7ade..89dd4a7 100644
--- a/lean/commands/init.py
+++ b/lean/commands/init.py
@@ -17,6 +17,7 @@ from typing import Optional, Tuple
 from click import command, option, Choice, confirm, prompt
 
 from lean.click import LeanCommand
+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
 from lean.models.errors import MoreInfoError
@@ -128,8 +129,13 @@ def init(organization: Optional[str], language: Optional[str]) -> None:
     from shutil import copytree
     from zipfile import ZipFile
 
-    # Select and set organization
+    # Retrieve current credentials from the Lean CLI configuration
+    # If credentials are not available, prompt the user to provide them
+    current_user_id, current_api_token = get_lean_config_credentials()
+    user_id, api_token = get_credentials(current_user_id, current_api_token, False)
+    validate_credentials(user_id, api_token)
 
+    # Select and set organization
     if organization is not None:
         organization_id, organization_name = _get_organization_id(organization)
     else:
diff --git a/lean/commands/live/deploy.py b/lean/commands/live/deploy.py
index 3e6bc85..ad600fa 100644
--- a/lean/commands/live/deploy.py
+++ b/lean/commands/live/deploy.py
@@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional, Tuple
 from click import option, argument, Choice
 from lean.click import LeanCommand, PathParameter
 from lean.components.util.name_rename import rename_internal_config_to_user_friendly_format
-from lean.constants import DEFAULT_ENGINE_IMAGE
+from lean.constants import DEFAULT_ENGINE_IMAGE, CONTAINER_LABEL_LEAN_VERSION_NAME
 from lean.container import container
 from lean.models.cli import (cli_brokerages, cli_data_queue_handlers, cli_data_downloaders,
                              cli_addon_modules, cli_history_provider)
@@ -271,23 +271,26 @@ def deploy(project: Path,
                                                                  kwargs, logger, interactive=True,
                                                                  environment_name=environment_name))
 
+    project_config_manager = container.project_config_manager
+    cli_config_manager = container.cli_config_manager
+
+    project_config = project_config_manager.get_project_config(algorithm_file.parent)
+    engine_image = cli_config_manager.get_engine_image(image or project_config.get("engine-image", None))
+
+    container_module_version = container.docker_manager.get_image_label(engine_image,
+                                                                        CONTAINER_LABEL_LEAN_VERSION_NAME, None)
+
     organization_id = container.organization_manager.try_get_working_organization_id()
     paths_to_mount = {}
     for module in (data_provider_live_instances + [data_downloader_instances, brokerage_instance]
                    + history_providers_instances):
-        module.ensure_module_installed(organization_id)
+        module.ensure_module_installed(organization_id, container_module_version)
         paths_to_mount.update(module.get_paths_to_mount())
 
     if not lean_config["environments"][environment_name]["live-mode"]:
         raise MoreInfoError(f"The '{environment_name}' is not a live trading environment (live-mode is set to false)",
                             "https://www.lean.io/docs/v2/lean-cli/live-trading/brokerages/quantconnect-paper-trading")
 
-    project_config_manager = container.project_config_manager
-    cli_config_manager = container.cli_config_manager
-
-    project_config = project_config_manager.get_project_config(algorithm_file.parent)
-    engine_image = cli_config_manager.get_engine_image(image or project_config.get("engine-image", None))
-
     container.update_manager.pull_docker_image_if_necessary(engine_image, update, no_update)
 
     _start_iqconnect_if_necessary(lean_config, environment_name)
diff --git a/lean/commands/login.py b/lean/commands/login.py
index 834318b..f46d24a 100644
--- a/lean/commands/login.py
+++ b/lean/commands/login.py
@@ -11,29 +11,38 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from typing import Optional
+from typing import Optional, Tuple
 
 from click import command, option, prompt
 
 from lean.click import LeanCommand
 from lean.container import container
 from lean.models.errors import MoreInfoError
-from lean.components.api.api_client import APIClient
 
 
-@command(cls=LeanCommand)
-@option("--user-id", "-u", type=str, help="QuantConnect user id")
-@option("--api-token", "-t", type=str, help="QuantConnect API token")
-@option("--show-secrets", is_flag=True, show_default=True, default=False, help="Show secrets as they are input")
-def login(user_id: Optional[str], api_token: Optional[str], show_secrets: bool) -> None:
-    """Log in with a QuantConnect account.
+def get_lean_config_credentials() -> Tuple[str, str]:
+    """Retrieve the QuantConnect credentials from the Lean CLI configuration.
 
-    If user id or API token is not provided an interactive prompt will show.
+    This function accesses the Lean CLI configuration manager to obtain the
+    stored user ID and API token. The credentials are retrieved from the
+    configuration settings managed by the Lean CLI.
 
-    Credentials are stored in ~/.lean/credentials and are removed upon running `lean logout`.
+    Returns:
+        tuple[str, str]: A tuple containing the user ID and API token as strings.
     """
+    cli_config_manager = container.cli_config_manager
+
+    user_id = cli_config_manager.user_id.get_value()
+    api_token = cli_config_manager.api_token.get_value()
+
+    return user_id, api_token
+
+
+def get_credentials(user_id: Optional[str], api_token: Optional[str], show_secrets: bool) -> Tuple[str, str]:
+    """Fetch user credentials, prompting the user if necessary."""
     logger = container.logger
     credentials_storage = container.credentials_storage
+    current_user_id, current_api_token = get_lean_config_credentials()
 
     if user_id is None or api_token is None:
         logger.info("Your user id and API token are needed to make authenticated requests to the QuantConnect API")
@@ -41,18 +50,41 @@ def login(user_id: Optional[str], api_token: Optional[str], show_secrets: bool)
         logger.info(f"Both will be saved in {credentials_storage.file}")
 
     if user_id is None:
-        user_id = prompt("User id")
+        user_id = prompt("User id", current_user_id)
 
     if api_token is None:
-        api_token = logger.prompt_password("API token", hide_input=not show_secrets)
+        api_token = logger.prompt_password("API token", current_api_token, hide_input=not show_secrets)
+
+    return user_id, api_token
+
 
+def validate_credentials(user_id: str, api_token: str) -> None:
+    """Validate the user credentials by attempting to authenticate with the QuantConnect API."""
     container.api_client.set_user_token(user_id=user_id, api_token=api_token)
+
     if not container.api_client.is_authenticated():
-        raise MoreInfoError("Credentials are invalid. Please ensure your computer clock is correct, or try using another terminal, or enter API token manually instead of copy-pasting.",
-                            "https://www.lean.io/docs/v2/lean-cli")
+        raise MoreInfoError(
+            "Credentials are invalid. Please ensure your computer clock is correct, or try using another terminal, or enter API token manually instead of copy-pasting.",
+            "https://www.lean.io/docs/v2/lean-cli"
+        )
 
     cli_config_manager = container.cli_config_manager
     cli_config_manager.user_id.set_value(user_id)
     cli_config_manager.api_token.set_value(api_token)
 
-    logger.info("Successfully logged in")
+    container.logger.info("Successfully logged in")
+
+@command(cls=LeanCommand)
+@option("--user-id", "-u", type=str, help="QuantConnect user id")
+@option("--api-token", "-t", type=str, help="QuantConnect API token")
+@option("--show-secrets", is_flag=True, show_default=True, default=False, help="Show secrets as they are input")
+def login(user_id: Optional[str], api_token: Optional[str], show_secrets: bool) -> None:
+    """Log in with a QuantConnect account.
+
+    If user id or API token is not provided an interactive prompt will show.
+
+    Credentials are stored in ~/.lean/credentials and are removed upon running `lean logout`.
+    """
+
+    user_id, api_token = get_credentials(user_id, api_token, show_secrets)
+    validate_credentials(user_id, api_token)
diff --git a/lean/commands/optimize.py b/lean/commands/optimize.py
index 7df8065..cd36112 100644
--- a/lean/commands/optimize.py
+++ b/lean/commands/optimize.py
@@ -19,7 +19,7 @@ from click import command, argument, option, Choice, IntRange
 
 from lean.click import LeanCommand, PathParameter, ensure_options
 from lean.components.docker.lean_runner import LeanRunner
-from lean.constants import DEFAULT_ENGINE_IMAGE
+from lean.constants import DEFAULT_ENGINE_IMAGE, CONTAINER_LABEL_LEAN_VERSION_NAME
 from lean.container import container
 from lean.models.api import QCParameter, QCBacktest
 from lean.models.click_options import options_from_json, get_configs_for_options
@@ -307,10 +307,13 @@ def optimize(project: Path,
 
     paths_to_mount = None
 
+    container_module_version = container.docker_manager.get_image_label(engine_image,
+                                                                        CONTAINER_LABEL_LEAN_VERSION_NAME, None)
+
     if data_provider_historical is not None:
         data_provider = non_interactive_config_build_for_name(lean_config, data_provider_historical,
                                                               cli_data_downloaders, kwargs, logger, environment_name)
-        data_provider.ensure_module_installed(organization_id)
+        data_provider.ensure_module_installed(organization_id, container_module_version)
         container.lean_config_manager.set_properties(data_provider.get_settings())
         paths_to_mount = data_provider.get_paths_to_mount()
 
diff --git a/lean/commands/research.py b/lean/commands/research.py
index 6596e87..604c92a 100644
--- a/lean/commands/research.py
+++ b/lean/commands/research.py
@@ -16,7 +16,7 @@ from typing import Optional, Tuple
 from click import command, argument, option, Choice
 from lean.click import LeanCommand, PathParameter
 from lean.components.docker.lean_runner import LeanRunner
-from lean.constants import DEFAULT_RESEARCH_IMAGE, LEAN_ROOT_PATH
+from lean.constants import DEFAULT_RESEARCH_IMAGE, LEAN_ROOT_PATH, CONTAINER_LABEL_LEAN_VERSION_NAME
 from lean.container import container
 from lean.models.cli import cli_data_downloaders
 from lean.components.util.name_extraction import convert_to_class_name
@@ -113,13 +113,27 @@ def research(project: Path,
     if download_data:
         data_provider_historical = "QuantConnect"
 
+    project_config_manager = container.project_config_manager
+    cli_config_manager = container.cli_config_manager
+
+    project_config = project_config_manager.get_project_config(algorithm_file.parent)
+    research_image = cli_config_manager.get_research_image(image or project_config.get("research-image", None))
+
+    container.update_manager.pull_docker_image_if_necessary(research_image, update, no_update)
+
+    container_module_version = container.docker_manager.get_image_label(research_image,
+                                                                        CONTAINER_LABEL_LEAN_VERSION_NAME, None)
+
+    if str(research_image) != DEFAULT_RESEARCH_IMAGE:
+        logger.warn(f'A custom research image: "{research_image}" is being used!')
+
     paths_to_mount = None
 
     if data_provider_historical is not None:
         organization_id = container.organization_manager.try_get_working_organization_id()
         data_provider = non_interactive_config_build_for_name(lean_config, data_provider_historical,
                                                               cli_data_downloaders, kwargs, logger, environment_name)
-        data_provider.ensure_module_installed(organization_id)
+        data_provider.ensure_module_installed(organization_id, container_module_version)
         container.lean_config_manager.set_properties(data_provider.get_settings())
         paths_to_mount = data_provider.get_paths_to_mount()
     lean_config_manager.configure_data_purchase_limit(lean_config, data_purchase_limit)
@@ -131,17 +145,6 @@ def research(project: Path,
     for key, value in extra_config:
         lean_config[key] = value
 
-    project_config_manager = container.project_config_manager
-    cli_config_manager = container.cli_config_manager
-
-    project_config = project_config_manager.get_project_config(algorithm_file.parent)
-    research_image = cli_config_manager.get_research_image(image or project_config.get("research-image", None))
-
-    container.update_manager.pull_docker_image_if_necessary(research_image, update, no_update)
-
-    if str(research_image) != DEFAULT_RESEARCH_IMAGE:
-        logger.warn(f'A custom research image: "{research_image}" is being used!')
-
     run_options = lean_runner.get_basic_docker_config(lean_config,
                                                       algorithm_file,
                                                       temp_manager.create_temporary_directory(),
diff --git a/lean/components/cloud/module_manager.py b/lean/components/cloud/module_manager.py
index 505557b..e54f196 100644
--- a/lean/components/cloud/module_manager.py
+++ b/lean/components/cloud/module_manager.py
@@ -37,28 +37,48 @@ class ModuleManager:
         self._installed_product_ids: Set[int] = set()
         self._installed_packages: Dict[int, List[NuGetPackage]] = {}
 
-    def install_module(self, product_id: int, organization_id: str) -> None:
-        """Installs a module into the global modules directory.
+    def install_module(self, product_id: int, organization_id: str, module_version: str) -> None:
+        """
+        Installs a module into the global modules' directory.
 
-        If an outdated version is already installed, it is automatically updated.
-        If the organization does not have a subscription for the given module, an error is raised.
+        If an outdated version is already installed, it is automatically updated. If a specific version
+        is provided and is different from the installed version, it will be updated. If the organization
+        does not have a subscription for the given module, an error is raised.
 
-        :param product_id: the product id of the module to download
-        :param organization_id: the id of the organization that has a license for the module
+        Args:
+        product_id (int): The product id of the module to download.
+        organization_id (str): The id of the organization that has a license for the module.
+        module_version (str): The specific version of the module to install. If None, installs the latest version.
         """
         if product_id in self._installed_product_ids:
             return
 
+        # Retrieve the list of module files for the specified product and organization
         module_files = self._api_client.modules.list_files(product_id, organization_id)
+        # Dictionaries to store the latest packages to download and specific version packages
         packages_to_download: Dict[str, NuGetPackage] = {}
+        packages_to_download_specific_version: Dict[str, NuGetPackage] = {}
 
-        for file_name in module_files:
-            package = NuGetPackage.parse(file_name)
+        # Parse the module files into NuGetPackage objects and sort them by version
+        packages = [NuGetPackage.parse(file_name) for file_name in module_files]
+        sorted_packages = sorted(packages, key=lambda p: p.version)
 
+        for package in sorted_packages:
+            # Store the latest version of each package
             if package.name not in packages_to_download or package.version > packages_to_download[package.name].version:
                 packages_to_download[package.name] = package
+                # If a specific version is requested, keep track of the highest version <= module_version
+                if module_version and package.version.split('.')[-1] <= module_version:
+                    packages_to_download_specific_version[package.name] = package
+
+        # Replace version packages based on module_version if available
+        for package_name, package_specific_version in packages_to_download_specific_version.items():
+            packages_to_download[package_name] = package_specific_version
 
         for package in packages_to_download.values():
+            if module_version and package.version.split('.')[-1] != module_version:
+                self._logger.debug(f'Package "{package.name}" does not have the specified version {module_version}. '
+                                   f'Using available version {package.version} instead.')
             self._download_file(product_id, organization_id, package)
 
         self._installed_product_ids.add(product_id)
diff --git a/lean/components/util/json_modules_handler.py b/lean/components/util/json_modules_handler.py
index 6657db2..bf449cd 100644
--- a/lean/components/util/json_modules_handler.py
+++ b/lean/components/util/json_modules_handler.py
@@ -33,7 +33,6 @@ def build_and_configure_modules(target_modules: List[str], module_list: List[Jso
     for target_module_name in target_modules:
         module = non_interactive_config_build_for_name(lean_config, target_module_name, module_list, properties,
                                                        logger, environment_name)
-        module.ensure_module_installed(organization_id)
         lean_config["environments"][environment_name].update(module.get_settings())
 
 
diff --git a/lean/constants.py b/lean/constants.py
index f2fe348..ada2dbc 100644
--- a/lean/constants.py
+++ b/lean/constants.py
@@ -26,6 +26,9 @@ DEFAULT_LEAN_PYTHON_VERSION = "3.11"
 DEFAULT_LEAN_STRICT_PYTHON_VERSION = f"{DEFAULT_LEAN_PYTHON_VERSION}.7"
 DEFAULT_LEAN_DOTNET_FRAMEWORK = "net6.0"
 
+# Label name used in Docker containers to specify the version of Lean being used
+CONTAINER_LABEL_LEAN_VERSION_NAME = "lean_version"
+
 # The path to the root python directory in docker image
 DOCKER_PYTHON_SITE_PACKAGES_PATH = "/root/.local/lib/python{LEAN_PYTHON_VERSION}/site-packages"
 
diff --git a/lean/models/json_module.py b/lean/models/json_module.py
index b95173f..6003af3 100644
--- a/lean/models/json_module.py
+++ b/lean/models/json_module.py
@@ -246,11 +246,21 @@ class JsonModule(ABC):
                 if (isinstance(config, PathParameterUserInput)
                     and self._check_if_config_passes_filters(config, all_for_platform_type=False))}
 
-    def ensure_module_installed(self, organization_id: str) -> None:
+    def ensure_module_installed(self, organization_id: str, module_version: str) -> None:
+        """
+        Ensures that the specified module is installed. If the module is not installed, it will be installed.
+
+        Args:
+            organization_id (str): The ID of the organization where the module should be installed.
+            module_version (str): The version of the module to install. If not provided,
+            the latest version will be installed.
+
+        Returns:
+            None
+        """
         if not self._is_module_installed and self._installs:
             container.logger.debug(f"JsonModule.ensure_module_installed(): installing module {self}: {self._product_id}")
-            container.module_manager.install_module(
-                self._product_id, organization_id)
+            container.module_manager.install_module(self._product_id, organization_id, module_version)
             self._is_module_installed = True
 
     def get_default(self, lean_config: Dict[str, Any], key: str, environment_name: str, logger: Logger):


Test patch:
diff --git a/lean/commands/backtest.py b/lean/commands/backtest.py
index 9c0836d..779b07f 100644
--- a/lean/commands/backtest.py
+++ b/lean/commands/backtest.py
@@ -16,7 +16,7 @@ from typing import List, Optional, Tuple
 from click import command, option, argument, Choice
 
 from lean.click import LeanCommand, PathParameter
-from lean.constants import DEFAULT_ENGINE_IMAGE, LEAN_ROOT_PATH
+from lean.constants import DEFAULT_ENGINE_IMAGE, LEAN_ROOT_PATH, CONTAINER_LABEL_LEAN_VERSION_NAME
 from lean.container import container, Logger
 from lean.models.utils import DebuggingMethod
 from lean.models.cli import cli_data_downloaders, cli_addon_modules
@@ -359,21 +359,24 @@ def backtest(project: Path,
     organization_id = container.organization_manager.try_get_working_organization_id()
     paths_to_mount = None
 
+    cli_config_manager = container.cli_config_manager
+    project_config_manager = container.project_config_manager
+
+    project_config = project_config_manager.get_project_config(algorithm_file.parent)
+    engine_image = cli_config_manager.get_engine_image(image or project_config.get("engine-image", None))
+
+    container_module_version = container.docker_manager.get_image_label(engine_image,
+                                                                        CONTAINER_LABEL_LEAN_VERSION_NAME, None)
+
     if data_provider_historical is not None:
         data_provider = non_interactive_config_build_for_name(lean_config, data_provider_historical,
                                                               cli_data_downloaders, kwargs, logger, environment_name)
-        data_provider.ensure_module_installed(organization_id)
+        data_provider.ensure_module_installed(organization_id, container_module_version)
         container.lean_config_manager.set_properties(data_provider.get_settings())
         paths_to_mount = data_provider.get_paths_to_mount()
 
     lean_config_manager.configure_data_purchase_limit(lean_config, data_purchase_limit)
 
-    cli_config_manager = container.cli_config_manager
-    project_config_manager = container.project_config_manager
-
-    project_config = project_config_manager.get_project_config(algorithm_file.parent)
-    engine_image = cli_config_manager.get_engine_image(image or project_config.get("engine-image", None))
-
     if str(engine_image) != DEFAULT_ENGINE_IMAGE:
         logger.warn(f'A custom engine image: "{engine_image}" is being used!')
 
diff --git a/tests/commands/test_init.py b/tests/commands/test_init.py
index f39c8ce..248a8e9 100644
--- a/tests/commands/test_init.py
+++ b/tests/commands/test_init.py
@@ -80,6 +80,9 @@ def set_mock_organizations(set_unauthenticated) -> None:
 
     # container.api_client is already a mock from set_unauthenticated()
     api_client = container.api_client
+    container.cli_config_manager.user_id.set_value("123")
+    container.cli_config_manager.api_token.set_value("456")
+    api_client.is_authenticated.return_value = True
     api_client.organizations.get_all.side_effect = _get_all_organizations
     api_client.organizations.get.side_effect = mock_get_organization