Skip to content

Commit b8fcc0f

Browse files
ml-evseimrek
andauthored
Defer aiida imports where not used (#79)
* Only import aiida into config when type checking * Defer import in conversion step too * Run tests for 3.11 and 3.12 * Restructure aiida-plugin into config and main, and separate the functionality out * Skip AiiDA test when not installed * Define an extra without aiida and use it to run tests in CI without aiida available * tiny ci fix * skip serve test if aiida is not installed --------- Co-authored-by: Kristjan Eimre <kristjaneimre@gmail.com>
1 parent 9ce68f1 commit b8fcc0f

File tree

9 files changed

+1694
-1594
lines changed

9 files changed

+1694
-1594
lines changed

.github/workflows/ci.yml

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ concurrency:
1616
cancel-in-progress: true
1717

1818
jobs:
19-
tests:
20-
name: Test the `optimade-maker` package
19+
linting:
20+
name: Lint the `optimade-maker` package
2121
runs-on: ubuntu-latest
2222

2323
steps:
@@ -31,7 +31,7 @@ jobs:
3131
- name: Set up uv
3232
uses: astral-sh/setup-uv@v5
3333
with:
34-
version: "0.6.x"
34+
version: "0.8.x"
3535
enable-cache: true
3636

3737
- name: Install latest compatible versions of immediate dependencies
@@ -42,12 +42,46 @@ jobs:
4242
run: |
4343
uv run pre-commit run --all-files --show-diff-on-failure
4444
45+
tests:
46+
name: Test the `optimade-maker` package
47+
runs-on: ubuntu-latest
48+
strategy:
49+
matrix:
50+
python-version: ["3.10", "3.11", "3.12"]
51+
52+
steps:
53+
- uses: actions/checkout@v3
54+
55+
- name: Set up Python ${{ matrix.python-version }}
56+
uses: actions/setup-python@v4
57+
with:
58+
python-version: "${{ matrix.python-version }}"
59+
60+
- name: Set up uv
61+
uses: astral-sh/setup-uv@v5
62+
with:
63+
version: "0.8.x"
64+
enable-cache: true
65+
66+
- name: Install latest compatible versions of immediate dependencies
67+
run: |
68+
uv sync --locked --all-extras --dev
69+
4570
- name: Run tests
4671
run: |
4772
uv run pytest -vv --cov=./src/optimade_maker --cov-report=xml --cov-report=term ./tests
4873
74+
- name: Reinstall environment without aiida
75+
run: |
76+
uv sync --locked --extra core --extra tests
77+
78+
- name: Rerun tests without aiida
79+
run: |
80+
uv run pytest -vv --cov=./src/optimade_maker --cov-report=xml --cov-report=term ./tests --ignore=./tests/aiida
81+
4982
- name: Upload coverage
5083
uses: codecov/codecov-action@v3
84+
if: matrix.python-version == '3.10'
5185
with:
5286
name: project
5387
file: ./coverage.xml

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ ase = ["ase ~= 3.22"]
4444
pymatgen = ["pymatgen >= 2023.9"]
4545
pandas = ["pandas >= 1.5, < 3"]
4646
aiida = ["aiida-core >= 2.6.3"]
47+
core = ["optimade-maker[ase,pymatgen,pandas]"]
4748
ingest = ["optimade-maker[ase,pymatgen,pandas,aiida]"]
4849
tests = ["pytest~=8.3", "pytest-cov~=6.0"]
4950
dev = ["ruff", "pre-commit", "mypy"]

src/optimade_maker/aiida_plugin.py renamed to src/optimade_maker/aiida_plugin/__init__.py

Lines changed: 39 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -2,106 +2,47 @@
22

33
import warnings
44
from pathlib import Path
5-
from typing import TYPE_CHECKING, Any, Optional
6-
7-
import aiida
8-
from aiida import orm
9-
from aiida.common.exceptions import (
10-
CorruptStorage,
11-
IncompatibleStorageSchema,
12-
NotExistent,
13-
ProfileConfigurationError,
14-
UnreachableStorage,
15-
)
16-
from aiida.storage.sqlite_zip.backend import SqliteZipBackend
5+
from typing import TYPE_CHECKING, Any
6+
177
from optimade.adapters import Structure
188
from optimade.models import DataType, EntryResource
19-
from pydantic import BaseModel, Field, model_validator
209

2110
if TYPE_CHECKING:
22-
from .config import EntryConfig
23-
24-
25-
class AiidaEntryPath(BaseModel):
26-
"""Config to specify an AiiDA entry path."""
27-
28-
aiida_file: Optional[str] = Field(
29-
None, description="AiiDA file that contains the structures."
30-
)
31-
aiida_profile: Optional[str] = Field(
32-
None, description="AiiDA profile that contains the structures."
33-
)
34-
aiida_group: Optional[str] = Field(
35-
None,
36-
description="AiiDA group that contains the structures. 'None' assumes all StructureData nodes",
37-
)
11+
from aiida import orm
3812

39-
@model_validator(mode="before")
40-
@classmethod
41-
def check_file_or_profile(cls, values):
42-
if isinstance(values, list):
43-
# Skip validation for lists
44-
return values
45-
if not values.get("aiida_file") and not values.get("aiida_profile"):
46-
raise ValueError("Either 'aiida_file' or 'aiida_profile' must be defined.")
47-
if values.get("aiida_file") and values.get("aiida_profile"):
48-
raise ValueError(
49-
"Both 'aiida_file' and 'aiida_profile' cannot be defined at the same time."
50-
)
51-
return values
13+
from optimade_maker.config import EntryConfig
5214

15+
from optimade_maker.aiida_plugin.config import AiidaEntryPath
5316

54-
class AiidaQueryItem(BaseModel):
55-
"""An item representing a step in an AiiDA query, which allows
56-
* to project properties of the current node, or
57-
* to move to a connected node in the AiiDA provenance graph.
58-
In the case of querying for connected nodes, the usual AiiDA
59-
QueryBuilder filters and edge_filters can be applied.
60-
"""
6117

62-
project: Optional[str] = Field(
63-
None, description="The AiiDA attribute to project in the query."
64-
)
65-
incoming_node: Optional[str] = Field(
66-
None, description="Query for an incoming node of the specified type."
67-
)
68-
outgoing_node: Optional[str] = Field(
69-
None, description="Query for an outgoing node of the specified type."
70-
)
71-
filters: Optional[dict[Any, Any]] = Field(
72-
None, description="filters passed to AiiDA QueryBuilder."
73-
)
74-
edge_filters: Optional[dict[Any, Any]] = Field(
75-
None, description="edge_filters passed to AiiDA QueryBuilder."
76-
)
18+
def _check_aiida_import():
19+
"""Check if AiiDA is installed and raise an ImportError if not."""
20+
try:
21+
import aiida
7722

78-
@model_validator(mode="before")
79-
@classmethod
80-
def check_required_fields(cls, values):
81-
if not any(
82-
values.get(field) for field in ["project", "incoming_node", "outgoing_node"]
83-
):
84-
raise ValueError(
85-
"One of 'project', 'incoming_node', or 'outgoing_node' must be defined."
86-
)
87-
if values.get("filters") or values.get("edge_filters"):
88-
if not any(
89-
values.get(field) for field in ["incoming_node", "outgoing_node"]
90-
):
91-
raise ValueError(
92-
"'filters' and 'edge_filters' can only be defined for 'incoming_node' or 'outgoing_node'."
93-
)
94-
return values
23+
return aiida
24+
except ImportError:
25+
raise ImportError(
26+
"The AiiDA plugin requires the `aiida-core` package to be installed. "
27+
"Please install it with `pip install aiida-core`."
28+
)
9529

9630

9731
def query_for_aiida_structures(
9832
structure_group: str | None = None,
99-
) -> dict[str, orm.StructureData]:
33+
) -> dict[str, "orm.StructureData"]:
10034
"""
10135
Query for all aiida structures in the specified group
10236
(or all structures if no group specified)
10337
"""
38+
_check_aiida_import()
39+
from aiida import orm
40+
from aiida.common.exceptions import (
41+
NotExistent,
42+
)
43+
10444
qb = orm.QueryBuilder()
45+
10546
if structure_group:
10647
# check that the AiiDA group exists
10748
try:
@@ -123,6 +64,8 @@ def query_for_aiida_properties(
12364
Query for structure properties based on the custom aiida query format
12465
specified in the yaml file.
12566
"""
67+
aiida = _check_aiida_import()
68+
from aiida import orm
12669

12770
# query for the structures
12871
qb = orm.QueryBuilder()
@@ -185,6 +128,14 @@ def query_for_aiida_properties(
185128

186129

187130
def get_aiida_profile_from_file(path: Path):
131+
_check_aiida_import()
132+
from aiida.common.exceptions import (
133+
CorruptStorage,
134+
IncompatibleStorageSchema,
135+
UnreachableStorage,
136+
)
137+
from aiida.storage.sqlite_zip.backend import SqliteZipBackend
138+
188139
if not path.exists():
189140
raise FileNotFoundError(f"File not found: {path}")
190141

@@ -200,6 +151,8 @@ def get_aiida_profile_from_file(path: Path):
200151
def convert_aiida_structure_to_optimade(
201152
aiida_structure: orm.StructureData,
202153
) -> dict:
154+
_check_aiida_import()
155+
203156
try:
204157
ase_structure = aiida_structure.get_ase()
205158
optimade_entry = Structure.ingest_from(ase_structure).entry.model_dump()
@@ -242,6 +195,9 @@ def construct_entries_from_aiida(
242195
entry_config: EntryConfig,
243196
provider_prefix: str,
244197
) -> dict[str, dict]:
198+
aiida = _check_aiida_import()
199+
from aiida.common.exceptions import ProfileConfigurationError
200+
245201
if not isinstance(entry_config.entry_paths, AiidaEntryPath):
246202
raise RuntimeError("entry_paths is not AiiDA-specific.")
247203
if entry_config.entry_type != "structures":
@@ -278,4 +234,5 @@ def construct_entries_from_aiida(
278234
optimade_entries[uuid]["attributes"][prop_name] = (
279235
_convert_property_type(prop_def.type, prop)
280236
)
237+
281238
return optimade_entries
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from typing import Any, Optional
2+
3+
from pydantic import BaseModel, Field, model_validator
4+
5+
6+
class AiidaEntryPath(BaseModel):
7+
"""Config to specify an AiiDA entry path."""
8+
9+
aiida_file: Optional[str] = Field(
10+
None, description="AiiDA file that contains the structures."
11+
)
12+
aiida_profile: Optional[str] = Field(
13+
None, description="AiiDA profile that contains the structures."
14+
)
15+
aiida_group: Optional[str] = Field(
16+
None,
17+
description="AiiDA group that contains the structures. 'None' assumes all StructureData nodes",
18+
)
19+
20+
@model_validator(mode="before")
21+
@classmethod
22+
def check_file_or_profile(cls, values):
23+
if isinstance(values, list):
24+
# Skip validation for lists
25+
return values
26+
if not values.get("aiida_file") and not values.get("aiida_profile"):
27+
raise ValueError("Either 'aiida_file' or 'aiida_profile' must be defined.")
28+
if values.get("aiida_file") and values.get("aiida_profile"):
29+
raise ValueError(
30+
"Both 'aiida_file' and 'aiida_profile' cannot be defined at the same time."
31+
)
32+
return values
33+
34+
35+
class AiidaQueryItem(BaseModel):
36+
"""An item representing a step in an AiiDA query, which allows
37+
* to project properties of the current node, or
38+
* to move to a connected node in the AiiDA provenance graph.
39+
In the case of querying for connected nodes, the usual AiiDA
40+
QueryBuilder filters and edge_filters can be applied.
41+
"""
42+
43+
project: Optional[str] = Field(
44+
None, description="The AiiDA attribute to project in the query."
45+
)
46+
incoming_node: Optional[str] = Field(
47+
None, description="Query for an incoming node of the specified type."
48+
)
49+
outgoing_node: Optional[str] = Field(
50+
None, description="Query for an outgoing node of the specified type."
51+
)
52+
filters: Optional[dict[Any, Any]] = Field(
53+
None, description="filters passed to AiiDA QueryBuilder."
54+
)
55+
edge_filters: Optional[dict[Any, Any]] = Field(
56+
None, description="edge_filters passed to AiiDA QueryBuilder."
57+
)
58+
59+
@model_validator(mode="before")
60+
@classmethod
61+
def check_required_fields(cls, values):
62+
if not any(
63+
values.get(field) for field in ["project", "incoming_node", "outgoing_node"]
64+
):
65+
raise ValueError(
66+
"One of 'project', 'incoming_node', or 'outgoing_node' must be defined."
67+
)
68+
if values.get("filters") or values.get("edge_filters"):
69+
if not any(
70+
values.get(field) for field in ["incoming_node", "outgoing_node"]
71+
):
72+
raise ValueError(
73+
"'filters' and 'edge_filters' can only be defined for 'incoming_node' or 'outgoing_node'."
74+
)
75+
return values

src/optimade_maker/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
__version__ = "0.1.0"
1111

1212
from pathlib import Path
13-
from typing import Optional
13+
from typing import Optional, Union
1414

1515
import yaml
1616
from pydantic import BaseModel, Field
1717

18-
from .aiida_plugin import AiidaEntryPath, AiidaQueryItem
18+
from .aiida_plugin.config import AiidaEntryPath, AiidaQueryItem
1919

2020

2121
class UnsupportedConfigVersion(RuntimeError): ...
@@ -87,7 +87,7 @@ class EntryConfig(BaseModel):
8787
entry_type: str = Field(
8888
description="The OPTIMADE entry type, e.g. `structures` or `references`."
8989
)
90-
entry_paths: list[ParsedFiles] | AiidaEntryPath = Field(
90+
entry_paths: Union[list[ParsedFiles], AiidaEntryPath] = Field(
9191
description=(
9292
"A list of paths patterns to parse, provided relative to the top-level of the archive entry, "
9393
"after any compressed locations have been decompressed. Supports Python glob syntax for wildcards."

src/optimade_maker/convert.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from optimade.models import EntryInfoResource, EntryResource
1717
from optimade.server.schemas import ENTRY_INFO_SCHEMAS, retrieve_queryable_properties
1818

19-
from .aiida_plugin import AiidaEntryPath, construct_entries_from_aiida
19+
from .aiida_plugin.config import AiidaEntryPath
2020
from .config import Config, EntryConfig, JSONLConfig, ParsedFiles, PropertyDefinition
2121

2222
PROVIDER_PREFIX = os.environ.get("OPTIMAKE_PROVIDER_PREFIX", "optimake")
@@ -613,11 +613,23 @@ def construct_entries(
613613
provider_prefix: str,
614614
limit: int | None = None,
615615
) -> dict[str, dict]:
616-
if isinstance(entry_config.entry_paths, AiidaEntryPath):
616+
try:
617+
is_aiida_archive = isinstance(entry_config.entry_paths, AiidaEntryPath)
618+
619+
except Exception:
620+
is_aiida_archive = False
621+
622+
if is_aiida_archive:
623+
try:
624+
from .aiida_plugin import construct_entries_from_aiida
625+
except ImportError:
626+
raise RuntimeError(
627+
"The AiiDA plugin is not installed. Please install it with `pip install optimake[aiida]`."
628+
)
629+
617630
optimade_entries = construct_entries_from_aiida(
618631
archive_path, entry_config, provider_prefix
619632
)
620-
621633
else:
622634
optimade_entries = construct_entries_from_files(
623635
archive_path, entry_config, provider_prefix, limit=limit

0 commit comments

Comments
 (0)