Skip to content

Commit 98b0789

Browse files
authored
Migrate to pydantic v2 (#182)
Update dependencies to support pydantic v2. Run bump-pydantic and use typing.Annotated. Fix custom OPTIMADEUrl type. Update everything to the pydantic v2 way, meaning use classmethods __get_pydantic_core_schema__ and __get_pydantic_json_schema__, drastically minimizing the class, but also making it a bit more opaque. Use new pydantic v2 BaseModel method names. Remove dependency on sessions containing Python objects - rely only on pure JSON types in the session object. Remove model2dict() utility function. What it does (recursively dump all models as dicts) is the default behaviour in pydantic v2 now, so there is no need for this functionality. Add scheme to OPTIMADEUrl It was found to be necessary when used in certain core OTEAPI strategies. It may be relevant to have all the pydantic_core.Url properties readily available. Also, make the OPTIMADE accessUrl further variable by making the host a changeable environment variable (OPTIMADE_HOST). Add Python 3.11 support. Cap supported Python versions at 3.11 due to DLite not supporting Python 3.12 and above. Use oteapi-services version prior to session/parser upgrade.
1 parent 5cae62f commit 98b0789

19 files changed

Lines changed: 791 additions & 713 deletions

File tree

.github/utils/end2end_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def main(oteapi_url: str) -> None:
6767

6868
source = client.create_dataresource(
6969
accessService="OPTIMADE",
70-
accessUrl=f"http://localhost:{os.getenv('OPTIMADE_PORT', '5000')}/",
70+
accessUrl=OPTIMADE_URL,
7171
configuration=config,
7272
)
7373

@@ -133,6 +133,7 @@ def main(oteapi_url: str) -> None:
133133
PORT = os.getenv("OTEAPI_PORT", "8080")
134134
OTEAPI_SERVICE_URL = f"http://localhost:{PORT}"
135135
OTEAPI_PREFIX = os.getenv("OTEAPI_prefix", "/api/v1") # noqa: SIM112
136+
OPTIMADE_URL = f"http://{os.getenv('OPTIMADE_HOST', 'localhost')}:{os.getenv('OPTIMADE_PORT', '5000')}/"
136137
if "OTEAPI_prefix" not in os.environ:
137138
# Set environment variables
138139
os.environ["OTEAPI_prefix"] = OTEAPI_PREFIX # noqa: SIM112

.github/workflows/ci_tests.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
strategy:
4141
fail-fast: false
4242
matrix:
43-
python-version: ["3.9", "3.10"]
43+
python-version: ["3.9", "3.10", "3.11"]
4444
os:
4545
- ["ubuntu-latest", "linux"]
4646
- ["windows-latest", "windows"]
@@ -132,9 +132,11 @@ jobs:
132132
.github/utils/wait_for_it.sh localhost:${OTEAPI_PORT} -t 240
133133
sleep 5
134134
env:
135-
# Pin to 1.20231108.329 until #163 has been closed.
136-
# Related issue: https://github.com/SINTEF/oteapi-optimade/issues/187
137-
DOCKER_OTEAPI_VERSION: '1.20231108.329'
135+
# Use version 1.20240228.345 until
136+
# https://github.com/SINTEF/oteapi-optimade/issues/213 has been resolved.
137+
# See also
138+
# https://github.com/EMMC-ASBL/oteapi-services/tree/8306d7212419764fb87e5cefdb5a869db9c68ef7?tab=readme-ov-file#open-translation-environment-ote-api
139+
DOCKER_OTEAPI_VERSION: '1.20240228.345'
138140

139141
- name: Run end-2-end tests
140142
run: python .github/utils/end2end_test.py

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ repos:
5454
exclude: ^tests/.*$
5555
additional_dependencies:
5656
- "types-requests"
57-
- "pydantic<2"
57+
- "pydantic>=2,<3"
5858

5959
- repo: https://github.com/SINTEF/ci-cd
6060
rev: v2.7.2

docs/api_reference/utils.md

Lines changed: 0 additions & 3 deletions
This file was deleted.

oteapi_optimade/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
from __future__ import annotations
1010

11+
import logging
12+
1113
__version__ = "0.4.2"
1214
__author__ = "Casper Welzel Andersen"
1315
__author_email__ = "casper.w.andersen@sintef.no"
16+
17+
logging.getLogger("oteapi_optimade").setLevel(logging.DEBUG)

oteapi_optimade/dlite/parse.py

Lines changed: 114 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@
22

33
from __future__ import annotations
44

5+
import importlib
56
import logging
67
from pathlib import Path
78
from typing import TYPE_CHECKING
89

910
import dlite
1011
from optimade.adapters import Structure
11-
from optimade.models import StructureResponseMany, StructureResponseOne, Success
12+
from optimade.models import (
13+
Response as OPTIMADEResponse,
14+
)
15+
from optimade.models import (
16+
StructureResponseMany,
17+
StructureResponseOne,
18+
Success,
19+
)
1220
from oteapi.models import SessionUpdate
1321
from oteapi_dlite.models import DLiteSessionUpdate
1422
from oteapi_dlite.utils import get_collection, update_collection
@@ -89,7 +97,14 @@ def get(
8997
context from services.
9098
9199
"""
92-
session = OPTIMADEParseStrategy(self.parse_config).get(session)
100+
generic_parse_config = self.parse_config.model_copy(
101+
update={
102+
"mediaType": self.parse_config.mediaType.lower().replace(
103+
"+dlite", "+json"
104+
)
105+
}
106+
).model_dump()
107+
session = OPTIMADEParseStrategy(generic_parse_config).get(session)
93108

94109
entities_path = Path(__file__).resolve().parent.resolve() / "entities"
95110

@@ -111,108 +126,117 @@ def get(
111126
f"yaml://{entities_path}/OPTIMADEStructureSpecies.yaml"
112127
)
113128

129+
if not all(
130+
_ in session for _ in ("optimade_response", "optimade_response_model")
131+
):
132+
base_error_message = (
133+
"Could not retrieve response from OPTIMADE parse strategy."
134+
)
135+
LOGGER.error(
136+
"%s\n"
137+
"optimade_response=%r\n"
138+
"optimade_response_model=%r\n"
139+
"session fields=%r",
140+
base_error_message,
141+
session.get("optimade_response"),
142+
session.get("optimade_response_model"),
143+
list(session.keys()),
144+
)
145+
raise OPTIMADEParseError(base_error_message)
146+
147+
optimade_response_model_module, optimade_response_model_name = session.get(
148+
"optimade_response_model"
149+
)
150+
optimade_response_dict = session.get("optimade_response")
151+
114152
error_message_supporting_only_structures = (
115153
"The DLite OPTIMADE Parser currently only supports structures entities."
116154
)
117155

118-
if self.parse_config.configuration.return_object:
119-
# The response is given as a "proper" pydantic data model instance
120-
121-
if "optimade_response_object" not in session:
122-
error_message = (
123-
"'optimade_response_object' was expected to be present in the "
124-
"session."
156+
# Parse response using the provided model
157+
try:
158+
optimade_response_model: type[OPTIMADEResponse] = getattr(
159+
importlib.import_module(optimade_response_model_module),
160+
optimade_response_model_name,
161+
)
162+
optimade_response = optimade_response_model(**optimade_response_dict)
163+
except (ImportError, AttributeError) as exc:
164+
base_error_message = "Could not import the response model."
165+
LOGGER.error(
166+
"%s\n"
167+
"ImportError: %s\n"
168+
"optimade_response_model_module=%r\n"
169+
"optimade_response_model_name=%r",
170+
base_error_message,
171+
exc,
172+
optimade_response_model_module,
173+
optimade_response_model_name,
174+
)
175+
raise OPTIMADEParseError(base_error_message) from exc
176+
except ValidationError as exc:
177+
base_error_message = "Could not validate the response model."
178+
LOGGER.error(
179+
"%s\n"
180+
"ValidationError: %s\n"
181+
"optimade_response_model_module=%r\n"
182+
"optimade_response_model_name=%r",
183+
base_error_message,
184+
exc,
185+
optimade_response_model_module,
186+
optimade_response_model_name,
187+
)
188+
raise OPTIMADEParseError(base_error_message) from exc
189+
190+
# Currently, only "structures" entries are supported and handled
191+
if isinstance(optimade_response, StructureResponseMany):
192+
structures = [
193+
(
194+
Structure(entry)
195+
if isinstance(entry, dict)
196+
else Structure(entry.model_dump())
125197
)
126-
raise ValueError(error_message)
127-
128-
# Currently, only "structures" entries are supported and handled
129-
if isinstance(session.optimade_response_object, StructureResponseMany):
198+
for entry in optimade_response.data
199+
]
200+
elif isinstance(optimade_response, StructureResponseOne):
201+
structures = [
202+
(
203+
Structure(optimade_response.data)
204+
if isinstance(optimade_response.data, dict)
205+
else Structure(optimade_response.data.model_dump())
206+
)
207+
]
208+
elif isinstance(optimade_response, Success):
209+
if isinstance(optimade_response.data, dict):
210+
structures = [Structure(optimade_response.data)]
211+
elif isinstance(optimade_response.data, BaseModel):
212+
structures = [Structure(optimade_response.data.model_dump())]
213+
elif isinstance(optimade_response.data, list):
130214
structures = [
131215
(
132216
Structure(entry)
133217
if isinstance(entry, dict)
134-
else Structure(entry.dict())
218+
else Structure(entry.model_dump())
135219
)
136-
for entry in session.optimade_response_object.data
220+
for entry in optimade_response.data
137221
]
138-
elif isinstance(session.optimade_response_object, StructureResponseOne):
139-
structures = [
140-
(
141-
Structure(session.optimade_response_object.data)
142-
if isinstance(session.optimade_response_object.data, dict)
143-
else Structure(session.optimade_response_object.data.dict())
144-
)
145-
]
146-
elif isinstance(session.optimade_response_object, Success):
147-
if isinstance(session.optimade_response_object.data, dict):
148-
structures = [Structure(session.optimade_response_object.data)]
149-
elif isinstance(session.optimade_response_object.data, BaseModel):
150-
structures = [
151-
Structure(session.optimade_response_object.data.dict())
152-
]
153-
elif isinstance(session.optimade_response_object.data, list):
154-
structures = [
155-
(
156-
Structure(entry)
157-
if isinstance(entry, dict)
158-
else Structure(entry.dict())
159-
)
160-
for entry in session.optimade_response_object.data
161-
]
162-
else:
163-
LOGGER.debug(
164-
"Could not determine what to do with `data`. Type %s.",
165-
type(session.optimade_response_object.data),
166-
)
167-
error_message = "Could not parse `data` entry in response."
168-
raise OPTIMADEParseError(error_message)
169222
else:
170-
LOGGER.debug(
171-
"Got currently unsupported response type %s. Only structures are "
172-
"supported.",
173-
session.optimade_response_object.__class__.__name__,
223+
LOGGER.error(
224+
"Could not determine what to do with `data`. Type %s.",
225+
type(optimade_response.data),
174226
)
175-
raise OPTIMADEParseError(error_message_supporting_only_structures)
227+
error_message = "Could not parse `data` entry in response."
228+
raise OPTIMADEParseError(error_message)
176229
else:
177-
# The response is given as pure Python dictionary
178-
179-
if "optimade_response" not in session:
180-
error_message = (
181-
"'optimade_response' was expected to be present in the session."
182-
)
183-
raise ValueError(error_message)
184-
185-
if not session.optimade_response or "data" not in session.optimade_response:
186-
LOGGER.debug("Not a successful response - no 'data' entry found.")
187-
return session
188-
189-
if isinstance(session.optimade_response["data"], list):
190-
try:
191-
structures = [
192-
Structure(entry) for entry in session.optimade_response["data"]
193-
]
194-
except ValidationError as exc:
195-
LOGGER.debug(
196-
"Could not parse list of 'data' entries as structures."
197-
)
198-
raise OPTIMADEParseError(
199-
error_message_supporting_only_structures
200-
) from exc
201-
elif session.optimade_response is not None:
202-
try:
203-
structures = [Structure(session.optimade_response["data"])]
204-
except ValidationError as exc:
205-
LOGGER.debug("Could not parse single 'data' entry as a structure.")
206-
raise OPTIMADEParseError(
207-
error_message_supporting_only_structures
208-
) from exc
209-
else:
210-
LOGGER.debug("Could not parse 'data' entries as structures.") # type: ignore[unreachable]
211-
raise OPTIMADEParseError(error_message_supporting_only_structures)
230+
LOGGER.error(
231+
"Got currently unsupported response type %s. Only structures are "
232+
"supported.",
233+
optimade_response_model_name,
234+
)
235+
raise OPTIMADEParseError(error_message_supporting_only_structures)
212236

237+
# DLite-fy OPTIMADE structures
213238
dlite_collection = get_collection(session)
214239

215-
# DLite-fy OPTIMADE structures
216240
for structure in structures:
217241
new_structure_attributes: dict[str, Any] = {}
218242

@@ -226,7 +250,7 @@ def get(
226250
for assembly in structure.attributes.assemblies:
227251
# Ensure we're dealing with a normal Python dict
228252
assembly_dict = (
229-
assembly.dict(exclude_none=True)
253+
assembly.model_dump(exclude_none=True)
230254
if isinstance(assembly, BaseModel)
231255
else assembly
232256
)
@@ -252,7 +276,7 @@ def get(
252276
for species_individual in structure.attributes.species:
253277
# Ensure we're dealing with a normal Python dict
254278
species_individual_dict = (
255-
species_individual.dict(exclude_none=True)
279+
species_individual.model_dump(exclude_none=True)
256280
if isinstance(species_individual, BaseModel)
257281
else species_individual
258282
)
@@ -274,7 +298,7 @@ def get(
274298

275299
# Attributes
276300
new_structure_attributes.update(
277-
structure.attributes.dict(
301+
structure.attributes.model_dump(
278302
exclude={"species", "assemblies", "nelements", "nsites"}
279303
)
280304
)

0 commit comments

Comments
 (0)