From f3319c07e7c24a66494429389dd8d9728ab229ac Mon Sep 17 00:00:00 2001 From: Josue Nina Date: Wed, 4 Mar 2026 16:27:53 -0500 Subject: [PATCH 1/6] Migrate from Pydantic v1 compatibility to native Pydantic v2 --- lean/commands/optimize.py | 4 +-- lean/components/api/live_client.py | 2 +- lean/components/util/library_manager.py | 5 ++- lean/models/api.py | 47 +++++++++++++------------ lean/models/data.py | 16 +++++---- lean/models/market_hours_database.py | 5 +-- lean/models/pydantic.py | 17 +++++---- lean/models/utils.py | 11 +++++- setup.py | 2 +- 9 files changed, 62 insertions(+), 47 deletions(-) diff --git a/lean/commands/optimize.py b/lean/commands/optimize.py index 8b686bee..3e659082 100644 --- a/lean/commands/optimize.py +++ b/lean/commands/optimize.py @@ -267,8 +267,8 @@ def optimize(project: Path, "target": optimization_target.target, "extremum": optimization_target.extremum.value }, - "parameters": [parameter.dict() for parameter in optimization_parameters], - "constraints": [constraint.dict(by_alias=True) for constraint in optimization_constraints] + "parameters": [parameter.model_dump() for parameter in optimization_parameters], + "constraints": [constraint.model_dump(by_alias=True) for constraint in optimization_constraints] } if max_concurrent_backtests is not None: diff --git a/lean/components/api/live_client.py b/lean/components/api/live_client.py index 729e2d8a..9e6b6bc9 100644 --- a/lean/components/api/live_client.py +++ b/lean/components/api/live_client.py @@ -97,7 +97,7 @@ def start(self, parameters["notification"] = { "events": events, - "targets": [{x: y for x, y in method.dict().items() if y} for method in notify_methods] + "targets": [{x: y for x, y in method.model_dump().items() if y} for method in notify_methods] } data = self._api.post("live/create", parameters) diff --git a/lean/components/util/library_manager.py b/lean/components/util/library_manager.py index 7464b869..29772ad5 100644 --- a/lean/components/util/library_manager.py +++ b/lean/components/util/library_manager.py @@ -119,11 +119,10 @@ def add_lean_library_reference_to_project(self, project_dir: Path, library_dir: raise RuntimeError("Circular dependency detected between " f"{project_relative_path} and {library_relative_path}") - from json import loads - project_libraries.append(loads(LeanLibraryReference( + project_libraries.append(LeanLibraryReference( name=library_dir.name, path=library_relative_path - ).json())) + ).model_dump(mode="json")) project_config.set("libraries", project_libraries) return False diff --git a/lean/models/api.py b/lean/models/api.py index 3cdbb14f..24000f3e 100644 --- a/lean/models/api.py +++ b/lean/models/api.py @@ -15,7 +15,7 @@ from enum import Enum from typing import Any, Dict, List, Optional, Union -from lean.models.pydantic import WrappedBaseModel, validator +from lean.models.pydantic import WrappedBaseModel, field_validator # The models in this module are all parts of responses from the QuantConnect API @@ -23,7 +23,7 @@ class QCAuth0Authorization(WrappedBaseModel): - authorization: Optional[Dict[str, Any]] + authorization: Optional[Dict[str, Any]] = None def get_account_ids(self) -> List[str]: """ @@ -55,7 +55,7 @@ class ProjectEncryptionKey(WrappedBaseModel): name: str class QCCollaborator(WrappedBaseModel): - uid: Optional[int] + uid: Optional[int] = None liveControl: bool permission: str profileImage: str @@ -66,10 +66,10 @@ class QCCollaborator(WrappedBaseModel): class QCParameter(WrappedBaseModel): key: str value: str - min: Optional[float] - max: Optional[float] - step: Optional[float] - type: Optional[str] + min: Optional[float] = None + max: Optional[float] = None + step: Optional[float] = None + type: Optional[str] = None class QCLanguage(str, Enum): @@ -112,7 +112,8 @@ class QCProject(WrappedBaseModel): encrypted: Optional[bool] = False encryptionKey: Optional[ProjectEncryptionKey] = None - @validator("parameters", pre=True) + @field_validator("parameters", mode="before") + @classmethod def process_parameters_dict(cls, value: Any) -> Any: if isinstance(value, dict): return list(value.values()) @@ -181,9 +182,9 @@ class QCBacktest(WrappedBaseModel): result: Optional[Any] = None error: Optional[str] = None stacktrace: Optional[str] = None - runtimeStatistics: Optional[Dict[str, str]] - statistics: Optional[Union[Dict[str, str], List[Any]]] - totalPerformance: Optional[Any] + runtimeStatistics: Optional[Dict[str, str]] = None + statistics: Optional[Union[Dict[str, str], List[Any]]] = None + totalPerformance: Optional[Any] = None def is_complete(self) -> bool: """Returns whether the backtest has completed in the cloud. @@ -271,7 +272,7 @@ class QCNode(WrappedBaseModel): cpu: int ram: float assets: int - host: Optional[str] + host: Optional[str] = None class QCNodeList(WrappedBaseModel): @@ -297,7 +298,7 @@ class QCLiveAlgorithmStatus(str, Enum): class QCRestResponse(WrappedBaseModel): success: bool - error: Optional[List[str]] + error: Optional[List[str]] = None class QCMinimalLiveAlgorithm(WrappedBaseModel): projectId: int @@ -317,7 +318,7 @@ class QCFullLiveAlgorithm(QCMinimalLiveAlgorithm): deployId: str status: Optional[QCLiveAlgorithmStatus] = None launched: datetime - stopped: Optional[datetime] + stopped: Optional[datetime] = None brokerage: str @@ -389,7 +390,7 @@ class QCOrganizationProduct(WrappedBaseModel): class QCOrganizationData(WrappedBaseModel): - signedTime: Optional[int] + signedTime: Optional[int] = None current: bool @@ -544,7 +545,8 @@ class QCOptimization(WrappedBaseModel): backtests: Dict[str, QCOptimizationBacktest] = {} runtimeStatistics: Dict[str, str] = {} - @validator("backtests", "runtimeStatistics", pre=True) + @field_validator("backtests", "runtimeStatistics", mode="before") + @classmethod def parse_empty_lists(cls, value: Any) -> Any: # If these fields have no data, they are assigned an array by default # For consistency we convert those empty arrays to empty dicts @@ -576,9 +578,10 @@ class QCDataVendor(WrappedBaseModel): regex: Any # Price in QCC - price: Optional[float] + price: Optional[float] = None - @validator("regex", pre=True) + @field_validator("regex", mode="before") + @classmethod def parse_regex(cls, value: Any) -> Any: from re import compile if isinstance(value, str): @@ -614,7 +617,7 @@ class QCDataset(WrappedBaseModel): class QCUser(WrappedBaseModel): name: str profile: str - badge: Optional[str] + badge: Optional[str] = None class QCTerminalNewsItem(WrappedBaseModel): @@ -625,8 +628,8 @@ class QCTerminalNewsItem(WrappedBaseModel): content: str image: str link: str - year_deleted: Optional[Any] - week_deleted: Optional[Any] + year_deleted: Optional[Any] = None + week_deleted: Optional[Any] = None created: datetime date: datetime @@ -634,6 +637,6 @@ class QCTerminalNewsItem(WrappedBaseModel): class QCLeanEnvironment(WrappedBaseModel): id: int name: str - path: Optional[str] + path: Optional[str] = None description: str public: bool diff --git a/lean/models/data.py b/lean/models/data.py index 4e9e72f8..b14cac56 100644 --- a/lean/models/data.py +++ b/lean/models/data.py @@ -14,7 +14,8 @@ from abc import ABC from datetime import datetime from enum import Enum -from typing import List, Any, Optional, Dict, Set, Tuple, Pattern +import re +from typing import List, Any, Optional, Dict, Set, Tuple from click import prompt @@ -22,7 +23,7 @@ from lean.container import container from lean.models.api import QCDataVendor from lean.models.logger import Option -from lean.models.pydantic import WrappedBaseModel, validator +from lean.models.pydantic import WrappedBaseModel, field_validator class OptionResult(WrappedBaseModel): """The OptionResult class represents an option's result with an internal value and a display-friendly label.""" @@ -84,7 +85,8 @@ class DatasetOption(WrappedBaseModel, ABC): description: str condition: Optional[DatasetCondition] = None - @validator("condition", pre=True) + @field_validator("condition", mode="before") + @classmethod def parse_condition(cls, value: Optional[Any]) -> Any: if value is None or isinstance(value, DatasetCondition): return value @@ -258,7 +260,8 @@ class DatasetPath(WrappedBaseModel): condition: Optional[DatasetCondition] = None templates: DatasetPathTemplates - @validator("condition", pre=True) + @field_validator("condition", mode="before") + @classmethod def parse_condition(cls, value: Optional[Any]) -> Any: if value is None or isinstance(value, DatasetCondition): return value @@ -290,7 +293,8 @@ class Dataset(WrappedBaseModel): paths: List[DatasetPath] requirements: Dict[int, str] - @validator("options", pre=True) + @field_validator("options", mode="before") + @classmethod def parse_options(cls, values: List[Any]) -> List[Any]: option_types = { "text": DatasetTextOption, @@ -358,7 +362,7 @@ def get_valid_files(self, files_with_prefix: Optional[List[str]]) -> Set[str]: class DataFileLatestGroup(DataFileGroup): - regex: Pattern + regex: re.Pattern def get_valid_files(self, files_with_prefix: Optional[List[str]]) -> Set[str]: if files_with_prefix is not None: diff --git a/lean/models/market_hours_database.py b/lean/models/market_hours_database.py index 31dad56e..3d992fdf 100644 --- a/lean/models/market_hours_database.py +++ b/lean/models/market_hours_database.py @@ -14,7 +14,7 @@ from datetime import datetime from typing import List, Dict, Any -from lean.models.pydantic import WrappedBaseModel, validator +from lean.models.pydantic import WrappedBaseModel, field_validator class MarketHoursSegment(WrappedBaseModel): start: str @@ -36,7 +36,8 @@ class MarketHoursDatabaseEntry(WrappedBaseModel): earlyCloses: Dict[str, str] = {} lateOpens: Dict[str, str] = {} - @validator("holidays", pre=True) + @field_validator("holidays", mode="before") + @classmethod def parse_holidays(cls, value: Any) -> Any: if isinstance(value, list): return [datetime.strptime(x, "%m/%d/%Y") for x in value] diff --git a/lean/models/pydantic.py b/lean/models/pydantic.py index e8a8a6b8..7bde6234 100644 --- a/lean/models/pydantic.py +++ b/lean/models/pydantic.py @@ -11,18 +11,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from pydantic import __version__ as pydantic_version -if pydantic_version.startswith("1."): - # We keep all this imports here, even if not used like validator, so other files can import them through this file - # to avoid having to check the pydantic version in every file. - # All imports should be done through this file to avoid pydantic version related errors. - from pydantic import BaseModel, ValidationError, Field, validator -else: - from pydantic.v1 import BaseModel, ValidationError, Field, validator +from pydantic import BaseModel, ConfigDict, ValidationError, Field, field_validator class WrappedBaseModel(BaseModel): """A version of Pydantic's BaseModel which makes the input data accessible in case of a validation error.""" + # Ensures backward compatibility: automatically converts numeric inputs to strings for string fields + model_config = ConfigDict(coerce_numbers_to_str=True) + def __init__(self, *args, **kwargs) -> None: """Creates a new WrappedBaseModel instance. @@ -32,5 +28,8 @@ def __init__(self, *args, **kwargs) -> None: try: super().__init__(*args, **kwargs) except ValidationError as error: - error.input_value = kwargs + try: + error.input_value = kwargs + except AttributeError: + pass raise error diff --git a/lean/models/utils.py b/lean/models/utils.py index 7c868d01..8ccaf8e7 100644 --- a/lean/models/utils.py +++ b/lean/models/utils.py @@ -13,8 +13,9 @@ from enum import Enum from pathlib import Path +from typing import Any -from lean.models.pydantic import WrappedBaseModel +from lean.models.pydantic import WrappedBaseModel, field_validator class DebuggingMethod(Enum): """The debugging methods supported by the CLI.""" @@ -48,3 +49,11 @@ class LeanLibraryReference(WrappedBaseModel): """The information of a library reference in a project's config.json file""" name: str path: Path + + # Ensure Path is built via Python to avoid filesystem patching issues in tests. + @field_validator("path", mode="before") + @classmethod + def coerce_path(cls, v: Any) -> Any: + if isinstance(v, Path): + return v + return Path(str(v)) diff --git a/setup.py b/setup.py index d3a270d4..1d519683 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ def get_stubs_version_range() -> str: "json5>=0.9.8", "docker>=6.0.0", "rich>=9.10.0", - "pydantic>=1.8.2", + "pydantic>=2.0.0", "python-dateutil>=2.8.2", "lxml>=4.9.0", "joblib>=1.1.0", From 9706957caf89553658653aaa5bb08a7bee81e387 Mon Sep 17 00:00:00 2001 From: Josue Nina Date: Thu, 5 Mar 2026 15:13:30 -0500 Subject: [PATCH 2/6] Update supported Python versions: drop 3.8 (EOL), add 3.14 --- .github/workflows-config/python-versions.txt | 4 ++-- lean/models/pydantic.py | 15 --------------- requirements.txt | 5 +++-- setup.py | 7 +++---- 4 files changed, 8 insertions(+), 23 deletions(-) diff --git a/.github/workflows-config/python-versions.txt b/.github/workflows-config/python-versions.txt index 04636f5b..738a0f9f 100644 --- a/.github/workflows-config/python-versions.txt +++ b/.github/workflows-config/python-versions.txt @@ -1,6 +1,6 @@ -3.8 3.9 3.10 3.11 3.12 -3.13 \ No newline at end of file +3.13 +3.14 \ No newline at end of file diff --git a/lean/models/pydantic.py b/lean/models/pydantic.py index 7bde6234..a124105f 100644 --- a/lean/models/pydantic.py +++ b/lean/models/pydantic.py @@ -18,18 +18,3 @@ class WrappedBaseModel(BaseModel): # Ensures backward compatibility: automatically converts numeric inputs to strings for string fields model_config = ConfigDict(coerce_numbers_to_str=True) - - def __init__(self, *args, **kwargs) -> None: - """Creates a new WrappedBaseModel instance. - - :param args: args to pass on to the BaseModel constructor - :param kwargs: kwargs to pass on to the BaseModel constructor - """ - try: - super().__init__(*args, **kwargs) - except ValidationError as error: - try: - error.input_value = kwargs - except AttributeError: - pass - raise error diff --git a/requirements.txt b/requirements.txt index d684a493..35a889b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # This file contains development dependencies # Production dependencies are stored in setup.py +setuptools==69.5.1 -e . wheel @@ -8,5 +9,5 @@ pytest>=7.1.2,<8 pyfakefs>=5.4.1,<6 responses~=0.21 lxml-stubs==0.4.0 -# Match pandas version to use same as Lean. Except that 2.1.* is only supported on Python 3.9+. -pandas==2.2.3;python_version>='3.9' +# Match pandas version used by LEAN. No prebuilt wheel for Python 3.14+, quantconnect-stubs will install a compatible version. +pandas==2.2.3;python_version>='3.9' and python_version<'3.14' diff --git a/setup.py b/setup.py index 1d519683..cc4ae04a 100644 --- a/setup.py +++ b/setup.py @@ -77,20 +77,19 @@ def get_stubs_version_range() -> str: "console_scripts": ["lean=lean.main:main"] }, install_requires=install_requires, - python_requires=">= 3.7", + python_requires=">= 3.9", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: Financial and Insurance Industry", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13" + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14" ], project_urls={ "Documentation": "https://www.lean.io/docs/v2/lean-cli/key-concepts/getting-started", From 070bb4bd00827f253a4332143b1a3d4f30444199 Mon Sep 17 00:00:00 2001 From: Josue Nina Date: Thu, 5 Mar 2026 16:37:26 -0500 Subject: [PATCH 3/6] Minor fix --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index cc4ae04a..9dba35c9 100644 --- a/setup.py +++ b/setup.py @@ -53,6 +53,7 @@ def get_stubs_version_range() -> str: "rich>=9.10.0", "pydantic>=2.0.0", "python-dateutil>=2.8.2", + "pytz", "lxml>=4.9.0", "joblib>=1.1.0", "setuptools", From 375f1e7d6733bc91de643860b14b04deedb738ed Mon Sep 17 00:00:00 2001 From: Josue Nina Date: Fri, 6 Mar 2026 13:47:58 -0500 Subject: [PATCH 4/6] Replace coerce_path validator with reusable SafePath annotated type --- lean/models/pydantic.py | 10 +++++++++- lean/models/utils.py | 14 ++------------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/lean/models/pydantic.py b/lean/models/pydantic.py index a124105f..bc241070 100644 --- a/lean/models/pydantic.py +++ b/lean/models/pydantic.py @@ -11,7 +11,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from pydantic import BaseModel, ConfigDict, ValidationError, Field, field_validator +from pathlib import Path +from typing import Annotated, Any + +from pydantic import BaseModel, BeforeValidator, ConfigDict, ValidationError, Field, field_validator + +# Path field that accepts str | Path. Converts in Python before pydantic-core runs, +# avoiding issues when pathlib is patched at runtime (e.g. pyfakefs). +SafePath = Annotated[Path, BeforeValidator(lambda v: v if isinstance(v, Path) else Path(v))] + class WrappedBaseModel(BaseModel): """A version of Pydantic's BaseModel which makes the input data accessible in case of a validation error.""" diff --git a/lean/models/utils.py b/lean/models/utils.py index 8ccaf8e7..d9699424 100644 --- a/lean/models/utils.py +++ b/lean/models/utils.py @@ -12,10 +12,8 @@ # limitations under the License. from enum import Enum -from pathlib import Path -from typing import Any -from lean.models.pydantic import WrappedBaseModel, field_validator +from lean.models.pydantic import WrappedBaseModel, SafePath class DebuggingMethod(Enum): """The debugging methods supported by the CLI.""" @@ -48,12 +46,4 @@ class CSharpLibrary(WrappedBaseModel): class LeanLibraryReference(WrappedBaseModel): """The information of a library reference in a project's config.json file""" name: str - path: Path - - # Ensure Path is built via Python to avoid filesystem patching issues in tests. - @field_validator("path", mode="before") - @classmethod - def coerce_path(cls, v: Any) -> Any: - if isinstance(v, Path): - return v - return Path(str(v)) + path: SafePath From 12497f9690e0aafac475a96e0ea315c873705f1e Mon Sep 17 00:00:00 2001 From: Josue Nina Date: Fri, 6 Mar 2026 13:58:02 -0500 Subject: [PATCH 5/6] Minor fix --- .github/workflows/regression-testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/regression-testing.yml b/.github/workflows/regression-testing.yml index 5ec7d3c9..7bbed604 100644 --- a/.github/workflows/regression-testing.yml +++ b/.github/workflows/regression-testing.yml @@ -23,7 +23,7 @@ jobs: echo "python=$(jq -Rsc 'split("\n") | map(select(length > 0))' .github/workflows-config/python-versions.txt)" >> $GITHUB_OUTPUT echo "os=$(jq -Rsc 'split("\n") | map(select(length > 0))' .github/workflows-config/os-versions.txt)" >> $GITHUB_OUTPUT else - echo 'python=["3.13"]' >> $GITHUB_OUTPUT + echo 'python=["3.14"]' >> $GITHUB_OUTPUT echo 'os=["ubuntu-24.04","macos-15","windows-latest"]' >> $GITHUB_OUTPUT fi From 7765d8ec340178929076897caa1dc47ba4a170f3 Mon Sep 17 00:00:00 2001 From: Josue Nina Date: Fri, 6 Mar 2026 15:28:23 -0500 Subject: [PATCH 6/6] Remove unused imports --- lean/models/pydantic.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lean/models/pydantic.py b/lean/models/pydantic.py index bc241070..21bd8d1a 100644 --- a/lean/models/pydantic.py +++ b/lean/models/pydantic.py @@ -12,8 +12,7 @@ # limitations under the License. from pathlib import Path -from typing import Annotated, Any - +from typing import Annotated from pydantic import BaseModel, BeforeValidator, ConfigDict, ValidationError, Field, field_validator # Path field that accepts str | Path. Converts in Python before pydantic-core runs,