"""
25 |
26 | test_id = "random_string_that_is_not_in_test_data"
27 | request_str = f"/references/{test_id}"
28 | response_cls = ReferenceResponseOne
29 |
30 | def test_references_endpoint_data(self):
31 | """Check known properties/attributes for successful response"""
32 | assert "data" in self.json_response
33 | assert "meta" in self.json_response
34 | assert self.json_response["data"] is None
35 | assert self.json_response["meta"]["data_returned"] == 0
36 | assert not self.json_response["meta"]["more_data_available"]
37 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing and getting help
2 |
3 | If you run into any problems using this package, or if you have a question, suggestion or feedback, then please raise an issue on [GitHub](https://github.com/Materials-Consortia/optimade-python-tools/issues/new).
4 |
5 | The [Materials Consortia](https://github.com/Materials-Consortia) is very open to contributions across all of its packages.
6 | This may be anything from simple feedback and raising [new issues](https://github.com/Materials-Consortia/optimade-python-tools/issues/new) to creating [new PRs](https://github.com/Materials-Consortia/optimade-python-tools/compare).
7 |
8 | If you are interested in contributing but don't know where to begin, some issues have been marked with the [good first issue](https://github.com/Materials-Consortia/optimade-python-tools/labels/good%20first%20issue) label, typically where an isolated enhancement has a concrete suggestion.
9 | Simply add a comment under an issue if you are interested in tackling it!
10 |
11 | Recommendations for setting up a development environment for this package can be found in the [Installation instructions](https://www.optimade.org/optimade-python-tools/INSTALL/#full-development-installation).
12 |
13 | More broadly, if you would like to ask questions or contact the consortium about creating an OPTIMADE implementation for a new database, then please read the relevant "get involved" section on the [OPTIMADE website](https://www.optimade.org/#get-involved).
14 |
--------------------------------------------------------------------------------
/docs/deployment/multiple_apps.md:
--------------------------------------------------------------------------------
1 | # Serve multiple OPTIMADE APIs within a single python process
2 |
3 | One can start multiple OPTIMADE API apps within a single FastAPI instance and mount them at different subpaths.
4 |
5 | This is enabled by the `create_app` method that allows to override parts of the configuration for each specific app, and set up separate loggers.
6 |
7 | Here's a simple example that sets up two OPTIMADE APIs and an Index Meta-DB respectively at subpaths `/app1`, `/app2` and `/idx`.
8 |
9 | ```python
10 | from fastapi import FastAPI
11 |
12 | from optimade.server.config import ServerConfig
13 | from optimade.server.create_app import create_app
14 |
15 | parent_app = FastAPI()
16 |
17 | base_url = "http://127.0.0.1:8000"
18 |
19 | conf1 = ServerConfig()
20 | conf1.base_url = f"{base_url}/app1"
21 | conf1.mongo_database = "optimade_1"
22 | app1 = create_app(conf1, logger_tag="app1")
23 | parent_app.mount("/app1", app1)
24 |
25 | conf2 = ServerConfig()
26 | conf2.base_url = f"{base_url}/app2"
27 | conf2.mongo_database = "optimade_2"
28 | app2 = create_app(conf2, logger_tag="app2")
29 | parent_app.mount("/app2", app2)
30 |
31 | conf3 = ServerConfig()
32 | conf3.base_url = f"{base_url}/idx"
33 | conf3.mongo_database = "optimade_idx"
34 | app3 = create_app(conf3, index=True, logger_tag="idx")
35 | parent_app.mount("/idx", app3)
36 | ```
37 |
38 | Note that `ServerConfig()` returns the configuration based on the usual sources - env variables or json file (see [Configuration](../configuration.md) section).
39 |
--------------------------------------------------------------------------------
/optimade_config.json:
--------------------------------------------------------------------------------
1 | {
2 | "debug": false,
3 | "default_db": "test_server",
4 | "base_url": "http://localhost:5000",
5 | "implementation": {
6 | "name": "Example implementation",
7 | "source_url": "https://github.com/Materials-Consortia/optimade-python-tools",
8 | "issue_tracker": "https://github.com/Materials-Consortia/optimade-python-tools/issues",
9 | "homepage": "https://optimade.org/optimade-python-tools",
10 | "maintainer": {"email": "dev@optimade.org"}
11 | },
12 | "provider": {
13 | "name": "Example provider",
14 | "description": "Provider used for examples, not to be assigned to a real database",
15 | "prefix": "exmpl",
16 | "homepage": "https://example.com"
17 | },
18 | "index_base_url": "http://localhost:5001",
19 | "provider_fields": {
20 | "structures": [
21 | "band_gap",
22 | {"name": "chemsys", "type": "string", "description": "A string representing the chemical system in an ordered fashion"}
23 | ]
24 | },
25 | "aliases": {
26 | "structures": {
27 | "id": "task_id",
28 | "immutable_id": "_id",
29 | "chemical_formula_descriptive": "pretty_formula",
30 | "chemical_formula_reduced": "pretty_formula",
31 | "chemical_formula_anonymous": "formula_anonymous"
32 | }
33 | },
34 | "length_aliases": {
35 | "structures": {
36 | "chemsys": "nelements"
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/filtertransformers/test_base.py:
--------------------------------------------------------------------------------
1 | """Tests for optimade.filtertransformers.BaseTransformer"""
2 |
3 |
4 | def test_quantity_builder() -> None:
5 | from optimade.filtertransformers.base_transformer import BaseTransformer, Quantity
6 | from optimade.server.mappers import StructureMapper
7 |
8 | class DummyTransformer(BaseTransformer):
9 | pass
10 |
11 | class AwkwardMapper(StructureMapper):
12 | ALIASES = (("elements", "my_elements"), ("nelements", "nelem"))
13 | LENGTH_ALIASES = (
14 | ("chemsys", "nelements"),
15 | ("cartesian_site_positions", "nsites"),
16 | ("elements", "nelements"),
17 | )
18 | PROVIDER_FIELDS = ("chemsys",)
19 |
20 | m = AwkwardMapper()
21 | t = DummyTransformer(mapper=m)
22 |
23 | assert "_exmpl_chemsys" in t.quantities
24 | assert isinstance(t.quantities["_exmpl_chemsys"], Quantity)
25 | assert t.quantities["_exmpl_chemsys"].name == "_exmpl_chemsys"
26 | assert t.quantities["_exmpl_chemsys"].backend_field == "chemsys"
27 |
28 | assert isinstance(t.quantities["_exmpl_chemsys"].length_quantity, Quantity)
29 | assert t.quantities["_exmpl_chemsys"].length_quantity.name == "nelements"
30 | assert t.quantities["_exmpl_chemsys"].length_quantity.backend_field == "nelem"
31 |
32 | assert isinstance(t.quantities["elements"], Quantity)
33 | assert isinstance(t.quantities["elements"].length_quantity, Quantity)
34 | assert t.quantities["elements"].length_quantity.backend_field == "nelem"
35 |
--------------------------------------------------------------------------------
/tests/adapters/references/raw_test_references.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "dijkstra1968",
4 | "type": "references",
5 | "attributes": {
6 | "last_modified": "2019-11-12T14:24:37.331000",
7 | "authors": [
8 | {
9 | "name": "Edsger W. Dijkstra",
10 | "firstname": "Edsger",
11 | "lastname": "Dijkstra"
12 | }
13 | ],
14 | "doi": "10.1145/362929.362947",
15 | "journal": "Communications of the ACM",
16 | "title": "Go To Statement Considered Harmful",
17 | "year": "1968"
18 | }
19 | },
20 | {
21 | "id": "maddox1988",
22 | "type": "references",
23 | "attributes": {
24 | "last_modified": "2019-11-27T14:24:37.331000",
25 | "authors": [
26 | {
27 | "name": "John Maddox",
28 | "firstname": "John",
29 | "lastname": "Maddox"
30 | }
31 | ],
32 | "doi": "10.1038/335201a0",
33 | "journal": "Nature",
34 | "title": "Crystals From First Principles",
35 | "year": "1988"
36 | }
37 | },
38 | {
39 | "id": "dummy/2019",
40 | "type": "references",
41 | "attributes": {
42 | "last_modified": "2019-11-23T14:24:37.332000",
43 | "authors": [
44 | {
45 | "name": "A Nother",
46 | "firstname": "A",
47 | "lastname": "Nother"
48 | }
49 | ],
50 | "doi": "10.1038/00000",
51 | "journal": "JACS",
52 | "title": "Dummy reference that should remain orphaned from all structures for testing purposes",
53 | "year": "2019"
54 | }
55 | }
56 | ]
57 |
--------------------------------------------------------------------------------
/tests/models/test_entries.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from pydantic import ValidationError
3 |
4 | from optimade.models.entries import EntryRelationships
5 |
6 |
7 | def test_simple_relationships():
8 | """Make sure relationship resources are added to the correct relationship"""
9 |
10 | good_relationships = (
11 | {"references": {"data": [{"id": "dijkstra1968", "type": "references"}]}},
12 | {"structures": {"data": [{"id": "dijkstra1968", "type": "structures"}]}},
13 | )
14 | for relationship in good_relationships:
15 | EntryRelationships(**relationship)
16 |
17 | bad_relationships = (
18 | {"references": {"data": [{"id": "dijkstra1968", "type": "structures"}]}},
19 | {"structures": {"data": [{"id": "dijkstra1968", "type": "references"}]}},
20 | )
21 | for relationship in bad_relationships:
22 | with pytest.raises(ValidationError):
23 | EntryRelationships(**relationship)
24 |
25 |
26 | def test_advanced_relationships():
27 | """Make sure the rules for the base resource 'meta' field are upheld"""
28 |
29 | relationship = {
30 | "references": {
31 | "data": [
32 | {
33 | "id": "dijkstra1968",
34 | "type": "references",
35 | "meta": {
36 | "description": "Reference for the search algorithm Dijkstra."
37 | },
38 | }
39 | ]
40 | }
41 | }
42 | EntryRelationships(**relationship)
43 |
44 | relationship = {
45 | "references": {
46 | "data": [{"id": "dijkstra1968", "type": "references", "meta": {}}]
47 | }
48 | }
49 | with pytest.raises(ValidationError):
50 | EntryRelationships(**relationship)
51 |
--------------------------------------------------------------------------------
/optimade/server/routers/references.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated, Any
2 |
3 | from fastapi import APIRouter, Depends, Request
4 |
5 | from optimade.models import (
6 | ReferenceResponseMany,
7 | ReferenceResponseOne,
8 | )
9 | from optimade.server.config import ServerConfig
10 | from optimade.server.query_params import EntryListingQueryParams, SingleEntryQueryParams
11 | from optimade.server.routers.utils import get_entries, get_single_entry
12 | from optimade.server.schemas import ERROR_RESPONSES
13 |
14 | router = APIRouter(redirect_slashes=True)
15 |
16 | CONFIG = ServerConfig()
17 |
18 |
19 | @router.get(
20 | "/references",
21 | response_model=ReferenceResponseMany
22 | if CONFIG.validate_api_response
23 | else dict[str, Any],
24 | response_model_exclude_unset=True,
25 | tags=["References"],
26 | responses=ERROR_RESPONSES,
27 | )
28 | def get_references(
29 | request: Request, params: Annotated[EntryListingQueryParams, Depends()]
30 | ) -> dict[str, Any]:
31 | references_coll = request.app.state.entry_collections.get("references")
32 | return get_entries(
33 | collection=references_coll,
34 | request=request,
35 | params=params,
36 | )
37 |
38 |
39 | @router.get(
40 | "/references/{entry_id:path}",
41 | response_model=ReferenceResponseOne
42 | if CONFIG.validate_api_response
43 | else dict[str, Any],
44 | response_model_exclude_unset=True,
45 | tags=["References"],
46 | responses=ERROR_RESPONSES,
47 | )
48 | def get_single_reference(
49 | request: Request,
50 | entry_id: str,
51 | params: Annotated[SingleEntryQueryParams, Depends()],
52 | ) -> dict[str, Any]:
53 | references_coll = request.app.state.entry_collections.get("references")
54 | return get_single_entry(
55 | collection=references_coll,
56 | entry_id=entry_id,
57 | request=request,
58 | params=params,
59 | )
60 |
--------------------------------------------------------------------------------
/optimade/server/routers/structures.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated, Any
2 |
3 | from fastapi import APIRouter, Depends, Request
4 |
5 | from optimade.models import (
6 | StructureResponseMany,
7 | StructureResponseOne,
8 | )
9 | from optimade.server.config import ServerConfig
10 | from optimade.server.query_params import EntryListingQueryParams, SingleEntryQueryParams
11 | from optimade.server.routers.utils import get_entries, get_single_entry
12 | from optimade.server.schemas import ERROR_RESPONSES
13 |
14 | router = APIRouter(redirect_slashes=True)
15 |
16 | CONFIG = ServerConfig()
17 |
18 |
19 | @router.get(
20 | "/structures",
21 | response_model=StructureResponseMany
22 | if CONFIG.validate_api_response
23 | else dict[str, Any],
24 | response_model_exclude_unset=True,
25 | tags=["Structures"],
26 | responses=ERROR_RESPONSES,
27 | )
28 | def get_structures(
29 | request: Request, params: Annotated[EntryListingQueryParams, Depends()]
30 | ) -> dict[str, Any]:
31 | structures_coll = request.app.state.entry_collections.get("structures")
32 | return get_entries(
33 | collection=structures_coll,
34 | request=request,
35 | params=params,
36 | )
37 |
38 |
39 | @router.get(
40 | "/structures/{entry_id:path}",
41 | response_model=StructureResponseOne
42 | if CONFIG.validate_api_response
43 | else dict[str, Any],
44 | response_model_exclude_unset=True,
45 | tags=["Structures"],
46 | responses=ERROR_RESPONSES,
47 | )
48 | def get_single_structure(
49 | request: Request,
50 | entry_id: str,
51 | params: Annotated[SingleEntryQueryParams, Depends()],
52 | ) -> dict[str, Any]:
53 | structures_coll = request.app.state.entry_collections.get("structures")
54 | return get_single_entry(
55 | collection=structures_coll,
56 | entry_id=entry_id,
57 | request=request,
58 | params=params,
59 | )
60 |
--------------------------------------------------------------------------------
/tests/server/entry_collections/test_indexes.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from bson import ObjectId
3 |
4 | from optimade.server.config import ServerConfig
5 |
6 | CONFIG = ServerConfig()
7 |
8 |
9 | @pytest.mark.skipif(
10 | CONFIG.database_backend.value not in ("mongomock", "mongodb"),
11 | reason="Skipping index test when testing the elasticsearch backend.",
12 | )
13 | def test_indexes_are_created_where_appropriate(client):
14 | """Test that with the test config, default indices are made by
15 | supported backends. This is tested by checking that we cannot insert
16 | an entry with the same underlying ID as the test data, and that this
17 | returns the appopriate database-specific error.
18 |
19 | """
20 | import pymongo.errors
21 |
22 | from optimade.server.query_params import EntryListingQueryParams
23 |
24 | app = client.app
25 |
26 | entry_collections = app.state.entry_collections
27 |
28 | # get one structure with and try to reinsert it
29 | for _type in entry_collections:
30 | result, _, _, _, _ = entry_collections[_type].find(
31 | EntryListingQueryParams(page_limit=1)
32 | )
33 | assert result is not None
34 | if isinstance(result, list):
35 | result = result[0]
36 |
37 | # The ID is mapped to the test data ID (e.g., 'task_id'), so the index is actually on that
38 | id_field = entry_collections[_type].resource_mapper.get_backend_field("id")
39 |
40 | # Take the raw database result, extract the OPTIMADE ID and try to insert a canary
41 | # document containing just that ID, plus a fake MongoDB ID to avoid '_id' clashes
42 | canary = {id_field: result["id"], "_id": ObjectId(24 * "0")}
43 | # Match either for "Duplicate" (mongomock) or "duplicate" (mongodb)
44 | with pytest.raises(pymongo.errors.BulkWriteError, match="uplicate"):
45 | entry_collections[_type].insert([canary]) # type: ignore
46 |
--------------------------------------------------------------------------------
/tests/test_config.json:
--------------------------------------------------------------------------------
1 | {
2 | "debug": false,
3 | "default_db": "test_server",
4 | "base_url": "http://example.org",
5 | "gzip": {"enabled": true},
6 | "implementation": {
7 | "name": "Example implementation",
8 | "source_url": "https://github.com/Materials-Consortia/optimade-python-tools",
9 | "issue_tracker": "https://github.com/Materials-Consortia/optimade-python-tools/issues",
10 | "maintainer": {"email": "test@test.org"}
11 | },
12 | "provider": {
13 | "name": "Example provider",
14 | "description": "Provider used for examples, not to be assigned to a real database",
15 | "prefix": "exmpl",
16 | "homepage": "https://example.com"
17 | },
18 | "mongo_count_timeout": 0,
19 | "index_base_url": "http://localhost:5001",
20 | "insert_from_jsonl": "optimade/server/data/test_data.jsonl",
21 | "create_default_index": true,
22 | "provider_fields": {
23 | "structures": [
24 | "band_gap",
25 | {"name": "chemsys", "type": "string", "description": "A string representing the chemical system in an ordered fashion"},
26 | {"name": "_exmpl_this_provider_field", "type": "string", "description": "A field defined by this provider, added to this config to check whether the server will pass it through without adding two prefixes."},
27 | {"name": "_exmpl_stability", "type": "dictionary", "description": "A dictionary field with some naughty keys that contain non-URL-safe characters."}
28 | ]
29 | },
30 | "aliases": {
31 | "structures": {
32 | "id": "task_id",
33 | "chemical_formula_descriptive": "pretty_formula",
34 | "chemical_formula_reduced": "pretty_formula",
35 | "chemical_formula_anonymous": "formula_anonymous"
36 | }
37 | },
38 | "length_aliases": {
39 | "structures": {
40 | "chemsys": "nelements"
41 | }
42 | },
43 | "license": "CC-BY-4.0",
44 | "request_delay": 0.1
45 | }
46 |
--------------------------------------------------------------------------------
/optimade/models/index_metadb.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated, Literal
2 |
3 | from pydantic import BaseModel
4 |
5 | from optimade.models.baseinfo import BaseInfoAttributes, BaseInfoResource
6 | from optimade.models.jsonapi import BaseResource
7 | from optimade.models.utils import StrictField
8 |
9 | __all__ = (
10 | "IndexInfoAttributes",
11 | "RelatedLinksResource",
12 | "IndexRelationship",
13 | "IndexInfoResource",
14 | )
15 |
16 |
17 | class IndexInfoAttributes(BaseInfoAttributes):
18 | """Attributes for Base URL Info endpoint for an Index Meta-Database"""
19 |
20 | is_index: Annotated[
21 | bool,
22 | StrictField(
23 | description="This must be `true` since this is an index meta-database (see section Index Meta-Database).",
24 | ),
25 | ] = True
26 |
27 |
28 | class RelatedLinksResource(BaseResource):
29 | """A related Links resource object"""
30 |
31 | type: Literal["links"] = "links"
32 |
33 |
34 | class IndexRelationship(BaseModel):
35 | """Index Meta-Database relationship"""
36 |
37 | data: Annotated[
38 | RelatedLinksResource | None,
39 | StrictField(
40 | description="""[JSON API resource linkage](http://jsonapi.org/format/1.0/#document-links).
41 | It MUST be either `null` or contain a single Links identifier object with the fields `id` and `type`""",
42 | ),
43 | ]
44 |
45 |
46 | class IndexInfoResource(BaseInfoResource):
47 | """Index Meta-Database Base URL Info endpoint resource"""
48 |
49 | attributes: IndexInfoAttributes
50 | relationships: Annotated[ # type: ignore[assignment]
51 | dict[Literal["default"], IndexRelationship] | None,
52 | StrictField(
53 | title="Relationships",
54 | description="""Reference to the Links identifier object under the `links` endpoint that the provider has chosen as their 'default' OPTIMADE API database.
55 | A client SHOULD present this database as the first choice when an end-user chooses this provider.""",
56 | ),
57 | ]
58 |
--------------------------------------------------------------------------------
/tests/adapters/structures/test_jarvis.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from .utils import get_min_ver
4 |
5 | min_ver = get_min_ver("jarvis-tools")
6 | jarvis = pytest.importorskip(
7 | "jarvis",
8 | minversion=min_ver,
9 | reason=f"jarvis-tools must be installed with minimum version {min_ver} for these tests to"
10 | " be able to run",
11 | )
12 |
13 | from jarvis.core.atoms import Atoms
14 |
15 | from optimade.adapters import Structure
16 | from optimade.adapters.exceptions import ConversionError
17 | from optimade.adapters.structures.jarvis import get_jarvis_atoms
18 |
19 |
20 | def test_successful_conversion(RAW_STRUCTURES):
21 | """Make sure its possible to convert"""
22 | for structure in RAW_STRUCTURES:
23 | assert isinstance(get_jarvis_atoms(Structure(structure)), Atoms)
24 |
25 |
26 | def test_null_lattice_vectors(null_lattice_vector_structure):
27 | """Make sure null lattice vectors are handled"""
28 | assert isinstance(get_jarvis_atoms(null_lattice_vector_structure), Atoms)
29 |
30 |
31 | def test_special_species(SPECIAL_SPECIES_STRUCTURES):
32 | """Make sure vacancies and non-chemical symbols ("X") are handled"""
33 | for special_structure in SPECIAL_SPECIES_STRUCTURES:
34 | structure = Structure(special_structure)
35 |
36 | # Since all the special species structure only have a single species, this works fine.
37 | if len(structure.species[0].chemical_symbols) > 1:
38 | # If the structure is disordered (has partial occupancies of any kind),
39 | # jarvis-tools cannot convert the structure
40 | with pytest.raises(
41 | ConversionError,
42 | match="jarvis-tools cannot handle structures with partial occupancies",
43 | ):
44 | get_jarvis_atoms(structure)
45 | else:
46 | # No partial occupancies, just special/non-standard species.
47 | # jarvis-tools should convert these structure fine enough.
48 | assert isinstance(get_jarvis_atoms(structure), Atoms)
49 |
--------------------------------------------------------------------------------
/tests/adapters/structures/test_pymatgen.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from .utils import get_min_ver
4 |
5 | min_ver = get_min_ver("pymatgen")
6 | pymatgen = pytest.importorskip(
7 | "pymatgen.core",
8 | minversion=min_ver,
9 | reason=f"pymatgen must be installed with minimum version {min_ver} for these tests to"
10 | " be able to run",
11 | )
12 |
13 | from pymatgen.core import Molecule
14 | from pymatgen.core import Structure as PymatgenStructure
15 |
16 | from optimade.adapters import Structure
17 | from optimade.adapters.structures.pymatgen import (
18 | _get_molecule,
19 | _get_structure,
20 | get_pymatgen,
21 | )
22 |
23 |
24 | def test_successful_conversion(RAW_STRUCTURES):
25 | """Make sure its possible to convert"""
26 | for structure in RAW_STRUCTURES:
27 | assert isinstance(
28 | get_pymatgen(Structure(structure)), (PymatgenStructure, Molecule)
29 | )
30 |
31 |
32 | def test_successful_conversion_structure(structure):
33 | """Make sure its possible to convert to pymatgen Structure"""
34 | assert isinstance(_get_structure(structure), PymatgenStructure)
35 | assert isinstance(get_pymatgen(structure), PymatgenStructure)
36 |
37 |
38 | def test_null_lattice_vectors(null_lattice_vector_structure):
39 | """Make sure null lattice vectors are handled
40 |
41 | This also respresents a test for successful conversion to pymatgen Molecule
42 | """
43 | assert isinstance(_get_molecule(null_lattice_vector_structure), Molecule)
44 | assert isinstance(get_pymatgen(null_lattice_vector_structure), Molecule)
45 |
46 |
47 | def test_special_species(SPECIAL_SPECIES_STRUCTURES):
48 | """Make sure vacancies and non-chemical symbols ("X") are handled"""
49 | for special_structure in SPECIAL_SPECIES_STRUCTURES:
50 | structure = Structure(special_structure)
51 | assert isinstance(get_pymatgen(structure), PymatgenStructure)
52 |
53 |
54 | def test_null_species(null_species_structure):
55 | """Make sure null species are handled"""
56 | assert isinstance(get_pymatgen(null_species_structure), PymatgenStructure)
57 |
--------------------------------------------------------------------------------
/optimade/server/data/test_references.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "_id": {
4 | "$oid": "5cfb441f053b174410701bec"
5 | },
6 | "id": "dijkstra1968",
7 | "last_modified": {
8 | "$date": "2019-11-12T14:24:37.331Z"
9 | },
10 | "authors": [
11 | {
12 | "name": "Edsger W. Dijkstra",
13 | "firstname": "Edsger",
14 | "lastname": "Dijkstra"
15 | }
16 | ],
17 | "year": "1968",
18 | "title": "Go To Statement Considered Harmful",
19 | "journal": "Communications of the ACM",
20 | "doi": "10.1145/362929.362947"
21 | },
22 | {
23 | "_id": {
24 | "$oid": "5cfb441f053b174410701bed"
25 | },
26 | "id": "maddox1988",
27 | "last_modified": {
28 | "$date": "2019-11-27T14:24:37.331Z"
29 | },
30 | "authors": [
31 | {
32 | "name": "John Maddox",
33 | "firstname": "John",
34 | "lastname": "Maddox"
35 | }
36 | ],
37 | "year": "1988",
38 | "title": "Crystals From First Principles",
39 | "journal": "Nature",
40 | "doi": "10.1038/335201a0"
41 | },
42 | {
43 | "_id": {
44 | "$oid": "5cfb441f053b174410701bea"
45 | },
46 | "id": "dummy/2019",
47 | "last_modified": {
48 | "$date": "2019-11-23T14:24:37.332Z"
49 | },
50 | "authors": [
51 | {
52 | "name": "A Nother",
53 | "firstname": "A",
54 | "lastname": "Nother"
55 | }
56 | ],
57 | "year": "2019",
58 | "title": "Dummy reference that should remain orphaned from all structures for testing purposes",
59 | "journal": "JACS",
60 | "doi": "10.1038/00000"
61 | },
62 | {
63 | "_id": {
64 | "$oid": "98fb441f053b1744107019e3"
65 | },
66 | "id": "dummy/2022",
67 | "last_modified": {
68 | "$date": "2022-01-23T14:24:37.332Z"
69 | },
70 | "authors": [
71 | {
72 | "name": "A Nother",
73 | "firstname": "A",
74 | "lastname": "Nother"
75 | }
76 | ],
77 | "year": "2019",
78 | "note": "Dummy reference",
79 | "title": "Just another title",
80 | "journal": "JACS"
81 | }
82 | ]
83 |
--------------------------------------------------------------------------------
/optimade/server/routers/index_info.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Request
2 |
3 | from optimade import __api_version__
4 | from optimade.models import (
5 | IndexInfoAttributes,
6 | IndexInfoResource,
7 | IndexInfoResponse,
8 | IndexRelationship,
9 | RelatedLinksResource,
10 | )
11 | from optimade.server.routers.utils import get_base_url, meta_values
12 | from optimade.server.schemas import ERROR_RESPONSES
13 |
14 | router = APIRouter(redirect_slashes=True)
15 |
16 |
17 | @router.get(
18 | "/info",
19 | response_model=IndexInfoResponse,
20 | response_model_exclude_unset=True,
21 | tags=["Info"],
22 | responses=ERROR_RESPONSES,
23 | )
24 | def get_info(request: Request) -> IndexInfoResponse:
25 | config = request.app.state.config
26 |
27 | return IndexInfoResponse(
28 | meta=meta_values(
29 | config,
30 | request.url,
31 | 1,
32 | 1,
33 | more_data_available=False,
34 | schema=config.index_schema_url,
35 | ),
36 | data=IndexInfoResource(
37 | id=IndexInfoResource.model_fields["id"].default,
38 | type=IndexInfoResource.model_fields["type"].default,
39 | attributes=IndexInfoAttributes(
40 | api_version=f"{__api_version__}",
41 | available_api_versions=[
42 | {
43 | "url": f"{get_base_url(config, request.url)}/v{__api_version__.split('.')[0]}/",
44 | "version": f"{__api_version__}",
45 | }
46 | ],
47 | formats=["json"],
48 | available_endpoints=["info", "links"],
49 | entry_types_by_format={"json": []},
50 | is_index=True,
51 | ),
52 | relationships={
53 | "default": IndexRelationship(
54 | data={
55 | "type": RelatedLinksResource.model_fields["type"].default,
56 | "id": config.default_db,
57 | }
58 | )
59 | },
60 | ),
61 | )
62 |
--------------------------------------------------------------------------------
/tests/models/test_links.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from optimade.models import LinksResponse
4 | from optimade.models.links import Aggregate, LinksResource, LinkType
5 | from optimade.server.mappers import LinksMapper
6 |
7 |
8 | def test_good_links(starting_links):
9 | """Check well-formed links used as example data"""
10 | # Test starting_links is a good links resource
11 |
12 | resource = LinksResource(**LinksMapper().map_back(starting_links))
13 | assert resource.attributes.link_type == LinkType.CHILD
14 | assert resource.attributes.aggregate == Aggregate.TEST
15 |
16 |
17 | def test_edge_case_links():
18 | response = LinksResponse(
19 | data=[
20 | {
21 | "id": "aflow",
22 | "type": "links",
23 | "attributes": {
24 | "name": "AFLOW",
25 | "description": "The AFLOW OPTIMADE endpoint",
26 | "base_url": "http://aflow.org/API/optimade/",
27 | "homepage": "http://aflow.org",
28 | "link_type": "child",
29 | },
30 | },
31 | ],
32 | meta={
33 | "query": {"representation": "/links"},
34 | "more_data_available": False,
35 | "api_version": "1.0.0",
36 | },
37 | )
38 |
39 | assert isinstance(response.data[0], LinksResource)
40 | assert response.data[0].attributes.link_type == LinkType.CHILD
41 |
42 |
43 | def test_bad_links(starting_links):
44 | """Check badly formed links"""
45 | from pydantic import ValidationError
46 |
47 | bad_links = [
48 | {"aggregate": "wrong"},
49 | {"link_type": "wrong"},
50 | {"base_url": "example.org"},
51 | {"homepage": "www.example.org"},
52 | {"relationships": {}},
53 | ]
54 |
55 | for index, links in enumerate(bad_links):
56 | # This is for helping devs finding any errors that may occur
57 | print(f"Now testing number {index}")
58 | bad_link = starting_links.copy()
59 | bad_link.update(links)
60 | with pytest.raises(ValidationError):
61 | LinksResource(**LinksMapper().map_back(bad_link))
62 | del bad_link
63 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # pre-commit.ci configuration
2 | ci:
3 | autofix_prs: false
4 | autoupdate_branch: 'main'
5 | autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate'
6 | autoupdate_schedule: monthly
7 | # Both of these require `invoke` to be installed in the environment
8 | skip: [json-diff, update-docs-api-reference]
9 | submodules: true
10 |
11 | default_language_version:
12 | python: python3.10
13 |
14 | # pre-commit hooks
15 | repos:
16 | - repo: https://github.com/pre-commit/pre-commit-hooks
17 | rev: v6.0.0
18 | hooks:
19 | - id: check-symlinks
20 | - id: check-yaml
21 | name: Check YAML
22 | - id: check-json
23 | - id: destroyed-symlinks
24 | - id: end-of-file-fixer
25 | - id: requirements-txt-fixer
26 | name: Fix requirements*.txt
27 | files: ^requirements.*\.txt$
28 | - id: trailing-whitespace
29 | args: [--markdown-linebreak-ext=md]
30 |
31 | - repo: https://github.com/asottile/pyupgrade
32 | rev: v3.20.0
33 | hooks:
34 | - id: pyupgrade
35 | args: [--py310-plus]
36 |
37 | - repo: https://github.com/astral-sh/ruff-pre-commit
38 | rev: 'v0.13.3'
39 |
40 | hooks:
41 | - id: ruff
42 | args: [--fix, --exit-non-zero-on-fix]
43 | - id: ruff-format
44 |
45 | - repo: local
46 | hooks:
47 | - id: json-diff
48 | name: OpenAPI diff
49 | description: Check for differences in openapi.json and index_openapi.json with local versions.
50 | entry: invoke check-openapi-diff
51 | pass_filenames: false
52 | language: python
53 | - id: update-docs-api-reference
54 | name: Update API Reference in Documentation
55 | entry: invoke create-api-reference-docs --pre-clean --pre-commit
56 | language: python
57 | pass_filenames: false
58 | files: ^optimade/.*\.py$
59 | description: Update the API Reference documentation whenever a Python file is touched in the code base.
60 |
61 | - repo: https://github.com/pre-commit/mirrors-mypy
62 | rev: v1.18.2
63 | hooks:
64 | - id: mypy
65 | name: "MyPy"
66 | additional_dependencies: ["types-requests", "types-pyyaml", "pydantic~=2.0"]
67 | exclude: ^tests/.*$
68 | args: [--check-untyped-defs]
69 |
--------------------------------------------------------------------------------
/optimade-version.json:
--------------------------------------------------------------------------------
1 | {
2 | "schemaVersion": 1,
3 | "label": "OPTIMADE",
4 | "message": "v1.2.0",
5 | "color": "yellowgreen",
6 | "logoSvg": ""
7 | }
8 |
--------------------------------------------------------------------------------
/docs/getting_started/use_cases.md:
--------------------------------------------------------------------------------
1 | # Example use cases
2 |
3 | ## Serving a single database
4 |
5 | The [Materials Project](https://materialsproject.org) uses `optimade-python-tools` alongside their existing API and MongoDB database, providing [OPTIMADE-compliant access](https://optimade.materialsproject.org) to highly-curated density-functional theory calculations across all known inorganic materials.
6 |
7 | `optimade-python-tools` handles filter parsing, database query generation and response validation by running the reference server implementation with minimal configuration.
8 |
9 | [_odbx_](https://odbx.science), a small database of results from crystal structure prediction calculations, follows a similar approach.
10 | This implementation is open source, available on GitHub at [ml-evs/odbx.science](https://github.com/ml-evs/odbx.science).
11 |
12 | ## Serving multiple databases
13 |
14 | [Materials Cloud](https://materialscloud.org) uses `optimade-python-tools` as a library to provide an OPTIMADE API entries to 1) their main databases create with the [AiiDA](https://aiida.net) Python framework; and 2) to user-contributed data via the Archive platform. Separate OPTIMADE API apps are started for each database, mounted as separate endpoints to a parent FastAPI instance. For converting the underying data to the OPTIMADE format, the [optimade-maker](https://github.com/materialscloud-org/optimade-maker) toolkit is used.
15 |
16 | ## Extending an existing API
17 |
18 | [NOMAD](https://nomad-lab.eu/) uses `optimade-python-tools` as a library to add OPTIMADE API endpoints to an existing web app.
19 | Their implementation uses the Elasticsearch database backend to filter on millions of structures from aggregated first-principles calculations provided by their users and partners.
20 | NOMAD also uses the package to implement a GUI search bar that accepts the OPTIMADE filter language.
21 | NOMAD uses the release versions of the `optimade-python-tools` package, performing all customisation via configuration and sub-classing.
22 | The NOMAD OPTIMADE API implementation is available in the [NOMAD FAIR GitLab repository](https://gitlab.mpcdf.mpg.de/nomad-lab/nomad-FAIR).
23 |
24 | This use case is demonstrated in the example [Integrate OPTIMADE with an existing web application](../deployment/integrated.md).
25 |
--------------------------------------------------------------------------------
/optimade/adapters/structures/jarvis.py:
--------------------------------------------------------------------------------
1 | """
2 | Convert an OPTIMADE structure, in the format of
3 | [`StructureResource`][optimade.models.structures.StructureResource]
4 | to a JARVIS `Atoms` object.
5 |
6 | For more information on the NIST-JARVIS repository, see [their website](https://jarvis.nist.gov/).
7 |
8 | This conversion function relies on the [jarvis-tools](https://github.com/usnistgov/jarvis) package.
9 |
10 | !!! success "Contributing author"
11 | This conversion function was contributed by Kamal Choudhary ([@knc6](https://github.com/knc6)).
12 | """
13 |
14 | from optimade.adapters.exceptions import ConversionError
15 | from optimade.models import StructureFeatures
16 | from optimade.models import StructureResource as OptimadeStructure
17 |
18 | try:
19 | from jarvis.core.atoms import Atoms
20 | except (ImportError, ModuleNotFoundError):
21 | from warnings import warn
22 |
23 | from optimade.adapters.warnings import AdapterPackageNotFound
24 |
25 | Atoms = type("Atoms", (), {})
26 | JARVIS_NOT_FOUND = "jarvis-tools package not found, cannot convert structure to a JARVIS Atoms. Visit https://github.com/usnistgov/jarvis"
27 |
28 |
29 | __all__ = ("get_jarvis_atoms",)
30 |
31 |
32 | def get_jarvis_atoms(optimade_structure: OptimadeStructure) -> Atoms:
33 | """Get jarvis `Atoms` from OPTIMADE structure.
34 |
35 | Caution:
36 | Cannot handle partial occupancies.
37 |
38 | Parameters:
39 | optimade_structure: OPTIMADE structure.
40 |
41 | Returns:
42 | A jarvis `Atoms` object.
43 |
44 | """
45 | if "optimade.adapters" in repr(globals().get("Atoms")):
46 | warn(JARVIS_NOT_FOUND, AdapterPackageNotFound)
47 | return None
48 |
49 | attributes = optimade_structure.attributes
50 |
51 | # Cannot handle partial occupancies
52 | if StructureFeatures.DISORDER in attributes.structure_features:
53 | raise ConversionError(
54 | "jarvis-tools cannot handle structures with partial occupancies."
55 | )
56 |
57 | return Atoms(
58 | lattice_mat=attributes.lattice_vectors,
59 | elements=[specie.name for specie in attributes.species], # type: ignore[union-attr]
60 | coords=attributes.cartesian_site_positions,
61 | cartesian=True,
62 | )
63 |
--------------------------------------------------------------------------------
/optimade/adapters/structures/adapter.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Callable
2 |
3 | from optimade.adapters.base import EntryAdapter
4 | from optimade.models import StructureResource
5 |
6 | from .aiida import get_aiida_structure_data
7 | from .ase import Atoms as ASEAtoms
8 | from .ase import from_ase_atoms, get_ase_atoms
9 | from .cif import get_cif
10 | from .jarvis import get_jarvis_atoms
11 | from .proteindatabank import get_pdb, get_pdbx_mmcif
12 | from .pymatgen import Structure as PymatgenStructure
13 | from .pymatgen import from_pymatgen, get_pymatgen
14 |
15 |
16 | class Structure(EntryAdapter):
17 | """
18 | Lazy structure resource converter.
19 |
20 | Go to [`EntryAdapter`][optimade.adapters.base.EntryAdapter] to see the full list of methods
21 | and properties.
22 |
23 | Attributes:
24 | ENTRY_RESOURCE: This adapter stores entry resources as
25 | [`StructureResource`][optimade.models.structures.StructureResource]s.
26 | _type_converters: Dictionary of valid conversion types for entry.
27 |
28 | Currently available types:
29 |
30 | - `aiida_structuredata`
31 | - `ase`
32 | - `cif`
33 | - `pdb`
34 | - `pdbx_mmcif`
35 | - `pymatgen`
36 | - `jarvis`
37 |
38 | _type_ingesters: Dictionary of valid ingesters.
39 |
40 | as_<_type_converters>: Convert entry to a type listed in `_type_converters`.
41 | from_<_type_converters>: Convert an external type to an OPTIMADE
42 | [`StructureResourceAttributes`][optimade.models.structures.StructureResourceAttributes]
43 | model.
44 |
45 | """
46 |
47 | ENTRY_RESOURCE: type[StructureResource] = StructureResource
48 | _type_converters: dict[str, Callable] = {
49 | "aiida_structuredata": get_aiida_structure_data,
50 | "ase": get_ase_atoms,
51 | "cif": get_cif,
52 | "pdb": get_pdb,
53 | "pdbx_mmcif": get_pdbx_mmcif,
54 | "pymatgen": get_pymatgen,
55 | "jarvis": get_jarvis_atoms,
56 | }
57 |
58 | _type_ingesters: dict[str, Callable] = {
59 | "pymatgen": from_pymatgen,
60 | "ase": from_ase_atoms,
61 | }
62 |
63 | _type_ingesters_by_type: dict[str, type] = {
64 | "pymatgen": PymatgenStructure,
65 | "ase": ASEAtoms,
66 | }
67 |
--------------------------------------------------------------------------------
/optimade/models/types.py:
--------------------------------------------------------------------------------
1 | from types import UnionType
2 | from typing import Annotated, Optional, Union, get_args
3 |
4 | from pydantic import Field
5 |
6 | from optimade.models.utils import (
7 | ELEMENT_SYMBOLS_PATTERN,
8 | EXTENDED_CHEMICAL_SYMBOLS_PATTERN,
9 | SEMVER_PATTERN,
10 | SYMMETRY_OPERATION_REGEXP,
11 | )
12 |
13 | __all__ = ("ChemicalSymbol", "SemanticVersion")
14 |
15 | ChemicalSymbol = Annotated[str, Field(pattern=EXTENDED_CHEMICAL_SYMBOLS_PATTERN)]
16 |
17 | SymmetryOperation = Annotated[str, Field(pattern=SYMMETRY_OPERATION_REGEXP)]
18 |
19 | ElementSymbol = Annotated[str, Field(pattern=ELEMENT_SYMBOLS_PATTERN)]
20 |
21 | SemanticVersion = Annotated[
22 | str,
23 | Field(
24 | pattern=SEMVER_PATTERN, examples=["0.10.1", "1.0.0-rc.2", "1.2.3-rc.5+develop"]
25 | ),
26 | ]
27 |
28 | AnnotatedType = type(ChemicalSymbol)
29 | OptionalType = type(Optional[str])
30 | _UnionType = type(Union[str, int])
31 | NoneType = type(None)
32 |
33 |
34 | def _get_origin_type(annotation: type) -> type:
35 | """Get the origin type of a type annotation.
36 |
37 | Parameters:
38 | annotation: The type annotation.
39 |
40 | Returns:
41 | The origin type.
42 |
43 | """
44 | # If the annotation is a Union, get the first non-None type (this includes
45 | # Optional[T])
46 | if isinstance(annotation, (OptionalType, UnionType, _UnionType)):
47 | for arg in get_args(annotation):
48 | if arg not in (None, NoneType):
49 | annotation = arg
50 | break
51 |
52 | # If the annotation is an Annotated type, get the first type
53 | if isinstance(annotation, AnnotatedType):
54 | annotation = get_args(annotation)[0]
55 |
56 | # Recursively unpack annotation, if it is a Union, Optional, or Annotated type
57 | while isinstance(annotation, (OptionalType, UnionType, _UnionType, AnnotatedType)):
58 | annotation = _get_origin_type(annotation)
59 |
60 | # Special case for Literal
61 | # NOTE: Expecting Literal arguments to all be of a single type
62 | arg = get_args(annotation)
63 | if arg and not isinstance(arg, type):
64 | # Expect arg to be a Literal type argument
65 | annotation = type(arg)
66 |
67 | # Ensure that the annotation is a builtin type
68 | return getattr(annotation, "__origin__", annotation)
69 |
--------------------------------------------------------------------------------
/optimade/server/routers/static/landing_page.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ provider.name }}
5 |
6 |
7 |
8 |
9 |
10 |
15 |
16 |
17 |
18 | This is an OPTIMADE base URL which can be queried with an OPTIMADE client.
19 | OPTIMADE version:
20 | {{ api_version }}
21 | Provider:
22 | {{ provider.name }}
23 | Prefix: {{ provider.prefix }}
24 | {{ provider.description }}
25 | {{ provider.homepage }}
26 | Implementation:
27 | {{ implementation.name }}
28 | Version: {{ implementation.version }}
29 | {{ implementation.source_url }}
30 | Available endpoints:
31 |
32 | {% ENDPOINTS %}
33 |
34 | {% INDEX_BASE_URL %}
35 |
36 |
44 |
45 |
--------------------------------------------------------------------------------
/tests/server/test_schemas.py:
--------------------------------------------------------------------------------
1 | """Tests for optimade.server.schemas"""
2 |
3 |
4 | def test_retrieve_queryable_properties() -> None:
5 | """Test that the default `ENTRY_INFO_SCHEMAS` contain
6 | all the required information about the OPTIMADE properties
7 | after dereferencing.
8 |
9 | """
10 | from optimade.models.entries import EntryResourceAttributes
11 | from optimade.server.schemas import (
12 | ENTRY_INFO_SCHEMAS,
13 | retrieve_queryable_properties,
14 | )
15 |
16 | for entry in ("Structures", "References"):
17 | schema = ENTRY_INFO_SCHEMAS[entry.lower()]
18 |
19 | top_level_props = ("id", "type", "attributes")
20 | properties = retrieve_queryable_properties(schema, top_level_props)
21 |
22 | attributes_annotation = schema.model_fields["attributes"].annotation
23 | assert issubclass(attributes_annotation, EntryResourceAttributes)
24 | fields = list(attributes_annotation.model_fields)
25 | fields += ["id", "type"]
26 |
27 | # Check all fields are present
28 | assert all(field in properties for field in fields)
29 |
30 | # Check that all expected keys are present for OPTIMADE fields
31 | for key in ("type", "sortable", "queryable", "description"):
32 | assert all(key in properties[field] for field in properties)
33 |
34 | # Check that all fields are queryable
35 | assert all(properties[field]["queryable"] for field in properties)
36 |
37 |
38 | def test_provider_field_schemas() -> None:
39 | """Tests that the default configured provider fields that have descriptions
40 | are dereferenced appropriately.
41 |
42 | """
43 | from optimade.server.config import ServerConfig
44 | from optimade.server.schemas import (
45 | ENTRY_INFO_SCHEMAS,
46 | retrieve_queryable_properties,
47 | )
48 |
49 | config = ServerConfig()
50 |
51 | entry = "structures"
52 | test_field = "chemsys"
53 | schema = ENTRY_INFO_SCHEMAS[entry]
54 | top_level_props = ("id", "type", "attributes")
55 | properties = retrieve_queryable_properties(schema, top_level_props, entry, config)
56 | name = f"_exmpl_{test_field}"
57 |
58 | assert name in properties
59 | assert properties[name] == {
60 | "type": "string",
61 | "description": "A string representing the chemical system in an ordered fashion",
62 | "sortable": True,
63 | }
64 |
--------------------------------------------------------------------------------
/tests/server/test_subapp_mounts.py:
--------------------------------------------------------------------------------
1 | # test_subapp_mounts.py
2 | import pytest
3 | from fastapi import FastAPI
4 | from fastapi.testclient import TestClient
5 |
6 | from optimade.server.config import ServerConfig
7 | from optimade.server.create_app import create_app
8 |
9 | CONFIG = ServerConfig()
10 |
11 |
12 | @pytest.fixture
13 | def mounted_client():
14 | """Mount 3 Optimade APIs with different Mongo databases."""
15 | parent_app = FastAPI()
16 |
17 | base_url = "http://testserver"
18 |
19 | conf1 = ServerConfig()
20 | conf1.base_url = f"{base_url}/app1"
21 | conf1.mongo_database = "optimade_1"
22 | conf1.provider = {
23 | "name": "Example 1",
24 | "description": "Example 1",
25 | "prefix": "example1",
26 | }
27 | app1 = create_app(conf1)
28 | parent_app.mount("/app1", app1)
29 |
30 | conf2 = ServerConfig()
31 | conf2.base_url = f"{base_url}/app2"
32 | conf2.mongo_database = "optimade_2"
33 | conf2.provider = {
34 | "name": "Example 2",
35 | "description": "Example 2",
36 | "prefix": "example2",
37 | }
38 | app2 = create_app(conf2)
39 | parent_app.mount("/app2", app2)
40 |
41 | conf3 = ServerConfig()
42 | conf3.base_url = f"{base_url}/idx"
43 | conf3.mongo_database = "optimade_idx"
44 | app3 = create_app(conf3, index=True)
45 | parent_app.mount("/idx", app3)
46 |
47 | return TestClient(parent_app)
48 |
49 |
50 | @pytest.mark.skipif(
51 | CONFIG.database_backend.value not in ("mongomock", "mongodb"),
52 | reason="Requires db-specific config, only MongoDB currently supported.",
53 | )
54 | def test_subapps(mounted_client):
55 | for app in ["app1", "app2"]:
56 | r = mounted_client.get(f"/{app}/structures")
57 | assert r.status_code == 200, f"API not reachable for /{app}"
58 | response = r.json()
59 | # Make sure we get at least 10 structures:
60 | assert len(response["data"]) > 10
61 |
62 | # Make sure the custom providers were picked up:
63 | prefix = response["meta"]["provider"]["prefix"]
64 | if app == "app1":
65 | assert prefix == "example1"
66 | if app == "app2":
67 | assert prefix == "example2"
68 |
69 | # the index, make sure links are accessible
70 | r = mounted_client.get("/idx/links")
71 | assert r.status_code == 200, "API not reachable for /idx"
72 | response = r.json()
73 | assert len(response["data"]) > 5
74 |
--------------------------------------------------------------------------------
/tests/adapters/structures/conftest.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 |
3 | import pytest
4 |
5 | if TYPE_CHECKING:
6 | from typing import Any
7 |
8 | from optimade.adapters.structures import Structure
9 |
10 |
11 | @pytest.fixture
12 | def RAW_STRUCTURES() -> "list[dict[str, Any]]":
13 | """Read and return raw_structures.json"""
14 | import json
15 | from pathlib import Path
16 |
17 | return json.loads(
18 | Path(__file__).parent.joinpath("raw_test_structures.json").read_bytes()
19 | )
20 |
21 |
22 | @pytest.fixture
23 | def SPECIAL_SPECIES_STRUCTURES() -> "list[dict[str, Any]]":
24 | """Read and return special_species.json"""
25 | import json
26 | from pathlib import Path
27 |
28 | return json.loads(
29 | Path(__file__).parent.joinpath("special_species.json").read_bytes()
30 | )
31 |
32 |
33 | @pytest.fixture
34 | def raw_structure(RAW_STRUCTURES: "list[dict[str, Any]]") -> "dict[str, Any]":
35 | """Return random raw structure from raw_structures.json"""
36 | from random import choice
37 |
38 | return choice(RAW_STRUCTURES)
39 |
40 |
41 | @pytest.fixture
42 | def structure(raw_structure: "dict[str, Any]") -> "Structure":
43 | """Create and return adapters.Structure"""
44 | from optimade.adapters.structures import Structure
45 |
46 | return Structure(raw_structure)
47 |
48 |
49 | @pytest.fixture
50 | def structures(RAW_STRUCTURES: "list[dict[str, Any]]") -> "list[Structure]":
51 | """Create and return list of adapters.Structure"""
52 | from optimade.adapters.structures import Structure
53 |
54 | return [Structure(_) for _ in RAW_STRUCTURES]
55 |
56 |
57 | @pytest.fixture
58 | def null_lattice_vector_structure(raw_structure: "dict[str, Any]") -> "Structure":
59 | """Create and return adapters.Structure with lattice_vectors that have None values"""
60 | from optimade.adapters.structures import Structure
61 |
62 | raw_structure["attributes"]["lattice_vectors"][0] = [None] * 3
63 | raw_structure["attributes"]["dimension_types"][0] = 0
64 | raw_structure["attributes"]["nperiodic_dimensions"] = sum(
65 | raw_structure["attributes"]["dimension_types"]
66 | )
67 | return Structure(raw_structure)
68 |
69 |
70 | @pytest.fixture
71 | def null_species_structure(raw_structure: "dict[str, Any]") -> "Structure":
72 | """Create and return Structure with species that have None values"""
73 | from optimade.adapters.structures import Structure
74 |
75 | raw_structure["attributes"]["species"] = None
76 | return Structure(raw_structure)
77 |
--------------------------------------------------------------------------------
/tests/adapters/structures/test_ase.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from .utils import get_min_ver
4 |
5 | min_ver = get_min_ver("ase")
6 | ase = pytest.importorskip(
7 | "ase",
8 | minversion=min_ver,
9 | reason=f"ase must be installed with minimum version {min_ver} for these tests to"
10 | " be able to run",
11 | )
12 |
13 | from ase import Atoms
14 |
15 | from optimade.adapters import Structure
16 | from optimade.adapters.exceptions import ConversionError
17 | from optimade.adapters.structures.ase import get_ase_atoms
18 |
19 |
20 | def test_successful_conversion(RAW_STRUCTURES):
21 | """Make sure its possible to convert"""
22 | for structure in RAW_STRUCTURES:
23 | assert isinstance(get_ase_atoms(Structure(structure)), Atoms)
24 |
25 |
26 | def test_null_lattice_vectors(null_lattice_vector_structure):
27 | """Make sure null lattice vectors are handled"""
28 | assert isinstance(get_ase_atoms(null_lattice_vector_structure), Atoms)
29 |
30 |
31 | def test_special_species(SPECIAL_SPECIES_STRUCTURES):
32 | """Make sure vacancies and non-chemical symbols ("X") are handled"""
33 | for special_structure in SPECIAL_SPECIES_STRUCTURES:
34 | structure = Structure(special_structure)
35 |
36 | with pytest.raises(
37 | ConversionError,
38 | match=r"(ASE cannot handle structures with partial occupancies)|"
39 | r"(ASE cannot handle structures with unknown \('X'\) chemical symbols)",
40 | ):
41 | get_ase_atoms(structure)
42 |
43 |
44 | def test_null_species(null_species_structure):
45 | """Make sure null species are handled"""
46 | assert isinstance(get_ase_atoms(null_species_structure), Atoms)
47 |
48 |
49 | def test_extra_info_keys(RAW_STRUCTURES):
50 | """Test that provider fields/ASE metadata is preserved during conversion."""
51 | structure = RAW_STRUCTURES[0]
52 | structure["attributes"]["_ase_key"] = "some value"
53 | structure["attributes"]["_ase_another_key"] = [1, 2, 3]
54 | structure["attributes"]["_key_without_ase_prefix"] = [4, 5, 6]
55 |
56 | atoms = Structure(structure).as_ase
57 | assert atoms.info["key"] == "some value"
58 | assert atoms.info["another_key"] == [1, 2, 3]
59 | assert atoms.info["_key_without_ase_prefix"] == [4, 5, 6]
60 |
61 | roundtrip_structure = Structure.ingest_from(atoms).attributes.model_dump()
62 | assert roundtrip_structure["_ase_key"] == "some value"
63 | assert roundtrip_structure["_ase_another_key"] == [1, 2, 3]
64 |
65 | # This key should have the _ase prefix re-added
66 | assert roundtrip_structure["_ase__key_without_ase_prefix"] == [4, 5, 6]
67 |
--------------------------------------------------------------------------------
/optimade/warnings.py:
--------------------------------------------------------------------------------
1 | """This submodule implements the possible warnings that can be omitted by an
2 | OPTIMADE API.
3 |
4 | """
5 |
6 | __all__ = (
7 | "OptimadeWarning",
8 | "FieldValueNotRecognized",
9 | "TooManyValues",
10 | "QueryParamNotUsed",
11 | "MissingExpectedField",
12 | "TimestampNotRFCCompliant",
13 | "UnknownProviderProperty",
14 | "UnknownProviderQueryParameter",
15 | )
16 |
17 |
18 | class OptimadeWarning(Warning):
19 | """Base Warning for the `optimade` package"""
20 |
21 | def __init__(
22 | self, detail: str | None = None, title: str | None = None, *args
23 | ) -> None:
24 | detail = detail if detail else self.__doc__
25 | super().__init__(detail, *args)
26 | self.detail = detail
27 | self.title = title if title else self.__class__.__name__
28 |
29 | def __repr__(self) -> str:
30 | attrs = {"detail": self.detail, "title": self.title}
31 | return "<{:s}({:s})>".format(
32 | self.__class__.__name__,
33 | " ".join(
34 | [
35 | f"{attr}={value!r}"
36 | for attr, value in attrs.items()
37 | if value is not None
38 | ]
39 | ),
40 | )
41 |
42 | def __str__(self) -> str:
43 | return self.detail if self.detail is not None else ""
44 |
45 |
46 | class LocalOptimadeWarning(OptimadeWarning):
47 | """A warning that is specific to a local implementation of the OPTIMADE API
48 | and should not appear in the server log or response.
49 | """
50 |
51 |
52 | class FieldValueNotRecognized(OptimadeWarning):
53 | """A field or value used in the request is not recognised by this implementation."""
54 |
55 |
56 | class TooManyValues(OptimadeWarning):
57 | """A field or query parameter has too many values to be handled by this implementation."""
58 |
59 |
60 | class QueryParamNotUsed(OptimadeWarning):
61 | """A query parameter is not used in this request."""
62 |
63 |
64 | class MissingExpectedField(LocalOptimadeWarning):
65 | """A field was provided with a null value when a related field was provided
66 | with a value."""
67 |
68 |
69 | class TimestampNotRFCCompliant(OptimadeWarning):
70 | """A timestamp has been used in a filter that contains microseconds and is thus not
71 | RFC 3339 compliant. This may cause undefined behaviour in the query results.
72 |
73 | """
74 |
75 |
76 | class UnknownProviderProperty(OptimadeWarning):
77 | """A provider-specific property has been requested via `response_fields` or as in a `filter` that is not
78 | recognised by this implementation.
79 |
80 | """
81 |
82 |
83 | class UnknownProviderQueryParameter(OptimadeWarning):
84 | """A provider-specific query parameter has been requested in the query with a prefix not
85 | recognised by this implementation.
86 |
87 | """
88 |
--------------------------------------------------------------------------------
/tests/server/query_params/test_response_fields.py:
--------------------------------------------------------------------------------
1 | """Make sure response_fields is handled correctly"""
2 |
3 |
4 | def test_required_fields_links(check_required_fields_response):
5 | """Certain fields are REQUIRED, no matter the value of `response_fields`"""
6 | endpoint = "links"
7 | illegal_top_level_field = "relationships"
8 | non_used_top_level_fields = {"links"}
9 | non_used_top_level_fields.add(illegal_top_level_field)
10 | expected_fields = {"homepage", "base_url", "link_type"}
11 | check_required_fields_response(endpoint, non_used_top_level_fields, expected_fields)
12 |
13 |
14 | def test_required_fields_references(check_required_fields_response):
15 | """Certain fields are REQUIRED, no matter the value of `response_fields`"""
16 | endpoint = "references"
17 | non_used_top_level_fields = {"links", "relationships"}
18 | expected_fields = {"year", "journal"}
19 | check_required_fields_response(endpoint, non_used_top_level_fields, expected_fields)
20 |
21 |
22 | def test_required_fields_structures(check_required_fields_response):
23 | """Certain fields are REQUIRED, no matter the value of `response_fields`"""
24 | endpoint = "structures"
25 | non_used_top_level_fields = {"links"}
26 | expected_fields = {"elements", "nelements"}
27 | check_required_fields_response(endpoint, non_used_top_level_fields, expected_fields)
28 |
29 |
30 | def test_unknown_field_structures(
31 | check_required_fields_response, check_error_response, check_response, structures
32 | ):
33 | """Check that unknown fields are returned as `null` in entries."""
34 | endpoint = "structures"
35 | non_used_top_level_fields = {"links"}
36 |
37 | expected_fields = {"_optimade_field"}
38 | check_required_fields_response(endpoint, non_used_top_level_fields, expected_fields)
39 |
40 | expected_fields = {"optimade_field"}
41 | check_error_response(
42 | request=f"/{endpoint}?response_fields={','.join(expected_fields)}",
43 | expected_status=400,
44 | expected_title="Bad Request",
45 | expected_detail="Unrecognised OPTIMADE field(s) in requested `response_fields`: {'optimade_field'}.",
46 | )
47 |
48 | expected_fields = {"_exmpl_optimade_field"}
49 | expected_ids = [doc["task_id"] for doc in structures]
50 | expected_warnings = [
51 | {
52 | "title": "UnknownProviderProperty",
53 | "detail": "Unrecognised field(s) for this provider requested in `response_fields`: {'_exmpl_optimade_field'}.",
54 | }
55 | ]
56 | check_response(
57 | request=f"/{endpoint}?response_fields={','.join(expected_fields)}",
58 | expected_ids=expected_ids,
59 | expected_warnings=expected_warnings,
60 | )
61 |
62 | expected_fields = {"_exmpl123_optimade_field"}
63 | expected_ids = [doc["task_id"] for doc in structures]
64 | check_response(
65 | request=f"/{endpoint}?response_fields={','.join(expected_fields)}",
66 | expected_ids=expected_ids,
67 | )
68 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: OPTIMADE Python tools
2 | site_description: Documentation for the OPTIMADE Python tools
3 | site_url: https://www.optimade.org/optimade-python-tools/
4 | copyright: Built by the Materials-Consortia
5 |
6 | theme:
7 | name: material
8 | font:
9 | text: 'Karla'
10 | code: 'Share Tech Mono'
11 | features:
12 | - content.code.copy
13 | palette:
14 |
15 | # Palette toggle for light mode
16 | - media: "(prefers-color-scheme: light)"
17 | scheme: default
18 | toggle:
19 | icon: material/brightness-7
20 | name: Switch to dark mode
21 | primary: black
22 | accent: red
23 |
24 | # Palette toggle for dark mode
25 | - media: "(prefers-color-scheme: dark)"
26 | scheme: slate
27 | toggle:
28 | icon: material/brightness-4
29 | name: Switch to light mode
30 | primary: black
31 | accent: red
32 |
33 | icon:
34 | repo: fontawesome/brands/github
35 | logo: https://matsci.org/uploads/default/original/2X/b/bd2f59b3bf14fb046b74538750699d7da4c19ac1.svg
36 | favicon: images/favicon.png
37 | language: en
38 |
39 | repo_name: optimade-python-tools
40 | repo_url: https://github.com/Materials-Consortia/optimade-python-tools
41 | edit_uri: ""
42 |
43 | extra:
44 | social:
45 | - icon: fontawesome/brands/github
46 | link: https://github.com/Materials-Consortia
47 | version:
48 | provider: mike
49 | default: stable
50 |
51 | extra_css:
52 | - css/reference.css
53 |
54 | markdown_extensions:
55 | - admonition
56 | - pymdownx.details
57 | - pymdownx.highlight
58 | - pymdownx.superfences
59 | - pymdownx.inlinehilite
60 | - pymdownx.tabbed:
61 | alternate_style: true
62 | - pymdownx.tasklist:
63 | custom_checkbox: true
64 | - pymdownx.snippets
65 | - toc:
66 | permalink: true
67 |
68 | plugins:
69 | - search:
70 | lang: en
71 | - mkdocstrings:
72 | default_handler: python
73 | enable_inventory: true
74 | handlers:
75 | python:
76 | options:
77 | # General options
78 | show_source: true
79 | show_bases: true
80 |
81 | # Heading options
82 | heading_level: 2
83 | show_root_heading: false
84 | show_root_toc_entry: true
85 | show_root_full_path: true
86 | show_object_full_path: false
87 | show_category_heading: false
88 |
89 | # Members options
90 | inherited_members: true
91 | filters:
92 | - "!^_[^_]"
93 | - "!__json_encoder__$"
94 | - "!__all__$"
95 | - "!__config__$"
96 | - "!ValidatorResults$"
97 | group_by_category: true
98 |
99 | # Docstring options
100 | docstring_style: google
101 | docstring_options:
102 | replace_admonitions: true
103 | show_if_no_docstring: false
104 | - awesome-pages
105 | - autorefs:
106 | resolve_closest: true
107 |
108 | watch:
109 | - optimade
110 |
--------------------------------------------------------------------------------
/tests/models/test_jsonapi.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from pydantic import ValidationError
3 |
4 |
5 | def test_hashability():
6 | """Check a list of errors can be converted to a set,
7 | i.e., check that Errors can be hashed."""
8 | from optimade.models.jsonapi import Error
9 |
10 | error = Error(id="test")
11 | assert {error}
12 |
13 |
14 | def test_toplevel_links():
15 | """Test top-level links responses as both URLs and Links objects,
16 | and check that any arbitrary key is allowed as long as the value
17 | can be validated as a URL or a Links object too.
18 |
19 | """
20 | from optimade.models.jsonapi import ToplevelLinks
21 |
22 | test_links = {
23 | "first": {"href": "http://example.org/structures?page_limit=3&page_offset=0"},
24 | "last": {"href": "http://example.org/structures?page_limit=3&page_offset=10"},
25 | "prev": {
26 | "href": "http://example.org/structures?page_limit=3&page_offset=2",
27 | "meta": {"description": "the previous link"},
28 | },
29 | "next": {
30 | "href": "http://example.org/structures?page_limit=3&page_offset=3",
31 | "meta": {"description": "the next link"},
32 | },
33 | }
34 |
35 | # Test all defined fields as URLs and Links objects
36 | for link in test_links:
37 | assert ToplevelLinks(**{link: test_links[link]})
38 | assert ToplevelLinks(**{link: test_links[link]["href"]})
39 |
40 | # Allow arbitrary keys are as long as they are links
41 | assert ToplevelLinks(
42 | **{
43 | "base_url": "https://example.org/structures",
44 | "other_url": {"href": "https://example.org"},
45 | }
46 | )
47 | assert ToplevelLinks(
48 | **{
49 | "base_url5": {
50 | "href": "https://example.org/structures",
51 | "meta": {"description": "the base URL"},
52 | },
53 | "none_url": None,
54 | }
55 | )
56 |
57 | # Check that non-URL and non-Links objects will fail to validate
58 | with pytest.raises(ValidationError):
59 | ToplevelLinks(**{"base_url": {"this object": "is not a URL or a Links object"}})
60 |
61 | with pytest.raises(ValidationError):
62 | ToplevelLinks(**{"base_url": {"href": "not a link"}})
63 |
64 |
65 | def test_response_top_level():
66 | """Ensure a response with "null" values can be created."""
67 | from optimade.models.jsonapi import Response
68 |
69 | assert isinstance(Response(data=[]), Response)
70 | assert isinstance(Response(data=None), Response)
71 | assert isinstance(Response(meta={}), Response)
72 | assert isinstance(Response(meta=None), Response)
73 |
74 | # "errors" MUST NOT be an empty or `null` value if given.
75 | with pytest.raises(ValidationError, match=r"Errors MUST NOT be an empty.*"):
76 | assert isinstance(Response(errors=[]), Response)
77 | with pytest.raises(ValidationError, match=r"Errors MUST NOT be an empty.*"):
78 | assert isinstance(Response(errors=None), Response)
79 |
80 | with pytest.raises(ValidationError, match=r"At least one of .*"):
81 | Response(links={})
82 |
--------------------------------------------------------------------------------
/tests/server/query_params/test_include.py:
--------------------------------------------------------------------------------
1 | """Make sure `include` is handled correctly
2 |
3 | NOTE: Currently _only_ structures have relationships (references).
4 | """
5 |
6 |
7 | def test_default_value(check_include_response):
8 | """Default value for `include` is 'references'
9 |
10 | Test also that passing `include=` equals passing the default value
11 | """
12 | request = "/structures"
13 | expected_types = ["references"]
14 | expected_reference_ids = ["dijkstra1968", "maddox1988", "dummy/2019"]
15 | check_include_response(
16 | request,
17 | expected_included_types=expected_types,
18 | expected_included_resources=expected_reference_ids,
19 | )
20 |
21 |
22 | def test_empty_value(check_include_response):
23 | """An empty value should resolve in no relationships being returned under `included`"""
24 | request = "/structures?include="
25 | expected_types = []
26 | expected_reference_ids = []
27 | expected_data_relationship_types = ["references"]
28 | check_include_response(
29 | request,
30 | expected_included_types=expected_types,
31 | expected_included_resources=expected_reference_ids,
32 | expected_relationship_types=expected_data_relationship_types,
33 | )
34 |
35 |
36 | def test_default_value_single_entry(check_include_response):
37 | """For single entry. Default value for `include` is 'references'"""
38 | request = "/structures/mpf_1"
39 | expected_types = ["references"]
40 | expected_reference_ids = ["dijkstra1968"]
41 | check_include_response(
42 | request,
43 | expected_included_types=expected_types,
44 | expected_included_resources=expected_reference_ids,
45 | )
46 |
47 |
48 | def test_empty_value_single_entry(check_include_response):
49 | """For single entry. An empty value should resolve in no relationships being returned under `included`"""
50 | request = "/structures/mpf_1?include="
51 | expected_types = []
52 | expected_reference_ids = []
53 | expected_data_relationship_types = ["references"]
54 | check_include_response(
55 | request,
56 | expected_included_types=expected_types,
57 | expected_included_resources=expected_reference_ids,
58 | expected_relationship_types=expected_data_relationship_types,
59 | )
60 |
61 |
62 | def test_wrong_relationship_type(check_error_response):
63 | """A wrong type should result in a `400 Bad Request` response"""
64 | from optimade.server.config import ServerConfig
65 | from optimade.server.entry_collections import create_entry_collections
66 |
67 | config = ServerConfig()
68 | entry_collections = create_entry_collections(config)
69 |
70 | for wrong_type in ("test", '""', "''"):
71 | request = f"/structures?include={wrong_type}"
72 | error_detail = (
73 | f"'{wrong_type}' cannot be identified as a valid relationship type. "
74 | f"Known relationship types: {sorted(entry_collections.keys())}"
75 | )
76 | check_error_response(
77 | request,
78 | expected_status=400,
79 | expected_title="Bad Request",
80 | expected_detail=error_detail,
81 | )
82 |
--------------------------------------------------------------------------------
/optimade/server/logger.py:
--------------------------------------------------------------------------------
1 | """Logging factory to create instance-specific loggers for mounted subapps"""
2 |
3 | import logging
4 | import logging.handlers
5 | import os
6 | import sys
7 | from contextvars import ContextVar
8 | from pathlib import Path
9 |
10 | from optimade.server.config import ServerConfig
11 |
12 | # Context variable for keeping track of the app-specific tag for logging
13 | _current_log_tag: ContextVar[str | None] = ContextVar("current_log_tag", default=None)
14 |
15 |
16 | def set_logging_context(tag: str | None):
17 | """Set the current API tag for context-based logging"""
18 | _current_log_tag.set(tag)
19 |
20 |
21 | def get_logger() -> logging.Logger:
22 | """
23 | Get logger for current context.
24 | """
25 | tag = _current_log_tag.get()
26 | logger_name = "optimade" + (f".{tag}" if tag else "")
27 | return logging.getLogger(logger_name)
28 |
29 |
30 | def create_logger(
31 | tag: str | None = None, config: ServerConfig | None = None
32 | ) -> logging.Logger:
33 | """
34 | Create a logger instance for a specific app. The purpose of this function
35 | is to have different loggers for different subapps (if needed).
36 |
37 | Args:
38 | tag: String added to the each logging line idenfiting this logger
39 | config: ServerConfig instance, will create the default one if not provided
40 |
41 | Returns:
42 | Configured logger instance
43 | """
44 | config = config or ServerConfig()
45 |
46 | logger_name = "optimade" + (f".{tag}" if tag else "")
47 | logger = logging.getLogger(logger_name)
48 | logger.setLevel(logging.DEBUG)
49 |
50 | if logger.handlers:
51 | return logger
52 |
53 | # Console handler only on parent (.tag will propagate to parent anyway)
54 | if tag is None:
55 | ch = logging.StreamHandler(sys.stdout)
56 | ch.setLevel(logging.DEBUG if config.debug else config.log_level.value.upper())
57 | try:
58 | from uvicorn.logging import DefaultFormatter
59 |
60 | ch.setFormatter(DefaultFormatter("%(levelprefix)s [%(name)s] %(message)s"))
61 | except ImportError:
62 | pass
63 | logger.addHandler(ch)
64 |
65 | logs_dir = config.log_dir
66 | if logs_dir is None:
67 | logs_dir = Path(os.getenv("OPTIMADE_LOG_DIR", "/var/log/optimade/")).resolve()
68 | try:
69 | logs_dir.mkdir(exist_ok=True, parents=True)
70 | log_filename = f"optimade_{tag}.log" if tag else "optimade.log"
71 | fh = logging.handlers.RotatingFileHandler(
72 | logs_dir / log_filename, maxBytes=1_000_000, backupCount=5
73 | )
74 | fh.setLevel(logging.DEBUG)
75 | fh.setFormatter(
76 | logging.Formatter(
77 | "[%(levelname)-8s %(asctime)s %(filename)s:%(lineno)d][%(name)s] %(message)s",
78 | "%d-%m-%Y %H:%M:%S",
79 | )
80 | )
81 | logger.addHandler(fh)
82 | except OSError:
83 | logger.warning(
84 | "Log files are not saved (%s). Set OPTIMADE_LOG_DIR or fix permissions for %s.",
85 | tag,
86 | logs_dir,
87 | )
88 |
89 | return logger
90 |
91 |
92 | # Create the global logger without a tag.
93 | LOGGER = create_logger()
94 |
--------------------------------------------------------------------------------
/tests/server/query_params/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 |
4 | @pytest.fixture
5 | def structures():
6 | """Fixture to provide a sorted list of structures."""
7 | from optimade.server.data import structures
8 |
9 | return sorted(structures, key=lambda x: x["task_id"])
10 |
11 |
12 | @pytest.fixture
13 | def check_include_response(get_good_response):
14 | """Fixture to check "good" `include` response"""
15 |
16 | def inner(
17 | request: str,
18 | expected_included_types: list | set,
19 | expected_included_resources: list | set,
20 | expected_relationship_types: list | set | None = None,
21 | server: str = "regular",
22 | ):
23 | response = get_good_response(request, server)
24 |
25 | response_data = (
26 | response["data"]
27 | if isinstance(response["data"], list)
28 | else [response["data"]]
29 | )
30 |
31 | included_resource_types = list({_["type"] for _ in response["included"]})
32 | assert sorted(expected_included_types) == sorted(included_resource_types), (
33 | f"Expected relationship types: {expected_included_types}. "
34 | f"Does not match relationship types in response's included field: {included_resource_types}",
35 | )
36 |
37 | if expected_relationship_types is None:
38 | expected_relationship_types = expected_included_types
39 | relationship_types = set()
40 | for entry in response_data:
41 | relationship_types.update(set(entry.get("relationships", {}).keys()))
42 | assert sorted(expected_relationship_types) == sorted(relationship_types), (
43 | f"Expected relationship types: {expected_relationship_types}. "
44 | f"Does not match relationship types found in response data: {relationship_types}",
45 | )
46 |
47 | included_resources = [_["id"] for _ in response["included"]]
48 | assert len(included_resources) == len(expected_included_resources), response[
49 | "included"
50 | ]
51 | assert sorted(set(included_resources)) == sorted(expected_included_resources)
52 |
53 | return inner
54 |
55 |
56 | @pytest.fixture
57 | def check_required_fields_response(get_good_response):
58 | """Fixture to check "good" `required_fields` response"""
59 | from optimade.server import mappers
60 |
61 | get_mapper = {
62 | "links": mappers.LinksMapper,
63 | "references": mappers.ReferenceMapper,
64 | "structures": mappers.StructureMapper,
65 | }
66 |
67 | def inner(
68 | endpoint: str,
69 | known_unused_fields: set,
70 | expected_fields: set,
71 | server: str = "regular",
72 | ):
73 | expected_fields |= (
74 | get_mapper[endpoint]().get_required_fields() - known_unused_fields
75 | )
76 | request = f"/{endpoint}?response_fields={','.join(expected_fields)}"
77 |
78 | response = get_good_response(request, server)
79 | expected_fields.add("attributes")
80 |
81 | response_fields = set()
82 | for entry in response["data"]:
83 | response_fields.update(set(entry.keys()))
84 | response_fields.update(set(entry["attributes"].keys()))
85 | assert sorted(expected_fields) == sorted(response_fields)
86 |
87 | return inner
88 |
--------------------------------------------------------------------------------
/tests/adapters/structures/test_aiida.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from .utils import get_min_ver
4 |
5 | min_ver = get_min_ver("aiida-core")
6 | aiida = pytest.importorskip(
7 | "aiida",
8 | minversion=min_ver,
9 | reason=f"aiida-core must be installed with minimum version {min_ver} for these tests to"
10 | " be able to run",
11 | )
12 |
13 | from aiida import load_profile
14 |
15 | load_profile()
16 |
17 | from aiida.orm import StructureData
18 |
19 | from optimade.adapters import Structure
20 | from optimade.adapters.structures.aiida import get_aiida_structure_data
21 |
22 |
23 | def test_successful_conversion(RAW_STRUCTURES):
24 | """Make sure its possible to convert"""
25 | for structure in RAW_STRUCTURES:
26 | assert isinstance(get_aiida_structure_data(Structure(structure)), StructureData)
27 |
28 |
29 | def test_null_lattice_vectors(null_lattice_vector_structure):
30 | """Make sure null lattice vectors are handled"""
31 | assert isinstance(
32 | get_aiida_structure_data(null_lattice_vector_structure), StructureData
33 | )
34 |
35 |
36 | def test_special_species(SPECIAL_SPECIES_STRUCTURES):
37 | """Make sure vacancies and non-chemical symbols ("X") are handled"""
38 | from optimade.adapters.warnings import ConversionWarning
39 |
40 | for special_structure in SPECIAL_SPECIES_STRUCTURES:
41 | structure = Structure(special_structure)
42 |
43 | if structure.attributes.species[0].mass:
44 | aiida_structure = get_aiida_structure_data(structure)
45 | else:
46 | with pytest.warns(
47 | ConversionWarning, match=r".*will default to setting mass to 1\.0\.$"
48 | ):
49 | aiida_structure = get_aiida_structure_data(structure)
50 |
51 | assert isinstance(aiida_structure, StructureData)
52 |
53 | # Test species.chemical_symbols
54 | if "vacancy" in structure.attributes.species[0].chemical_symbols:
55 | assert aiida_structure.has_vacancies
56 | assert not aiida_structure.is_alloy
57 | elif len(structure.attributes.species[0].chemical_symbols) > 1:
58 | assert not aiida_structure.has_vacancies
59 | assert aiida_structure.is_alloy
60 | else:
61 | assert not aiida_structure.has_vacancies
62 | assert not aiida_structure.is_alloy
63 |
64 | # Test species.mass
65 | if structure.attributes.species[0].mass:
66 | if len(structure.attributes.species[0].mass) > 1:
67 | assert aiida_structure.kinds[0].mass == sum(
68 | [
69 | conc * mass
70 | for conc, mass in zip(
71 | structure.attributes.species[0].concentration,
72 | structure.attributes.species[0].mass,
73 | )
74 | ]
75 | )
76 | else:
77 | assert (
78 | aiida_structure.kinds[0].mass
79 | == structure.attributes.species[0].mass[0]
80 | )
81 | else:
82 | assert aiida_structure.kinds[0].mass == 1.0
83 |
84 |
85 | def test_null_species(null_species_structure):
86 | """Make sure null species are handled"""
87 | assert isinstance(get_aiida_structure_data(null_species_structure), StructureData)
88 |
--------------------------------------------------------------------------------
/optimade/exceptions.py:
--------------------------------------------------------------------------------
1 | from abc import ABC
2 | from typing import Any
3 |
4 | __all__ = (
5 | "OptimadeHTTPException",
6 | "BadRequest",
7 | "VersionNotSupported",
8 | "Forbidden",
9 | "NotFound",
10 | "UnprocessableEntity",
11 | "InternalServerError",
12 | "NotImplementedResponse",
13 | "POSSIBLE_ERRORS",
14 | )
15 |
16 |
17 | class OptimadeHTTPException(Exception, ABC):
18 | """This abstract class can be subclassed to define
19 | HTTP responses with the desired status codes, and
20 | detailed error strings to represent in the JSON:API
21 | error response.
22 |
23 | This class closely follows the `starlette.HTTPException` without
24 | requiring it as a dependency, so that such errors can also be
25 | raised from within client code.
26 |
27 | Attributes:
28 | status_code: The HTTP status code accompanying this exception.
29 | title: A descriptive title for this exception.
30 | detail: An optional string containing the details of the error.
31 |
32 | """
33 |
34 | status_code: int
35 | title: str
36 | detail: str | None = None
37 | headers: dict[str, Any] | None = None
38 |
39 | def __init__(self, detail: str | None = None, headers: dict | None = None) -> None:
40 | if self.status_code is None:
41 | raise AttributeError(
42 | "HTTPException class {self.__class__.__name__} is missing required `status_code` attribute."
43 | )
44 | self.detail = detail
45 | self.headers = headers
46 |
47 | def __str__(self) -> str:
48 | return self.detail if self.detail is not None else self.__repr__()
49 |
50 | def __repr__(self) -> str:
51 | class_name = self.__class__.__name__
52 | return f"{class_name}(status_code={self.status_code!r}, detail={self.detail!r})"
53 |
54 |
55 | class BadRequest(OptimadeHTTPException):
56 | """400 Bad Request"""
57 |
58 | status_code: int = 400
59 | title: str = "Bad Request"
60 |
61 |
62 | class VersionNotSupported(OptimadeHTTPException):
63 | """553 Version Not Supported"""
64 |
65 | status_code: int = 553
66 | title: str = "Version Not Supported"
67 |
68 |
69 | class Forbidden(OptimadeHTTPException):
70 | """403 Forbidden"""
71 |
72 | status_code: int = 403
73 | title: str = "Forbidden"
74 |
75 |
76 | class NotFound(OptimadeHTTPException):
77 | """404 Not Found"""
78 |
79 | status_code: int = 404
80 | title: str = "Not Found"
81 |
82 |
83 | class UnprocessableEntity(OptimadeHTTPException):
84 | """422 Unprocessable Entity"""
85 |
86 | status_code: int = 422
87 | title: str = "Unprocessable Entity"
88 |
89 |
90 | class InternalServerError(OptimadeHTTPException):
91 | """500 Internal Server Error"""
92 |
93 | status_code: int = 500
94 | title: str = "Internal Server Error"
95 |
96 |
97 | class NotImplementedResponse(OptimadeHTTPException):
98 | """501 Not Implemented"""
99 |
100 | status_code: int = 501
101 | title: str = "Not Implemented"
102 |
103 |
104 | """A tuple of the possible errors that can be returned by an OPTIMADE API."""
105 | POSSIBLE_ERRORS: tuple[type[OptimadeHTTPException], ...] = (
106 | BadRequest,
107 | Forbidden,
108 | NotFound,
109 | UnprocessableEntity,
110 | InternalServerError,
111 | NotImplementedResponse,
112 | VersionNotSupported,
113 | )
114 |
--------------------------------------------------------------------------------
/optimade/server/routers/landing.py:
--------------------------------------------------------------------------------
1 | """OPTIMADE landing page router."""
2 |
3 | from pathlib import Path
4 |
5 | from fastapi import Request
6 | from fastapi.responses import HTMLResponse
7 | from starlette.routing import Route, Router
8 |
9 | from optimade import __api_version__
10 | from optimade.server.config import ServerConfig
11 | from optimade.server.routers.utils import get_base_url, meta_values
12 |
13 | # In-process cache: {(config_id, url, custom_mtime): HTMLResponse}
14 | _PAGE_CACHE: dict[tuple[int, str, float | None], HTMLResponse] = {}
15 |
16 |
17 | def _custom_file_mtime(config: ServerConfig) -> float | None:
18 | path = getattr(config, "custom_landing_page", None)
19 | if not path:
20 | return None
21 | try:
22 | return Path(path).resolve().stat().st_mtime
23 | except FileNotFoundError:
24 | return None
25 |
26 |
27 | def render_landing_page(
28 | config: ServerConfig, entry_collections, url: str
29 | ) -> HTMLResponse:
30 | """Render and cache the landing page with a manual, hashable key."""
31 | cache_key = (id(config), url, _custom_file_mtime(config))
32 | if cache_key in _PAGE_CACHE:
33 | return _PAGE_CACHE[cache_key]
34 |
35 | meta = meta_values(
36 | config, url, 1, 1, more_data_available=False, schema=config.schema_url
37 | )
38 | major_version = __api_version__.split(".")[0]
39 | versioned_url = f"{get_base_url(config, url)}/v{major_version}/"
40 |
41 | if config.custom_landing_page:
42 | html = Path(config.custom_landing_page).resolve().read_text()
43 | else:
44 | html = (
45 | (Path(__file__).parent / "static/landing_page.html").resolve().read_text()
46 | )
47 |
48 | replacements = {"api_version": __api_version__}
49 | if meta.provider:
50 | replacements.update(
51 | {
52 | "provider.name": meta.provider.name,
53 | "provider.prefix": meta.provider.prefix,
54 | "provider.description": meta.provider.description,
55 | "provider.homepage": str(meta.provider.homepage or ""),
56 | }
57 | )
58 | if meta.implementation:
59 | replacements.update(
60 | {
61 | "implementation.name": meta.implementation.name or "",
62 | "implementation.version": meta.implementation.version or "",
63 | "implementation.source_url": str(meta.implementation.source_url or ""),
64 | }
65 | )
66 |
67 | for k, v in replacements.items():
68 | html = html.replace(f"{{{{ {k} }}}}", v)
69 |
70 | endpoints = "\n".join(
71 | f'{versioned_url}{e}'
72 | for e in [*entry_collections.keys(), "info"]
73 | )
74 | html = html.replace("{% ENDPOINTS %}", endpoints)
75 |
76 | index_html = (
77 | f"""Index base URL:
\n{config.index_base_url}
\n"""
78 | if config.index_base_url
79 | else ""
80 | )
81 | html = html.replace("{% INDEX_BASE_URL %}", index_html)
82 |
83 | resp = HTMLResponse(html)
84 | _PAGE_CACHE[cache_key] = resp
85 | return resp
86 |
87 |
88 | async def landing(request: Request):
89 | """Show landing page when the base URL is accessed."""
90 | return render_landing_page(
91 | request.app.state.config, request.app.state.entry_collections, str(request.url)
92 | )
93 |
94 |
95 | router = Router(routes=[Route("/", endpoint=landing)])
96 |
--------------------------------------------------------------------------------
/tests/filtertransformers/test_elasticsearch.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | elasticsearch_dsl = pytest.importorskip(
4 | "elasticsearch_dsl",
5 | reason="ElasticSearch dependencies (elasticsearch_dsl, elasticsearch) are required to run these tests.",
6 | )
7 |
8 | from optimade.filterparser import LarkParser
9 | from optimade.filtertransformers.elasticsearch import ElasticTransformer
10 |
11 |
12 | @pytest.fixture
13 | def parser():
14 | return LarkParser()
15 |
16 |
17 | @pytest.fixture
18 | def transformer():
19 | from optimade.server.mappers import StructureMapper
20 |
21 | return ElasticTransformer(mapper=StructureMapper())
22 |
23 |
24 | test_queries = [
25 | ("nelements > 1", 4),
26 | ("nelements >= 2", 4),
27 | ("nelements > 2", 1),
28 | ("nelements < 4", 4),
29 | ("nelements < 3", 3),
30 | ("nelements <= 3", 4),
31 | ("nelements != 2", 1),
32 | ("1 < nelements", 4),
33 | ('elements HAS "H"', 4),
34 | ('elements HAS ALL "H", "O"', 4),
35 | ('elements HAS ALL "H", "C"', 1),
36 | ('elements HAS ANY "H", "C"', 4),
37 | ('elements HAS ANY "C"', 1),
38 | # ('elements HAS ONLY "C"', 0),
39 | # ('elements HAS ONLY "H", "O"', 3),
40 | # ('elements:elements_ratios HAS "H":>0.66', 2),
41 | # ('elements:elements_ratios HAS ALL "O":>0.33', 3),
42 | # ('elements:elements_ratios HAS ALL "O":>0.33,"O":<0.34', 2),
43 | ("elements IS KNOWN", 4),
44 | ("elements IS UNKNOWN", 0),
45 | ('chemical_formula_reduced = "H2O"', 2),
46 | ('chemical_formula_reduced CONTAINS "H2"', 3),
47 | ('chemical_formula_reduced CONTAINS "H"', 4),
48 | ('chemical_formula_reduced CONTAINS "C"', 1),
49 | ('chemical_formula_reduced STARTS "H2"', 3),
50 | ('chemical_formula_reduced STARTS WITH "H2"', 3),
51 | ('chemical_formula_reduced ENDS WITH "C"', 1),
52 | ('chemical_formula_reduced ENDS "C"', 1),
53 | ("elements LENGTH = 2", 3),
54 | ("elements LENGTH = 3", 1),
55 | ("elements LENGTH > 1", 3),
56 | ("elementsLENGTH = 1", 1),
57 | ("nelements = 2 AND elements LENGTH = 2", 1),
58 | ("nelements = 3 AND elements LENGTH = 1", 0),
59 | ("nelements = 3 OR elements LENGTH = 1", 2),
60 | ("nelements > 1 OR elements LENGTH = 1 AND nelements = 2", 4),
61 | ("(nelements > 1 OR elements LENGTH = 1) AND nelements = 2", 3),
62 | ("NOT elements LENGTH = 1", 3),
63 | ("_exmpl2_field = 2", 1),
64 | ]
65 |
66 |
67 | @pytest.mark.parametrize("query", test_queries)
68 | def test_parse_n_transform(query, parser, transformer):
69 | tree = parser.parse(query[0])
70 | result = transformer.transform(tree)
71 | assert result is not None
72 |
73 |
74 | def test_bad_queries(parser, transformer):
75 | filter_ = "unknown_field = 0"
76 | with pytest.raises(
77 | Exception, match="'unknown_field' is not a known or searchable quantity"
78 | ) as exc_info:
79 | transformer.transform(parser.parse(filter_))
80 | assert exc_info.type.__name__ == "VisitError"
81 |
82 | filter_ = "dimension_types LENGTH = 0"
83 | with pytest.raises(
84 | Exception, match="LENGTH is not supported for 'dimension_types'"
85 | ) as exc_info:
86 | transformer.transform(parser.parse(filter_))
87 | assert exc_info.type.__name__ == "VisitError"
88 |
89 | filter_ = "_exmpl_field = 1"
90 | with pytest.raises(
91 | Exception, match="'_exmpl_field' is not a known or searchable quantity"
92 | ) as exc_info:
93 | transformer.transform(parser.parse(filter_))
94 | assert exc_info.type.__name__ == "VisitError"
95 |
--------------------------------------------------------------------------------
/docs/configuration.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | Since the server implementation is built with [FastAPI](https://fastapi.tiangolo.com/), which uses [pydantic](https://pydantic-docs.helpmanual.io/), the configuration is based on pydantic's [Setting management](https://pydantic-docs.helpmanual.io/usage/settings/).
4 | This way of handling configuration options supports various different approaches to configure the server.
5 | We recommend either or a combination of the following:
6 |
7 | 1. Create a JSON or YAML configuration file with an implementation's complete configuration in the default location [DEFAULT_CONFIG_FILE_PATH][optimade.server.config.DEFAULT_CONFIG_FILE_PATH] or specify its location with the `OPTIMADE_CONFIG_FILE` environment variable.
8 | 2. Set environment variables prefixed with `OPTIMADE_` or `optimade_`.
9 | 3. Create a custom [`ServerConfig`][optimade.server.config.ServerConfig] object with the desired settings directly.
10 | 4. Load settings from a secret file (see [pydantic documentation](https://pydantic-docs.helpmanual.io/usage/settings/#secret-support) for more information).
11 |
12 | ## The JSON configuration file
13 |
14 | The main way of configuring the OPTIMADE server is by creating a configuration JSON file.
15 |
16 | An example of one that works with the example implementation can be found in [`optimade_config.json`](static/optimade_config.json):
17 |
18 | === "Configuration file for the default OPTIMADE server"
19 |
20 | ```json
21 | --8<-- "optimade_config.json"
22 | ```
23 |
24 | ## Environment variables
25 |
26 | In order for the implementation to know where your configuration JSON file is located, you can set an environment variable `OPTIMADE_CONFIG_FILE` with either the value of the _absolute_ path to the configuration file or the _relative_ path to the file from the current working directory of where the server is run.
27 |
28 | This variable is actually an extension of the configuration option `config_file`.
29 | By default, the server will try to load a JSON file called `.optimade.json` located in your home folder (or equivalent).
30 |
31 | Here the generally recognized environment variable prefix becomes evident, namely `OPTIMADE_` or `optimade_`.
32 | Hence, you can set (or overwrite) any configuration option from the server's defaults or a value read from the configuration JSON by setting an environment variable named `OPTIMADE_`.
33 |
34 | ## Custom configuration options
35 |
36 | One can extend the current list of configuration options by sub-classing [`ServerConfig`][optimade.server.config.ServerConfig] and adding configuration options as attributes with values of `Field` (`pydantic.field`).
37 | Any attribute type will be validated through `pydantic` as is the case for all of the regular configuration options.
38 |
39 | This is useful for, e.g., custom database backends, if one wants to utilize the general server configuration setup implemented in `optimade` to declare specific database information.
40 | It can also be useful if one wishes to extend and build upon the general `optimade` server with new endpoints and routes.
41 |
42 | Remember to instantiate an instance of the sub-class, which can be imported and used in your application.
43 |
44 | ## List of configuration options
45 |
46 | See [`config.py`][optimade.server.config.ServerConfig] for a complete list of configuration options.
47 |
48 | The following configuration file represents the default values for all configuration options:
49 |
50 | === "Default values for all configuration options"
51 |
52 | ```json
53 | --8<-- "docs/static/default_config.json"
54 | ```
55 |
--------------------------------------------------------------------------------
/tests/server/middleware/test_versioned_url.py:
--------------------------------------------------------------------------------
1 | """Test CheckWronglyVersionedBaseUrls middleware"""
2 |
3 | import urllib.parse
4 |
5 | import pytest
6 |
7 | from optimade.server.exceptions import VersionNotSupported
8 | from optimade.server.middleware import CheckWronglyVersionedBaseUrls
9 |
10 |
11 | def test_wrong_version(both_clients):
12 | """If a non-supported versioned base URL is passed, `553 Version Not Supported` should be returned"""
13 | from optimade.server.config import ServerConfig
14 |
15 | CONFIG = ServerConfig()
16 |
17 | version = "/v0"
18 | urls = (
19 | f"{CONFIG.base_url}{version}/info",
20 | f"{CONFIG.base_url}{version}",
21 | )
22 |
23 | for url in urls:
24 | with pytest.raises(VersionNotSupported):
25 | CheckWronglyVersionedBaseUrls(both_clients.app).check_url(
26 | CONFIG, urllib.parse.urlparse(url)
27 | )
28 |
29 |
30 | def test_wrong_version_json_response(check_error_response, both_clients):
31 | """If a non-supported versioned base URL is passed, `553 Version Not Supported` should be returned
32 |
33 | A specific JSON response should also occur.
34 | """
35 | from optimade.server.routers.utils import BASE_URL_PREFIXES
36 |
37 | version = "/v0"
38 | request = f"{version}/info"
39 | with pytest.raises(VersionNotSupported):
40 | check_error_response(
41 | request,
42 | expected_status=553,
43 | expected_title="Version Not Supported",
44 | expected_detail=(
45 | f"The parsed versioned base URL {version!r} from '{both_clients.base_url}{request}' is not supported by this implementation. "
46 | f"Supported versioned base URLs are: {', '.join(BASE_URL_PREFIXES.values())}"
47 | ),
48 | server=both_clients,
49 | )
50 |
51 |
52 | def test_multiple_versions_in_path(both_clients):
53 | """If another version is buried in the URL path, only the OPTIMADE versioned URL path part should be recognized."""
54 | from optimade.server.config import ServerConfig
55 | from optimade.server.routers.utils import BASE_URL_PREFIXES
56 |
57 | CONFIG = ServerConfig()
58 | non_valid_version = "/v0.5"
59 | org_base_url = CONFIG.base_url
60 |
61 | try:
62 | CONFIG.base_url = f"https://example.org{non_valid_version}/my_database/optimade"
63 |
64 | for valid_version_prefix in BASE_URL_PREFIXES.values():
65 | url = f"{CONFIG.base_url}{valid_version_prefix}/info"
66 | CheckWronglyVersionedBaseUrls(both_clients.app).check_url(
67 | CONFIG, urllib.parse.urlparse(url)
68 | )
69 |
70 | # Test also that a non-valid OPTIMADE version raises
71 | url = f"{CONFIG.base_url}/v0/info"
72 | with pytest.raises(VersionNotSupported):
73 | CheckWronglyVersionedBaseUrls(both_clients.app).check_url(
74 | CONFIG, urllib.parse.urlparse(url)
75 | )
76 | finally:
77 | if org_base_url:
78 | CONFIG.base_url = org_base_url
79 | else:
80 | CONFIG.base_url = None
81 |
82 |
83 | def test_versioned_base_urls(both_clients):
84 | """Test the middleware does not wrongly catch requests to versioned base URLs"""
85 | from optimade.server.config import ServerConfig
86 | from optimade.server.routers.utils import BASE_URL_PREFIXES
87 |
88 | CONFIG = ServerConfig()
89 |
90 | for request in BASE_URL_PREFIXES.values():
91 | CheckWronglyVersionedBaseUrls(both_clients.app).check_url(
92 | CONFIG, urllib.parse.urlparse(f"{CONFIG.base_url}{request}")
93 | )
94 |
--------------------------------------------------------------------------------
/docs/deployment/integrated.md:
--------------------------------------------------------------------------------
1 | # Integrate OPTIMADE with an existing web application
2 |
3 | The `optimade` package can be used to create a standalone web application that serves the OPTIMADE API based on a pre-configured MongoDB backend.
4 | In this document, we are going to use `optimade` differently and use it to add an OPTIMADE API implementation alongside an existing API that employs an Elasticsearch storage layer.
5 |
6 | Let's assume we already have a _FastAPI_ application that runs an unrelated web service, and that we use an Elasticsearch backend that contains all structure data, but not necessarily in a form that OPTIMADE expects.
7 |
8 | ## Providing the `optimade` configuration
9 |
10 | `optimade` can read its configuration from a JSON file.
11 | It uses the `OPTIMADE_CONFIG_FILE` environment variable (or a default path) to find the config file.
12 | If you run `optimade` code inside another application, you might want to provide this config file as part of the source code and not via environment variables.
13 | Let's say you have a file `optimade_config.json` as part of the Python module that you use to create your OPTIMADE API.
14 |
15 | !!! tip
16 | You can find more detailed information about configuring the `optimade` server in the [Configuration](../configuration.md) section.
17 |
18 | Before importing any `optimade` modules, you can set the `OPTIMADE_CONFIG_FILE` environment variable to refer to your config file:
19 |
20 | ```python
21 | import os
22 | from pathlib import Path
23 |
24 | os.environ['OPTIMADE_CONFIG_FILE'] = str(Path(__file__).parent / "optimade_config.json")
25 | ```
26 |
27 | ## Customize the [`EntryCollection`][optimade.server.entry_collections.entry_collections.EntryCollection] implementation
28 |
29 | Let's assume that your Elasticsearch backend stores structure data in a different enough manner that you need to provide your own custom implementation.
30 | The following code customizes the [`EntryCollection`][optimade.server.entry_collections.entry_collections.EntryCollection] class for structures, whilst keeping the default MongoDB-based implementation (using [`MongoCollection`][optimade.server.entry_collections.mongo.MongoCollection]) for all other entry types.
31 |
32 | ```python
33 | from optimade.server.routers import structures
34 |
35 | structures.structures_coll = MyElasticsearchStructureCollection()
36 | ```
37 |
38 | You can imagine that `MyElasticsearchStructureCollection` either sub-classes the default `optimade` Elasticsearch implementation ([`ElasticsearchCollection`][optimade.server.entry_collections.elasticsearch.ElasticCollection]) or sub-classes [`EntryCollection`][optimade.server.entry_collections.entry_collections.EntryCollection], depending on how deeply you need to customize the default `optimade` behavior.
39 |
40 | ## Mounting the OPTIMADE Python tools _FastAPI_ app into an existing _FastAPI_ app
41 |
42 | Let's assume you have an existing _FastAPI_ app `my_app`.
43 | It already implements a few routers under certain path prefixes, and now you want to add an OPTIMADE implementation under the path prefix `/optimade`.
44 |
45 | The primary thing to modify is the `base_url` to match the new subpath. The easiest is to just update your configuration file or env parameters.
46 |
47 | Then one can just simply do the following:
48 |
49 | ```python
50 | from optimade.server.main import main as optimade
51 |
52 | my_app.mount("/optimade", optimade.app)
53 | ```
54 |
55 | See also the _FastAPI_ documentation on [sub-applications](https://fastapi.tiangolo.com/advanced/sub-applications/).
56 |
57 | Now, if you run `my_app`, it will still serve all its routers as before and in addition it will also serve all OPTIMADE routes under `/optimade/`.
58 |
--------------------------------------------------------------------------------
/optimade/grammar/v1.0.0.lark:
--------------------------------------------------------------------------------
1 | // optimade v1.0.0 (also valid for v0.10.1) grammar spec in lark grammar format
2 |
3 | ?start: filter
4 | filter: expression*
5 |
6 | // Values
7 | constant: string | number
8 | // Note: support for property in value is OPTIONAL
9 | value: string | number | property
10 |
11 | // Note: not_implemented_string is only here to help Transformers
12 | non_string_value: number | property
13 | not_implemented_string: string
14 |
15 | // Note: support for OPERATOR in value_list is OPTIONAL
16 | value_list: [ OPERATOR ] value ( "," [ OPERATOR ] value )*
17 | // Note: support for OPERATOR in value_zip is OPTIONAL
18 | value_zip: [ OPERATOR ] value ":" [ OPERATOR ] value (":" [ OPERATOR ] value)*
19 | value_zip_list: value_zip ( "," value_zip )*
20 |
21 | // Expressions
22 | expression: expression_clause ( _OR expression_clause )*
23 | expression_clause: expression_phrase ( _AND expression_phrase )*
24 | expression_phrase: [ NOT ] ( comparison | "(" expression ")" )
25 | // Note: support for constant_first_comparison is OPTIONAL
26 | comparison: constant_first_comparison | property_first_comparison
27 |
28 | // Note: support for set_zip_op_rhs in comparison is OPTIONAL
29 | property_first_comparison: property ( value_op_rhs
30 | | known_op_rhs
31 | | fuzzy_string_op_rhs
32 | | set_op_rhs
33 | | set_zip_op_rhs
34 | | length_op_rhs )
35 |
36 | constant_first_comparison: constant OPERATOR ( non_string_value | not_implemented_string )
37 |
38 | value_op_rhs: OPERATOR value
39 | known_op_rhs: IS ( KNOWN | UNKNOWN )
40 | fuzzy_string_op_rhs: CONTAINS value
41 | | STARTS [ WITH ] value
42 | | ENDS [ WITH ] value
43 | // Note: support for ONLY in set_op_rhs is OPTIONAL
44 | // Note: support for [ OPERATOR ] in set_op_rhs is OPTIONAL
45 | // set_op_rhs: HAS [ ALL | ANY | ONLY] value_list
46 | set_op_rhs: HAS ( [ OPERATOR ] value
47 | | ALL value_list
48 | | ANY value_list
49 | | ONLY value_list )
50 |
51 | // Note: support for [ OPERATOR ] is OPTIONAL
52 | length_op_rhs: LENGTH [ OPERATOR ] signed_int
53 |
54 | set_zip_op_rhs: property_zip_addon HAS ( value_zip | ONLY value_zip_list | ALL value_zip_list | ANY value_zip_list )
55 | property_zip_addon: ":" property (":" property)*
56 |
57 | // Property syntax
58 | property: IDENTIFIER ( "." IDENTIFIER )*
59 |
60 | // String syntax
61 | string: ESCAPED_STRING
62 |
63 | // Number token syntax
64 | number: SIGNED_INT | SIGNED_FLOAT
65 |
66 | // Custom signed int
67 | signed_int: SIGNED_INT
68 |
69 | // Tokens
70 |
71 | // Boolean relations
72 | _AND: "AND"
73 | _OR: "OR"
74 | NOT: "NOT"
75 |
76 | IS: "IS"
77 | KNOWN: "KNOWN"
78 | UNKNOWN: "UNKNOWN"
79 |
80 | CONTAINS: "CONTAINS"
81 | STARTS: "STARTS"
82 | ENDS: "ENDS"
83 | WITH: "WITH"
84 |
85 | LENGTH: "LENGTH"
86 | HAS: "HAS"
87 | ALL: "ALL"
88 | ONLY: "ONLY"
89 | ANY: "ANY"
90 |
91 | // Comparison OPERATORs
92 | OPERATOR: ( "<" ["="] | ">" ["="] | ["!"] "=" )
93 |
94 | IDENTIFIER: ( "_" | LCASE_LETTER ) ( "_" | LCASE_LETTER | DIGIT )*
95 | LCASE_LETTER: "a".."z"
96 | DIGIT: "0".."9"
97 |
98 | // Strings
99 |
100 | _STRING_INNER: /(.|[\t\f\r\n])*?/
101 | _STRING_ESC_INNER: _STRING_INNER /(?" ["="] | ["!"] "=" )
96 |
97 | IDENTIFIER: ( "_" | LCASE_LETTER ) ( "_" | LCASE_LETTER | DIGIT )*
98 | NESTED_IDENTIFIER: ( "_" | LCASE_LETTER ) ( "_" | "+" | LCASE_LETTER | DIGIT )*
99 | LCASE_LETTER: "a".."z"
100 | DIGIT: "0".."9"
101 |
102 | // Strings
103 |
104 | _STRING_INNER: /(.|[\t\f\r\n])*?/
105 | _STRING_ESC_INNER: _STRING_INNER /(? None:
14 | """Make sure warnings.showwarning can be overloaded correctly"""
15 | import warnings
16 |
17 | from optimade.server.middleware import AddWarnings
18 | from optimade.server.warnings import OptimadeWarning
19 |
20 | add_warning_middleware = AddWarnings(both_clients.app)
21 | # Set up things that are setup usually in `dispatch()`
22 | add_warning_middleware._warnings = []
23 |
24 | warnings.showwarning = add_warning_middleware.showwarning
25 |
26 | warning_message = "It's all gone awry!"
27 |
28 | warnings.warn(OptimadeWarning(detail=warning_message))
29 |
30 | assert add_warning_middleware._warnings == [
31 | {"title": OptimadeWarning.__name__, "detail": warning_message}
32 | ]
33 |
34 | # Make sure a "normal" warning is treated as usual
35 | warnings.warn(warning_message, UserWarning)
36 | assert len(add_warning_middleware._warnings) == 1
37 | assert (
38 | len(recwarn.list) >= 2
39 | ) # Ensure at least two warnings were raised (the ones we just added)
40 | assert recwarn.pop(OptimadeWarning)
41 | assert recwarn.pop(UserWarning)
42 |
43 |
44 | def test_showwarning_debug(both_clients, recwarn):
45 | """Make sure warnings.showwarning adds 'meta' field in DEBUG MODE"""
46 | import warnings
47 |
48 | from optimade.server.config import ServerConfig
49 | from optimade.server.middleware import AddWarnings
50 | from optimade.server.warnings import OptimadeWarning
51 |
52 | CONFIG = ServerConfig()
53 | CONFIG.debug = True
54 |
55 | add_warning_middleware = AddWarnings(both_clients.app, CONFIG)
56 | # Set up things that are setup usually in `dispatch()`
57 | add_warning_middleware._warnings = []
58 |
59 | warnings.showwarning = add_warning_middleware.showwarning
60 |
61 | warning_message = "It's all gone awry!"
62 |
63 | warnings.warn(OptimadeWarning(detail=warning_message))
64 |
65 | assert add_warning_middleware._warnings != [
66 | {"title": OptimadeWarning.__name__, "detail": warning_message}
67 | ]
68 | warning = add_warning_middleware._warnings[0]
69 | assert "meta" in warning
70 | for meta_field in ("filename", "lineno", "line"):
71 | assert meta_field in warning["meta"]
72 | assert (
73 | len(recwarn.list) >= 1
74 | ) # Ensure at least one warning was raised (the one we just added)
75 | assert recwarn.pop(OptimadeWarning)
76 |
77 |
78 | def test_chunk_it_up():
79 | """Make sure all content is return from `chunk_it_up` generator"""
80 | from optimade.server.middleware import AddWarnings
81 |
82 | content = "Oh what a sad and tragic waste of a young attractive life!"
83 | chunk_size = 16
84 | assert len(content) % chunk_size != 0, "We want a rest bit, this isn't helpful!"
85 |
86 | generator = AddWarnings.chunk_it_up(content, chunk_size)
87 | assert "".join(generator) == content
88 |
89 |
90 | def test_empty_response_chunk_it_up(client_with_empty_extension_endpoint):
91 | """Test that the chunking induced by the warnings middleware can handle
92 | responses with empty bodies."""
93 | from optimade.server.middleware import AddWarnings
94 |
95 | add_warning_middleware = AddWarnings(client_with_empty_extension_endpoint.app)
96 |
97 | response = client_with_empty_extension_endpoint.get("/extensions/test_empty_body")
98 | add_warning_middleware._warnings = []
99 | assert response.content == b""
100 |
--------------------------------------------------------------------------------
/tests/adapters/references/test_references.py:
--------------------------------------------------------------------------------
1 | """Test Reference adapter"""
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | import pytest
6 |
7 | if TYPE_CHECKING:
8 | from typing import Any, Union
9 |
10 | from optimade.adapters.references import Reference
11 |
12 |
13 | def test_instantiate(RAW_REFERENCES: "list[dict[str, Any]]") -> None:
14 | """Try instantiating Reference for all raw test references"""
15 | from optimade.adapters.references import Reference
16 | from optimade.models.references import ReferenceResource
17 |
18 | for reference in RAW_REFERENCES:
19 | new_Reference = Reference(reference)
20 | assert isinstance(new_Reference.entry, ReferenceResource)
21 |
22 |
23 | def test_setting_entry(RAW_REFERENCES: "list[dict[str, Any]]") -> None:
24 | """Make sure entry can only be set once"""
25 | from optimade.adapters.references import Reference
26 |
27 | reference = Reference(RAW_REFERENCES[0])
28 | with pytest.raises(AttributeError):
29 | reference.entry = RAW_REFERENCES[1]
30 |
31 |
32 | @pytest.mark.skip("Currently, there are no conversion types available for references")
33 | def test_convert(reference: "Reference") -> None:
34 | """Test convert() works
35 | Choose currently known entry type - must be updated if no longer available.
36 | """
37 | if not reference._type_converters:
38 | pytest.fail("_type_converters is seemingly empty. This should not be.")
39 |
40 | chosen_type = "SOME_VALID_TYPE"
41 | if chosen_type not in reference._type_converters:
42 | pytest.fail(
43 | f"{chosen_type} not found in _type_converters: {reference._type_converters} - "
44 | "please update test tests/adapters/references/test_references.py:TestReference."
45 | "test_convert()"
46 | )
47 |
48 | converted_reference = reference.convert(chosen_type)
49 | assert isinstance(converted_reference, (str, None.__class__))
50 | assert converted_reference == reference._converted[chosen_type]
51 |
52 |
53 | def test_convert_wrong_format(reference: "Reference") -> None:
54 | """Test AttributeError is raised if format does not exist"""
55 | nonexistant_format: "Union[str, int]" = 0
56 | right_wrong_format_found = False
57 | while not right_wrong_format_found:
58 | if str(nonexistant_format) not in reference._type_converters:
59 | nonexistant_format = str(nonexistant_format)
60 | right_wrong_format_found = True
61 | else:
62 | assert isinstance(nonexistant_format, int)
63 | nonexistant_format += 1
64 |
65 | with pytest.raises(
66 | AttributeError,
67 | match=f"Non-valid entry type to convert to: {nonexistant_format}",
68 | ):
69 | reference.convert(nonexistant_format)
70 |
71 |
72 | def test_getattr_order(reference: "Reference") -> None:
73 | """The order of getting an attribute should be:
74 | 1. `as_`
75 | 2. ``
76 | 3. ``
77 | 3. `raise AttributeError with custom message`
78 | """
79 | # If passing attribute starting with `as_`, it should call `self.convert()`
80 | with pytest.raises(AttributeError, match="Non-valid entry type to convert to: "):
81 | reference.as_
82 |
83 | # If passing valid ReferenceResource attribute, it should return said attribute
84 | for attribute, attribute_type in (
85 | ("id", str),
86 | ("authors", list),
87 | ("attributes.authors", list),
88 | ):
89 | assert isinstance(getattr(reference, attribute), attribute_type)
90 |
91 | # Otherwise, it should raise AttributeError
92 | for attribute in ("nonexistant_attribute", "attributes.nonexistant_attribute"):
93 | with pytest.raises(AttributeError, match=f"Unknown attribute: {attribute}"):
94 | getattr(reference, attribute)
95 |
--------------------------------------------------------------------------------
/optimade/filterparser/lark_parser.py:
--------------------------------------------------------------------------------
1 | """This submodule implements the [`LarkParser`][optimade.filterparser.lark_parser.LarkParser] class,
2 | which uses the lark library to parse filter strings with a defined OPTIMADE filter grammar
3 | into `Lark.Tree` objects for use by the filter transformers.
4 |
5 | """
6 |
7 | from pathlib import Path
8 |
9 | from lark import Lark, Tree
10 |
11 | from optimade.exceptions import BadRequest
12 |
13 | __all__ = ("ParserError", "LarkParser")
14 |
15 |
16 | class ParserError(Exception):
17 | """Triggered by critical parsing errors that should lead
18 | to 500 Server Error HTTP statuses.
19 | """
20 |
21 |
22 | def get_versions() -> dict[tuple[int, int, int], dict[str, Path]]:
23 | """Find grammar files within this package's grammar directory,
24 | returning a dictionary broken down by scraped grammar version
25 | (major, minor, patch) and variant (a string tag).
26 |
27 | Returns:
28 | A mapping from version, variant to grammar file name.
29 |
30 | """
31 | dct: dict[tuple[int, int, int], dict[str, Path]] = {}
32 | for filename in Path(__file__).parent.joinpath("../grammar").glob("*.lark"):
33 | tags = filename.stem.lstrip("v").split(".")
34 | version: tuple[int, int, int] = (int(tags[0]), int(tags[1]), int(tags[2]))
35 | variant: str = "default" if len(tags) == 3 else str(tags[-1])
36 | if version not in dct:
37 | dct[version] = {}
38 | dct[version][variant] = filename
39 | return dct
40 |
41 |
42 | AVAILABLE_PARSERS = get_versions()
43 |
44 |
45 | class LarkParser:
46 | """This class wraps a versioned OPTIMADE grammar and allows
47 | it to be parsed into Lark tree objects.
48 |
49 | """
50 |
51 | def __init__(
52 | self, version: tuple[int, int, int] | None = None, variant: str = "default"
53 | ):
54 | """For a given version and variant, try to load the corresponding grammar.
55 |
56 | Parameters:
57 | version: The grammar version number to use (e.g., `(1, 0, 1)` for v1.0.1).
58 | variant: The grammar variant to employ.
59 |
60 | Raises:
61 | ParserError: If the requested version/variant of the
62 | grammar does not exist.
63 |
64 | """
65 |
66 | if not version:
67 | version = max(
68 | _ for _ in AVAILABLE_PARSERS if AVAILABLE_PARSERS[_].get("default")
69 | )
70 |
71 | if version not in AVAILABLE_PARSERS:
72 | raise ParserError(f"Unknown parser grammar version: {version}")
73 |
74 | if variant not in AVAILABLE_PARSERS[version]:
75 | raise ParserError(f"Unknown variant of the parser: {variant}")
76 |
77 | self.version = version
78 | self.variant = variant
79 |
80 | with open(AVAILABLE_PARSERS[version][variant]) as f:
81 | self.lark = Lark(f, maybe_placeholders=False)
82 |
83 | self.tree: Tree | None = None
84 | self.filter: str | None = None
85 |
86 | def parse(self, filter_: str) -> Tree:
87 | """Parse a filter string into a `lark.Tree`.
88 |
89 | Parameters:
90 | filter_: The filter string to parse.
91 |
92 | Raises:
93 | BadRequest: If the filter cannot be parsed.
94 |
95 | Returns:
96 | The parsed filter.
97 |
98 | """
99 | try:
100 | self.tree = self.lark.parse(filter_)
101 | self.filter = filter_
102 | return self.tree
103 | except Exception as exc:
104 | raise BadRequest(
105 | detail=f"Unable to parse filter {filter_}. Lark traceback: \n{exc}"
106 | ) from exc
107 |
108 | def __repr__(self):
109 | if isinstance(self.tree, Tree):
110 | return self.tree.pretty()
111 | return repr(self.lark)
112 |
--------------------------------------------------------------------------------
/docs/concepts/validation.md:
--------------------------------------------------------------------------------
1 | # Validation of OPTIMADE APIs
2 |
3 | `optimade-python-tools` contains tools for validating external OPTIMADE implementations that may be helpful for all OPTIMADE providers.
4 | The validator is dynamic and fuzzy, in that the tested filters are generated based on *random* entries served by the API, and the description of the API provided at the `/info` endpoint.
5 |
6 | The validator is implemented in the [`optimade.validator`][optimade.validator.validator] submodule, but the two main entry points are:
7 |
8 | 1. The `optimade-validator` script, which is installed alongside the package.
9 | 2. The [`optimade-validator-action`](https://github.com/Materials-Consortia/optimade-validator-action) which allows the validator to be used as a GitHub Action.
10 |
11 | To run the script, simply provide an OPTIMADE URL to the script at the command-line.
12 | You can use the following to validate the Fly deployment of our reference server:
13 |
14 | ```shell
15 | $ optimade-validator https://optimade.fly.dev/
16 | Testing entire implementation at https://optimade.fly.dev
17 | ...
18 | ```
19 |
20 | Several additional options can be found under the `--help` flag, with the most important being `-v/-vvvv` to set the verbosity, `--index` to validate OPTIMADE index meta-databases and `--json` to receive the validation results as JSON document for programmatic use.
21 |
22 | ```shell
23 | $ optimade-validator --help
24 | usage: optimade-validator [-h] [-v] [-j] [-t AS_TYPE] [--index]
25 | [--skip-optional] [--fail-fast] [-m]
26 | [--page_limit PAGE_LIMIT]
27 | [--headers HEADERS]
28 | [base_url]
29 |
30 | Tests OPTIMADE implementations for compliance with the optimade-python-tools models.
31 |
32 | - To test an entire implementation (at say example.com/optimade/v1) for all required/available endpoints:
33 |
34 | $ optimade-validator http://example.com/optimade/v1
35 |
36 | - To test a particular response of an implementation against a particular model:
37 |
38 | $ optimade-validator http://example.com/optimade/v1/structures/id=1234 --as-type structure
39 |
40 | - To test a particular response of an implementation against a particular model:
41 |
42 | $ optimade-validator http://example.com/optimade/v1/structures --as-type structures
43 |
44 |
45 | positional arguments:
46 | base_url The base URL of the OPTIMADE
47 | implementation to point at, e.g.
48 | 'http://example.com/optimade/v1' or
49 | 'http://localhost:5000/v1'
50 |
51 | optional arguments:
52 | -h, --help show this help message and exit
53 | -v, --verbosity Increase the verbosity of the output.
54 | (-v: warning, -vv: info, -vvv: debug)
55 | -j, --json Only a JSON summary of the validator
56 | results will be printed to stdout.
57 | -t AS_TYPE, --as-type AS_TYPE
58 | Validate the request URL with the
59 | provided type, rather than scanning the
60 | entire implementation e.g. optimade-
61 | validator `http://example.com/optimade/v1
62 | /structures/0 --as-type structure`
63 | --index Flag for whether the specified OPTIMADE
64 | implementation is an Index meta-database
65 | or not.
66 | --skip-optional Flag for whether the skip the tests of
67 | optional features.
68 | --fail-fast Whether to exit on first test failure.
69 | -m, --minimal Run only a minimal test set.
70 | --page_limit PAGE_LIMIT
71 | Alter the requested page limit for some
72 | tests.
73 | --headers HEADERS Additional HTTP headers to use for each
74 | request, specified as a JSON object.
75 | ```
76 |
--------------------------------------------------------------------------------
/tests/server/test_mappers.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from optimade.models import StructureResource
4 | from optimade.server.config import ServerConfig
5 | from optimade.server.mappers import BaseResourceMapper
6 |
7 | CONFIG = ServerConfig()
8 |
9 |
10 | @pytest.mark.skipif(
11 | CONFIG.database_backend.value not in ("mongomock", "mongodb"),
12 | reason="Skipping mongo-related test when testing the elasticsearch backend.",
13 | )
14 | def test_disallowed_aliases():
15 | from optimade.server.entry_collections.mongo import MongoCollection
16 |
17 | class MyMapper(BaseResourceMapper):
18 | ENTRY_RESOURCE_CLASS = StructureResource
19 | ALIASES = (("$and", "my_special_and"), ("not", "$not"))
20 |
21 | mapper = MyMapper()
22 | with pytest.raises(RuntimeError):
23 | MongoCollection("fake", StructureResource, mapper, config=CONFIG)
24 |
25 |
26 | def test_property_aliases():
27 | class MyMapper(BaseResourceMapper):
28 | ENTRY_RESOURCE_CLASS = StructureResource
29 | PROVIDER_FIELDS = ("dft_parameters", "test_field")
30 | LENGTH_ALIASES = (("_exmpl_test_field", "test_field_len"),)
31 | ALIASES = (("field", "completely_different_field"),)
32 |
33 | mapper = MyMapper()
34 | assert mapper.get_backend_field("_exmpl_dft_parameters") == "dft_parameters"
35 | assert mapper.get_backend_field("_exmpl_test_field") == "test_field"
36 | assert mapper.get_backend_field("field") == "completely_different_field"
37 | assert mapper.length_alias_for("_exmpl_test_field") == "test_field_len"
38 | assert mapper.length_alias_for("test_field") is None
39 | assert mapper.get_backend_field("test_field") == "test_field"
40 | with pytest.warns(DeprecationWarning):
41 | assert mapper.alias_for("test_field") == "test_field"
42 |
43 | assert mapper.get_optimade_field("dft_parameters") == "_exmpl_dft_parameters"
44 | assert mapper.get_optimade_field("test_field") == "_exmpl_test_field"
45 | assert mapper.get_optimade_field("completely_different_field") == "field"
46 | assert mapper.get_optimade_field("nonexistent_field") == "nonexistent_field"
47 | with pytest.warns(DeprecationWarning):
48 | assert mapper.alias_of("nonexistent_field") == "nonexistent_field"
49 |
50 | # nested properties
51 | assert (
52 | mapper.get_backend_field("_exmpl_dft_parameters.nested.property")
53 | == "dft_parameters.nested.property"
54 | )
55 | assert (
56 | mapper.get_backend_field("_exmpl_dft_parameters.nested_property")
57 | == "dft_parameters.nested_property"
58 | )
59 |
60 | # test nonsensical query
61 | assert mapper.get_backend_field("_exmpl_test_field.") == "test_field."
62 |
63 | # test an awkward case that has no alias
64 | assert (
65 | mapper.get_backend_field("_exmpl_dft_parameters_dft_parameters.nested.property")
66 | == "_exmpl_dft_parameters_dft_parameters.nested.property"
67 | )
68 |
69 |
70 | def test_cached_mapper_properties():
71 | """Tests that alias caching both occurs, and is not effected
72 | by the presence of other mapper caches.
73 | """
74 |
75 | class MyMapper(BaseResourceMapper):
76 | ENTRY_RESOURCE_CLASS = StructureResource
77 | ALIASES = (("field", "completely_different_field"),)
78 |
79 | class MyOtherMapper(BaseResourceMapper):
80 | ENTRY_RESOURCE_CLASS = StructureResource
81 | ALIASES = (
82 | ("field", "completely_different_field2"),
83 | ("a", "b"),
84 | )
85 |
86 | m1 = MyMapper()
87 | m2 = MyOtherMapper()
88 | assert m1.get_backend_field("field") == "completely_different_field"
89 | assert m1.get_backend_field("field") == "completely_different_field"
90 | assert m2.get_backend_field("field") == "completely_different_field2"
91 | assert m2.get_backend_field("field") == "completely_different_field2"
92 |
93 | assert m2.get_backend_field("a") == "b"
94 | assert m2.get_backend_field("a") == "b"
95 | assert m1.get_backend_field("a") == "a"
96 |
--------------------------------------------------------------------------------
/optimade/server/entry_collections/elastic_indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": {
3 | "mappings": {
4 | "properties": {
5 | "address": {
6 | "type": "keyword"
7 | },
8 | "annote": {
9 | "type": "keyword"
10 | },
11 | "authors": {
12 | "properties": {
13 | "name": {
14 | "type": "keyword"
15 | },
16 | "firstname": {
17 | "type": "keyword"
18 | },
19 | "lastname": {
20 | "type": "keyword"
21 | }
22 | }
23 | },
24 | "bib_type": {
25 | "type": "keyword"
26 | },
27 | "booktitle": {
28 | "type": "keyword"
29 | },
30 | "chapter": {
31 | "type": "keyword"
32 | },
33 | "crossref": {
34 | "type": "keyword"
35 | },
36 | "doi": {
37 | "type": "keyword"
38 | },
39 | "edition": {
40 | "type": "keyword"
41 | },
42 | "editors": {
43 | "type": "keyword"
44 | },
45 | "howpublished": {
46 | "type": "keyword"
47 | },
48 | "id": {
49 | "type": "keyword"
50 | },
51 | "immutable_id": {
52 | "type": "keyword"
53 | },
54 | "institution": {
55 | "type": "keyword"
56 | },
57 | "journal": {
58 | "type": "keyword"
59 | },
60 | "key": {
61 | "type": "keyword"
62 | },
63 | "last_modified": {
64 | "type": "date"
65 | },
66 | "links": {
67 | "type": "keyword"
68 | },
69 | "month": {
70 | "type": "keyword"
71 | },
72 | "note": {
73 | "type": "keyword"
74 | },
75 | "number": {
76 | "type": "keyword"
77 | },
78 | "organization": {
79 | "type": "keyword"
80 | },
81 | "pages": {
82 | "type": "keyword"
83 | },
84 | "publisher": {
85 | "type": "keyword"
86 | },
87 | "relationships": {
88 | "type": "keyword"
89 | },
90 | "school": {
91 | "type": "keyword"
92 | },
93 | "series": {
94 | "type": "keyword"
95 | },
96 | "title": {
97 | "type": "keyword"
98 | },
99 | "type": {
100 | "type": "keyword"
101 | },
102 | "url": {
103 | "type": "keyword"
104 | },
105 | "volume": {
106 | "type": "keyword"
107 | },
108 | "year": {
109 | "type": "keyword"
110 | }
111 | }
112 | }
113 | },
114 | "structures": {
115 | "mappings": {
116 | "properties": {
117 | "chemical_formula_anonymous": {
118 | "type": "keyword"
119 | },
120 | "chemical_formula_descriptive": {
121 | "type": "keyword"
122 | },
123 | "chemical_formula_hill": {
124 | "type": "keyword"
125 | },
126 | "chemical_formula_reduced": {
127 | "type": "keyword"
128 | },
129 | "dimension_types": {
130 | "type": "integer"
131 | },
132 | "elements": {
133 | "type": "keyword"
134 | },
135 | "elements_ratios": {
136 | "type": "long"
137 | },
138 | "id": {
139 | "type": "keyword"
140 | },
141 | "nelements": {
142 | "type": "integer"
143 | },
144 | "nsites": {
145 | "type": "integer"
146 | },
147 | "species_at_sites": {
148 | "type": "keyword"
149 | },
150 | "structure_features": {
151 | "type": "keyword"
152 | },
153 | "lattice_vectors": {
154 | "type": "float"
155 | }
156 | }
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/tests/models/test_baseinfo.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from pydantic import ValidationError
3 |
4 | from optimade.models.baseinfo import AvailableApiVersion
5 | from optimade.models.entries import EntryInfoResource
6 |
7 |
8 | def test_available_api_versions():
9 | """Check version formatting for available_api_versions"""
10 |
11 | bad_urls = [
12 | {"url": "asfdsafhttps://example.com/v0.0", "version": "0.0.0"},
13 | {"url": "https://example.com/optimade", "version": "1.0.0"},
14 | {"url": "https://example.com/v0999", "version": "0999.0.0"},
15 | {"url": "http://example.com/v2.3", "version": "2.3.0"},
16 | {"url": "https://example.com/v1.0.0-rc.2", "version": "1.0.0-rc.2"},
17 | ]
18 | bad_versions = [
19 | {"url": "https://example.com/v0", "version": "v0.1.9"},
20 | {"url": "https://example.com/v0", "version": "0.1"},
21 | {"url": "https://example.com/v1", "version": "1.0"},
22 | {"url": "https://example.com/v1.0.2", "version": "v1.0.2"},
23 | {"url": "https://example.com/optimade/v1.2", "version": "v1.2.3"},
24 | {"url": "https://example.com/v1.0.0", "version": "1.asdfaf.0-rc55"},
25 | ]
26 | bad_combos = [
27 | {"url": "https://example.com/v0", "version": "1.0.0"},
28 | {"url": "https://example.com/v1.0.2", "version": "1.0.3"},
29 | {"url": "https://example.com/optimade/v1.2", "version": "1.3.2"},
30 | ]
31 | good_combos = [
32 | {"url": "https://example.com/v0", "version": "0.1.9"},
33 | {"url": "https://example.com/v1.0.2", "version": "1.0.2"},
34 | {"url": "https://example.com/optimade/v1.2", "version": "1.2.3"},
35 | {"url": "https://example.com/v1.0.0", "version": "1.0.0-rc.2"},
36 | {"url": "https://example.com/v1.0.0", "version": "1.0.0-rc2+develop-x86-64"},
37 | {
38 | "url": "https://example.com/v1.0.1",
39 | "version": "1.0.1-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay",
40 | },
41 | ]
42 |
43 | for data in bad_urls:
44 | if not data["url"].startswith("http"):
45 | with pytest.raises(ValidationError):
46 | AvailableApiVersion(**data)
47 | else:
48 | with pytest.raises(ValueError):
49 | AvailableApiVersion(**data)
50 |
51 | for data in bad_versions:
52 | with pytest.raises(ValueError):
53 | AvailableApiVersion(**data)
54 |
55 | for data in bad_combos:
56 | with pytest.raises(ValueError):
57 | AvailableApiVersion(**data)
58 |
59 | for data in good_combos:
60 | assert isinstance(AvailableApiVersion(**data), AvailableApiVersion)
61 |
62 |
63 | def test_good_entry_info_custom_properties():
64 | test_good_info = {
65 | "id": "structures",
66 | "type": "info",
67 | "formats": ["json", "xml"],
68 | "description": "good info data",
69 | "output_fields_by_format": {"json": ["nelements", "id"]},
70 | "properties": {
71 | "nelements": {"description": "num elements", "type": "integer"},
72 | "_custom_field": {"description": "good custom field", "type": "string"},
73 | },
74 | }
75 | assert EntryInfoResource(**test_good_info)
76 |
77 |
78 | def test_bad_entry_info_custom_properties():
79 | """Checks that error is raised if custom field contains upper case letter."""
80 | test_bad_info = {
81 | "id": "structures",
82 | "type": "info",
83 | "formats": ["json", "xml"],
84 | "description": "bad info data",
85 | "output_fields_by_format": {"json": ["nelements", "id"]},
86 | "properties": {
87 | "nelements": {"description": "num elements", "type": "integer"},
88 | "_custom_Field": {"description": "bad custom field", "type": "string"},
89 | },
90 | }
91 | with pytest.raises(
92 | ValueError,
93 | match=".*[type=string_pattern_mismatch, input_value='_custom_Field', input_type=str].*",
94 | ):
95 | EntryInfoResource(**test_bad_info)
96 |
--------------------------------------------------------------------------------
/tests/server/routers/test_utils.py:
--------------------------------------------------------------------------------
1 | """Tests specifically for optimade.servers.routers.utils."""
2 |
3 | from collections.abc import Mapping
4 | from unittest import mock
5 |
6 | import pytest
7 | from requests.exceptions import ConnectionError
8 |
9 |
10 | def mocked_providers_list_response(
11 | url: str | bytes = "",
12 | param: Mapping[str, str] | tuple[str, str] | None = None,
13 | **kwargs,
14 | ):
15 | """This function will be used to mock requests.get
16 |
17 | It will _always_ return a successful response, returning the submodule's provider.json.
18 |
19 | NOTE: This function is loosely inspired by the stackoverflow response here:
20 | https://stackoverflow.com/questions/15753390/how-can-i-mock-requests-and-the-response
21 | """
22 | try:
23 | from optimade.server.data import providers
24 | except ImportError:
25 | pytest.fail(
26 | "Cannot import providers from optimade.server.data, "
27 | "please initialize the `providers` submodule!"
28 | )
29 |
30 | class MockResponse:
31 | def __init__(self, data: list | dict, status_code: int):
32 | self.data = data
33 | self.status_code = status_code
34 |
35 | def json(self) -> list | dict:
36 | return self.data
37 |
38 | def content(self) -> str:
39 | return str(self.data)
40 |
41 | return MockResponse(providers, 200)
42 |
43 |
44 | def test_get_providers():
45 | """Make sure valid responses are handled as expected."""
46 | try:
47 | from optimade.server.data import providers
48 | except ImportError:
49 | pytest.fail(
50 | "Cannot import providers from optimade.server.data, "
51 | "please initialize the `providers` submodule!"
52 | )
53 |
54 | from optimade.server.routers.utils import get_providers, mongo_id_for_database
55 |
56 | side_effects = [
57 | mocked_providers_list_response,
58 | ConnectionError,
59 | ]
60 |
61 | for side_effect in side_effects:
62 | with mock.patch("requests.get", side_effect=side_effect):
63 | providers_list = [
64 | _ for _ in providers.get("data", []) if _["id"] != "exmpl"
65 | ]
66 | for provider in providers_list:
67 | provider.update(
68 | {
69 | "_id": {
70 | "$oid": mongo_id_for_database(
71 | provider["id"], provider["type"]
72 | )
73 | }
74 | }
75 | )
76 | assert get_providers() == providers_list
77 |
78 |
79 | def test_get_all_databases():
80 | from optimade.utils import get_all_databases
81 |
82 | assert list(get_all_databases())
83 |
84 |
85 | def test_get_providers_warning(caplog, top_dir):
86 | """Make sure a warning is logged as a last resort."""
87 | import copy
88 |
89 | from optimade.server.routers.utils import PROVIDER_LIST_URLS, get_providers
90 |
91 | providers_cache = False
92 | try:
93 | from optimade.server import data
94 |
95 | if getattr(data, "providers", None) is not None:
96 | providers_cache = copy.deepcopy(data.providers)
97 |
98 | caplog.clear()
99 | with mock.patch("requests.get", side_effect=ConnectionError):
100 | del data.providers
101 | assert get_providers() == []
102 |
103 | warning_message = """Could not retrieve a list of providers!
104 |
105 | Tried the following resources:
106 |
107 | {}
108 | The list of providers will not be included in the `/links`-endpoint.
109 | """.format("".join([f" * {_}\n" for _ in PROVIDER_LIST_URLS]))
110 | assert warning_message in caplog.messages
111 |
112 | finally:
113 | if providers_cache:
114 | from optimade.server import data
115 |
116 | data.providers = providers_cache
117 |
118 | # Trying to import providers to make sure it's there again now
119 | from optimade.server.data import providers
120 |
121 | assert providers == providers_cache
122 |
--------------------------------------------------------------------------------