Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Pydantic V2 #179

Merged
merged 17 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
60 changes: 60 additions & 0 deletions .github/workflows/CI-v1.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# This workflow is aimed at testing GEOLib with Pydantic v1
name: ci-pydantic-v1

on:
push:
branches:
- master
pull_request:
branches:
- master

jobs:
CI:
strategy:
fail-fast: false
matrix:
python-version: [3.9, "3.10", "3.11", "3.12"]
os: [ubuntu-22.04, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/[email protected]
with:
fetch-depth: 0

- name: Set up Python
uses: actions/[email protected]
with:
python-version: ${{ matrix.python-version }}

- name: Run image
uses: abatilo/[email protected]
with:
poetry-version: 1.6.1
- name: Cache Poetry virtualenv
uses: actions/cache@v3
id: cache
with:
path: ~/.virtualenvs
key: venv--${{ matrix.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}-v1
restore-keys: |
venv--${{ matrix.os }}-${{ matrix.python-version }}-v1

- name: Set Poetry config
run: |
poetry config virtualenvs.in-project false
poetry config virtualenvs.path ~/.virtualenvs

# To force Pydantic v1, we install the package before the dependencies. This
# simulates a user who has Pydantic v1 required from another package.
- name: Install Pydantic v1
run: |
poetry remove pydantic-settings pydantic-extra-types
poetry add pydantic==1.10.7

# Install dependencies. This function will take the already installed Pydantic v1
- name: Install Dependencies
run: poetry install -E server

- name: Test with pytest
run: poetry run pytest --cov=geolib --cov-report xml:coverage-reports/coverage-hydrolib-core.xml --junitxml=xunit-reports/xunit-result-hydrolib-core.xml -m "unittest and not workinprogress"
4 changes: 2 additions & 2 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: ci
name: ci-pydantic-v2

on:
push:
Expand Down Expand Up @@ -45,7 +45,7 @@ jobs:
poetry config virtualenvs.path ~/.virtualenvs

- name: Install Dependencies
run: poetry install -E server
run: poetry install -E server -E pydantic-v2

- name: Test with pytest
run: poetry run pytest --cov=geolib --cov-report xml:coverage-reports/coverage-hydrolib-core.xml --junitxml=xunit-reports/xunit-result-hydrolib-core.xml -m "unittest and not workinprogress"
14 changes: 14 additions & 0 deletions docs/user/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ This package, unlike GEOLib+, tries to limit the number of

You don't need to install anything manually, as the pip installation should take care of it.

Combining GEOLib with pydantic v2
---------------------------------

GEOLib uses pydantic for validation of types and some parameters (min/max/defaults). The
latest version of pydantic (v2) has some breaking changes. When using pydantic v2, some
extra dependencies are required.To use GEOLib with pydantic v2, you can use the following
command to automatically install the extra dependencies::

$ pip install d-geolib[pydantic-v2]

When the extra dependencies are not installed, and pydantic v2 is used, an error will be
thrown when trying to import GEOLib. The error message will guide you in installing the
required packages yourself.

Get the Source Code
-------------------

Expand Down
24 changes: 24 additions & 0 deletions geolib/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""This module contaiuns the logic to support both pydantic v1 and v2
Copy link
Contributor

Choose a reason for hiding this comment

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

contains

"""
from pydantic import VERSION as PYDANTIC_VERSION

IS_PYDANTIC_V2 = PYDANTIC_VERSION.startswith("2.")

if IS_PYDANTIC_V2:
try:
from pydantic_extra_types.color import Color
from pydantic_settings import BaseSettings
except ImportError:
raise ImportError(
"Please install `pydantic-settings` and `pydantic-extra-types` to use geolib with "
"pydantic v2 with `pip install pydantic-settings pydantic-extra-types`. Alternatively, "
"you can install geolib with the extra required packages by running `pip install "
"geolib[pydantic-v2]`."
)
else:
# Example of how to raise a DeprecationWarning. Should be enabled when it is decided to remove
# support for pydantic v1 in a future release.
# raise DeprecationWarning(
# "Support for pydantic v1 will be removed in the next major release. Please upgrade to pydantic v2."
# )
pass
22 changes: 15 additions & 7 deletions geolib/models/base_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@

import requests
from pydantic import DirectoryPath, FilePath, HttpUrl, conlist
from pydantic.error_wrappers import ValidationError

from geolib._compat import IS_PYDANTIC_V2

if IS_PYDANTIC_V2:
from pydantic import ValidationError
else:
from pydantic.error_wrappers import ValidationError

from requests.auth import HTTPBasicAuth

from geolib.errors import CalculationError
Expand All @@ -29,8 +36,8 @@


class BaseModel(BaseDataClass, abc.ABC):
filename: Optional[Path]
datastructure: Optional[BaseModelStructure]
filename: Optional[Path] = None
datastructure: Optional[BaseModelStructure] = None

def execute(self, timeout_in_seconds: int = meta.timeout) -> "BaseModel":
"""Execute a Model and wait for `timeout` seconds.
Expand Down Expand Up @@ -85,7 +92,7 @@ def execute(self, timeout_in_seconds: int = meta.timeout) -> "BaseModel":
else:
error = self.get_error_context()
raise CalculationError(
process.returncode, error + " Path: " + str(output_filename.absolute)
process.returncode, error + " Path: " + str(output_filename.absolute())
)

def execute_remote(self, endpoint: HttpUrl) -> "BaseModel":
Expand Down Expand Up @@ -133,7 +140,7 @@ def serialize(
@property
def default_console_path(self) -> Path:
raise NotImplementedError("Implement in concrete classes.")

@property
def custom_console_path(self) -> Optional[Path]:
return None
Expand Down Expand Up @@ -186,21 +193,22 @@ def output(self):
Requires a successful execute.
"""
return self.datastructure.results

def get_meta_property(self, key: str) -> Optional[str]:
"""Get a metadata property from the input file."""
if hasattr(meta, key):
return meta.__getattribute__(key)
else:
return None

def set_meta_property(self, key: str, value: str) -> None:
"""Set a metadata property from the input file."""
if hasattr(meta, key):
meta.__setattr__(key, value)
else:
raise ValueError(f"Metadata property {key} does not exist.")


class BaseModelList(BaseDataClass):
"""Hold multiple models that can be executed in parallel.

Expand Down
24 changes: 19 additions & 5 deletions geolib/models/base_model_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

from pydantic import BaseModel

from geolib._compat import IS_PYDANTIC_V2

if IS_PYDANTIC_V2:
from pydantic import ConfigDict

from .meta import MetaData
from .validators import BaseValidator

Expand All @@ -12,11 +17,20 @@
class BaseDataClass(BaseModel):
"""Base class for *all* pydantic classes in GEOLib."""

class Config:
validate_assignment = True
arbitrary_types_allowed = True
validate_all = True
extra = settings.extra_fields
if IS_PYDANTIC_V2:
model_config = ConfigDict(
validate_assignment=True,
arbitrary_types_allowed=True,
validate_default=True,
extra=settings.extra_fields,
)
else:

class Config:
validate_assignment = True
arbitrary_types_allowed = True
validate_all = True
extra = settings.extra_fields


class BaseModelStructure(BaseDataClass, abc.ABC):
Expand Down
100 changes: 74 additions & 26 deletions geolib/models/dfoundations/dfoundations_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
from subprocess import CompletedProcess, run
from typing import BinaryIO, List, Optional, Type, Union

from pydantic import FilePath, confloat, conint
from pydantic import FilePath

from geolib._compat import IS_PYDANTIC_V2

if IS_PYDANTIC_V2:
from pydantic import Field
from typing_extensions import Annotated
else:
from pydantic import confloat, conint

from geolib.geometry import Point
from geolib.models import BaseDataClass, BaseModel, BaseModelStructure
Expand Down Expand Up @@ -47,15 +55,30 @@ class ModelOptions(BaseDataClass):
is_rigid: Bool = True

# Transformation
max_allowed_settlement_lim_state_str: confloat(ge=0, le=100000) = 0
max_allowed_rel_rotation_lim_state_str: conint(ge=1, le=10000) = 100
max_allowed_settlement_lim_state_serv: confloat(ge=0, le=100000) = 0
max_allowed_rel_rotation_lim_state_serv: conint(ge=1, le=10000) = 300

# Factors
factor_xi3: Optional[confloat(ge=0.01, le=10)] = None
factor_xi4: Optional[confloat(ge=0.01, le=10)] = None
ea_gem: Optional[confloat(ge=1)] = None
if IS_PYDANTIC_V2:
max_allowed_settlement_lim_state_str: Annotated[float, Field(ge=0, le=100000)] = 0
max_allowed_rel_rotation_lim_state_str: Annotated[
int, Field(ge=1, le=10000)
] = 100
max_allowed_settlement_lim_state_serv: Annotated[
float, Field(ge=0, le=100000)
] = 0
max_allowed_rel_rotation_lim_state_serv: Annotated[
int, Field(ge=1, le=10000)
] = 300
# Factors
factor_xi3: Optional[Annotated[float, Field(ge=0.01, le=10)]] = None
factor_xi4: Optional[Annotated[float, Field(ge=0.01, le=10)]] = None
ea_gem: Optional[Annotated[float, Field(ge=1)]] = None
else:
max_allowed_settlement_lim_state_str: confloat(ge=0, le=100000) = 0
max_allowed_rel_rotation_lim_state_str: conint(ge=1, le=10000) = 100
max_allowed_settlement_lim_state_serv: confloat(ge=0, le=100000) = 0
max_allowed_rel_rotation_lim_state_serv: conint(ge=1, le=10000) = 300
# Factors
factor_xi3: Optional[confloat(ge=0.01, le=10)] = None
factor_xi4: Optional[confloat(ge=0.01, le=10)] = None
ea_gem: Optional[confloat(ge=1)] = None

# Combined Model Options
is_suppress_qc_reduction: Bool = False
Expand All @@ -67,33 +90,52 @@ class ModelOptions(BaseDataClass):
use_extra_almere_rules: Bool = False

def _to_internal(self):
return InternalModelOptions(**self.dict())
if IS_PYDANTIC_V2:
return InternalModelOptions(**self.model_dump())
else:
return InternalModelOptions(**self.dict())

@classmethod
def model_type(cls):
raise NotImplementedError("Implement in concrete classes.")


class BearingPilesModel(ModelOptions):
factor_gamma_b: Optional[confloat(ge=1, le=100)] = None
factor_gamma_s: Optional[confloat(ge=1, le=100)] = None
factor_gamma_fnk: Optional[confloat(ge=-100, le=100)] = None
area: Optional[confloat(ge=0, le=100000)] = None
if IS_PYDANTIC_V2:
factor_gamma_b: Optional[Annotated[float, Field(ge=1, le=100)]] = None
factor_gamma_s: Optional[Annotated[float, Field(ge=1, le=100)]] = None
factor_gamma_fnk: Optional[Annotated[float, Field(ge=-100, le=100)]] = None
area: Optional[Annotated[float, Field(ge=0, le=100000)]] = None
else:
factor_gamma_b: Optional[confloat(ge=1, le=100)] = None
factor_gamma_s: Optional[confloat(ge=1, le=100)] = None
factor_gamma_fnk: Optional[confloat(ge=-100, le=100)] = None
area: Optional[confloat(ge=0, le=100000)] = None

@classmethod
def model_type(cls):
return ModelTypeEnum.BEARING_PILES


class TensionPilesModel(ModelOptions):
unit_weight_water: confloat(ge=0.01, le=20) = 9.81
use_compaction: Bool = False
surcharge: confloat(ge=0, le=1e7) = 0
use_piezometric_levels: Bool = True

factor_gamma_var: Optional[confloat(ge=0.01, le=100)] = None
factor_gamma_st: Optional[confloat(ge=0.01, le=100)] = None
factor_gamma_gamma: Optional[confloat(ge=0.01, le=100)] = None
if IS_PYDANTIC_V2:
unit_weight_water: Annotated[float, Field(ge=0.01, le=20)] = 9.81
use_compaction: Bool = False
surcharge: Annotated[float, Field(ge=0, le=1e7)] = 0
use_piezometric_levels: Bool = True

factor_gamma_var: Optional[Annotated[float, Field(ge=0.01, le=100)]] = None
factor_gamma_st: Optional[Annotated[float, Field(ge=0.01, le=100)]] = None
factor_gamma_gamma: Optional[Annotated[float, Field(ge=0.01, le=100)]] = None
else:
unit_weight_water: confloat(ge=0.01, le=20) = 9.81
use_compaction: Bool = False
surcharge: confloat(ge=0, le=1e7) = 0
use_piezometric_levels: Bool = True

factor_gamma_var: Optional[confloat(ge=0.01, le=100)] = None
factor_gamma_st: Optional[confloat(ge=0.01, le=100)] = None
factor_gamma_gamma: Optional[confloat(ge=0.01, le=100)] = None

@classmethod
def model_type(cls):
Expand Down Expand Up @@ -128,7 +170,10 @@ class CalculationOptions(BaseDataClass):
trajectory_interval: float = 0.50

def _to_internal(self):
fields = self.dict(exclude={"calculationtype"})
if IS_PYDANTIC_V2:
fields = self.model_dump(exclude=["calculationtype"])
else:
fields = self.dict(exclude={"calculationtype"})
return PreliminaryDesign(**fields)


Expand All @@ -151,7 +196,7 @@ def parser_provider_type(self) -> Type[DFoundationsParserProvider]:
@property
def default_console_path(self) -> Path:
return Path("DFoundations/DFoundations.exe")

@property
def custom_console_path(self) -> Path:
return self.get_meta_property("dfoundations_console_path")
Expand All @@ -169,7 +214,10 @@ def input(self):
return self.datastructure.input_data

def serialize(self, filename: Union[FilePath, BinaryIO]):
serializer = DFoundationsInputSerializer(ds=self.datastructure.dict())
if IS_PYDANTIC_V2:
serializer = DFoundationsInputSerializer(ds=self.datastructure.model_dump())
else:
serializer = DFoundationsInputSerializer(ds=self.datastructure.dict())
serializer.write(filename)

if isinstance(filename, Path):
Expand Down
Loading
Loading