├── tests ├── __init__.py ├── models │ ├── __init__.py │ ├── test_types.py │ ├── test_references.py │ ├── test_data │ │ └── test_good_references.json │ ├── test_entries.py │ ├── test_links.py │ ├── test_jsonapi.py │ ├── test_optimade_json.py │ └── test_baseinfo.py ├── server │ ├── __init__.py │ ├── routers │ │ ├── __init__.py │ │ ├── test_links.py │ │ ├── test_versions.py │ │ ├── test_references.py │ │ └── test_utils.py │ ├── query_params │ │ ├── __init__.py │ │ ├── test_response_fields.py │ │ ├── test_include.py │ │ └── conftest.py │ ├── middleware │ │ ├── test_cors.py │ │ ├── test_versioned_url.py │ │ └── test_warnings.py │ ├── entry_collections │ │ ├── test_entry_collections.py │ │ └── test_indexes.py │ ├── test_schemas.py │ ├── test_subapp_mounts.py │ └── test_mappers.py ├── adapters │ ├── references │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── raw_test_references.json │ │ └── test_references.py │ ├── structures │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── test_cif.py │ │ ├── test_pdb.py │ │ ├── test_pdbx_mmcif.py │ │ ├── test_jarvis.py │ │ ├── test_pymatgen.py │ │ ├── conftest.py │ │ ├── test_ase.py │ │ └── test_aiida.py │ └── conftest.py ├── conftest.py ├── test_setup.py ├── filtertransformers │ ├── test_base.py │ └── test_elasticsearch.py └── test_config.json ├── docs ├── images ├── INSTALL.md ├── index.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── api_reference │ ├── .pages │ ├── client │ │ ├── .pages │ │ ├── cli.md │ │ ├── utils.md │ │ └── client.md │ ├── models │ │ ├── .pages │ │ ├── links.md │ │ ├── types.md │ │ ├── utils.md │ │ ├── entries.md │ │ ├── jsonapi.md │ │ ├── baseinfo.md │ │ ├── responses.md │ │ ├── references.md │ │ ├── structures.md │ │ ├── index_metadb.md │ │ └── optimade_json.md │ ├── server │ │ ├── .pages │ │ ├── mappers │ │ │ ├── .pages │ │ │ ├── links.md │ │ │ ├── entries.md │ │ │ ├── references.md │ │ │ └── structures.md │ │ ├── routers │ │ │ ├── .pages │ │ │ ├── info.md │ │ │ ├── links.md │ │ │ ├── utils.md │ │ │ ├── landing.md │ │ │ ├── versions.md │ │ │ ├── index_info.md │ │ │ ├── references.md │ │ │ └── structures.md │ │ ├── main.md │ │ ├── entry_collections │ │ │ ├── .pages │ │ │ ├── mongo.md │ │ │ ├── elasticsearch.md │ │ │ └── entry_collections.md │ │ ├── logger.md │ │ ├── schemas.md │ │ ├── warnings.md │ │ ├── create_app.md │ │ ├── exceptions.md │ │ ├── main_index.md │ │ ├── middleware.md │ │ ├── query_params.md │ │ ├── exception_handlers.md │ │ └── config.md │ ├── adapters │ │ ├── .pages │ │ ├── references │ │ │ ├── .pages │ │ │ └── adapter.md │ │ ├── structures │ │ │ ├── .pages │ │ │ ├── ase.md │ │ │ ├── cif.md │ │ │ ├── aiida.md │ │ │ ├── utils.md │ │ │ ├── adapter.md │ │ │ ├── jarvis.md │ │ │ ├── pymatgen.md │ │ │ └── proteindatabank.md │ │ ├── base.md │ │ ├── logger.md │ │ ├── warnings.md │ │ └── exceptions.md │ ├── validator │ │ ├── .pages │ │ ├── utils.md │ │ ├── config.md │ │ └── validator.md │ ├── filterparser │ │ ├── .pages │ │ └── lark_parser.md │ ├── utils.md │ ├── filtertransformers │ │ ├── .pages │ │ ├── mongo.md │ │ ├── elasticsearch.md │ │ └── base_transformer.md │ ├── warnings.md │ └── exceptions.md ├── openapi │ ├── openapi.json │ └── index_openapi.json ├── static │ ├── optimade_config.json │ └── default_config.json ├── concepts │ ├── .pages │ └── validation.md ├── deployment │ ├── .pages │ ├── multiple_apps.md │ └── integrated.md ├── getting_started │ ├── .pages │ └── use_cases.md ├── .pages ├── overrides │ └── main.html ├── css │ └── reference.css ├── all_models.md └── configuration.md ├── optimade ├── grammar │ ├── __init__.py │ ├── v1.0.1.lark │ ├── v1.1.0.lark │ ├── v1.0.0.lark │ └── v1.2.0.lark ├── server │ ├── __init__.py │ ├── routers │ │ ├── __init__.py │ │ ├── versions.py │ │ ├── links.py │ │ ├── references.py │ │ ├── structures.py │ │ ├── index_info.py │ │ ├── static │ │ │ └── landing_page.html │ │ └── landing.py │ ├── data │ │ ├── providers.json │ │ ├── test_links.json │ │ ├── __init__.py │ │ └── test_references.json │ ├── entry_collections │ │ ├── __init__.py │ │ └── elastic_indexes.json │ ├── mappers │ │ ├── references.py │ │ ├── __init__.py │ │ ├── structures.py │ │ └── links.py │ ├── main.py │ ├── main_index.py │ ├── index_links.json │ ├── exceptions.py │ ├── warnings.py │ └── logger.py ├── __init__.py ├── adapters │ ├── references │ │ ├── __init__.py │ │ └── adapter.py │ ├── structures │ │ ├── __init__.py │ │ ├── jarvis.py │ │ └── adapter.py │ ├── exceptions.py │ ├── logger.py │ ├── __init__.py │ └── warnings.py ├── filterparser │ ├── __init__.py │ └── lark_parser.py ├── filtertransformers │ └── __init__.py ├── client │ └── __init__.py ├── models │ ├── __init__.py │ ├── index_metadb.py │ └── types.py ├── warnings.py └── exceptions.py ├── runtime.txt ├── .github ├── CODEOWNERS ├── utils │ ├── release_tag_msg.txt │ └── update_docs.sh ├── aiida │ ├── profile.yaml │ └── setup_aiida.sh ├── fly.toml └── dependabot.yml ├── requirements-http-client.txt ├── images ├── favicon.png └── exampletree.png ├── .gitmodules ├── Procfile ├── requirements.txt ├── requirements-server.txt ├── requirements-client.txt ├── requirements-docs.txt ├── requirements-dev.txt ├── .codecov.yml ├── .gitignore ├── LICENSE ├── Dockerfile ├── .docker ├── run.sh └── docker_config.json ├── run.sh ├── CONTRIBUTING.md ├── optimade_config.json ├── .pre-commit-config.yaml ├── optimade-version.json └── mkdocs.yml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/images: -------------------------------------------------------------------------------- 1 | ../images -------------------------------------------------------------------------------- /docs/INSTALL.md: -------------------------------------------------------------------------------- 1 | ../INSTALL.md -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /optimade/grammar/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /optimade/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.10.5 2 | -------------------------------------------------------------------------------- /tests/server/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ../CONTRIBUTING.md -------------------------------------------------------------------------------- /optimade/server/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/adapters/references/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/adapters/structures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/query_params/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @CasperWA @ml-evs 2 | -------------------------------------------------------------------------------- /optimade/grammar/v1.0.1.lark: -------------------------------------------------------------------------------- 1 | v1.0.0.lark -------------------------------------------------------------------------------- /optimade/grammar/v1.1.0.lark: -------------------------------------------------------------------------------- 1 | v1.0.0.lark -------------------------------------------------------------------------------- /docs/api_reference/.pages: -------------------------------------------------------------------------------- 1 | title: "API Reference" 2 | -------------------------------------------------------------------------------- /docs/api_reference/client/.pages: -------------------------------------------------------------------------------- 1 | title: "client" 2 | -------------------------------------------------------------------------------- /docs/api_reference/models/.pages: -------------------------------------------------------------------------------- 1 | title: "models" 2 | -------------------------------------------------------------------------------- /docs/api_reference/server/.pages: -------------------------------------------------------------------------------- 1 | title: "server" 2 | -------------------------------------------------------------------------------- /docs/openapi/openapi.json: -------------------------------------------------------------------------------- 1 | ../../openapi/openapi.json -------------------------------------------------------------------------------- /docs/api_reference/adapters/.pages: -------------------------------------------------------------------------------- 1 | title: "adapters" 2 | -------------------------------------------------------------------------------- /docs/api_reference/validator/.pages: -------------------------------------------------------------------------------- 1 | title: "validator" 2 | -------------------------------------------------------------------------------- /docs/static/optimade_config.json: -------------------------------------------------------------------------------- 1 | ../../optimade_config.json -------------------------------------------------------------------------------- /docs/api_reference/server/mappers/.pages: -------------------------------------------------------------------------------- 1 | title: "mappers" 2 | -------------------------------------------------------------------------------- /docs/api_reference/server/routers/.pages: -------------------------------------------------------------------------------- 1 | title: "routers" 2 | -------------------------------------------------------------------------------- /docs/openapi/index_openapi.json: -------------------------------------------------------------------------------- 1 | ../../openapi/index_openapi.json -------------------------------------------------------------------------------- /docs/api_reference/filterparser/.pages: -------------------------------------------------------------------------------- 1 | title: "filterparser" 2 | -------------------------------------------------------------------------------- /docs/api_reference/utils.md: -------------------------------------------------------------------------------- 1 | # utils 2 | 3 | ::: optimade.utils 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/references/.pages: -------------------------------------------------------------------------------- 1 | title: "references" 2 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/structures/.pages: -------------------------------------------------------------------------------- 1 | title: "structures" 2 | -------------------------------------------------------------------------------- /docs/api_reference/client/cli.md: -------------------------------------------------------------------------------- 1 | # cli 2 | 3 | ::: optimade.client.cli 4 | -------------------------------------------------------------------------------- /docs/api_reference/filtertransformers/.pages: -------------------------------------------------------------------------------- 1 | title: "filtertransformers" 2 | -------------------------------------------------------------------------------- /docs/api_reference/server/main.md: -------------------------------------------------------------------------------- 1 | # main 2 | 3 | ::: optimade.server.main 4 | -------------------------------------------------------------------------------- /docs/api_reference/warnings.md: -------------------------------------------------------------------------------- 1 | # warnings 2 | 3 | ::: optimade.warnings 4 | -------------------------------------------------------------------------------- /optimade/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.3.1" 2 | __api_version__ = "1.2.0" 3 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/base.md: -------------------------------------------------------------------------------- 1 | # base 2 | 3 | ::: optimade.adapters.base 4 | -------------------------------------------------------------------------------- /docs/api_reference/client/utils.md: -------------------------------------------------------------------------------- 1 | # utils 2 | 3 | ::: optimade.client.utils 4 | -------------------------------------------------------------------------------- /docs/api_reference/exceptions.md: -------------------------------------------------------------------------------- 1 | # exceptions 2 | 3 | ::: optimade.exceptions 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/entry_collections/.pages: -------------------------------------------------------------------------------- 1 | title: "entry_collections" 2 | -------------------------------------------------------------------------------- /optimade/server/data/providers.json: -------------------------------------------------------------------------------- 1 | ../../../providers/src/links/v1/providers.json -------------------------------------------------------------------------------- /requirements-http-client.txt: -------------------------------------------------------------------------------- 1 | click==8.1.8 2 | httpx==0.28.1 3 | rich==14.2.0 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/logger.md: -------------------------------------------------------------------------------- 1 | # logger 2 | 3 | ::: optimade.adapters.logger 4 | -------------------------------------------------------------------------------- /docs/api_reference/client/client.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | ::: optimade.client.client 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/logger.md: -------------------------------------------------------------------------------- 1 | # logger 2 | 3 | ::: optimade.server.logger 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/schemas.md: -------------------------------------------------------------------------------- 1 | # schemas 2 | 3 | ::: optimade.server.schemas 4 | -------------------------------------------------------------------------------- /docs/api_reference/validator/utils.md: -------------------------------------------------------------------------------- 1 | # utils 2 | 3 | ::: optimade.validator.utils 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/warnings.md: -------------------------------------------------------------------------------- 1 | # warnings 2 | 3 | ::: optimade.server.warnings 4 | -------------------------------------------------------------------------------- /docs/api_reference/validator/config.md: -------------------------------------------------------------------------------- 1 | # config 2 | 3 | ::: optimade.validator.config 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/warnings.md: -------------------------------------------------------------------------------- 1 | # warnings 2 | 3 | ::: optimade.adapters.warnings 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/create_app.md: -------------------------------------------------------------------------------- 1 | # create_app 2 | 3 | ::: optimade.server.create_app 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/exceptions.md: -------------------------------------------------------------------------------- 1 | # exceptions 2 | 3 | ::: optimade.server.exceptions 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/main_index.md: -------------------------------------------------------------------------------- 1 | # main_index 2 | 3 | ::: optimade.server.main_index 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/mappers/links.md: -------------------------------------------------------------------------------- 1 | # links 2 | 3 | ::: optimade.server.mappers.links 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/middleware.md: -------------------------------------------------------------------------------- 1 | # middleware 2 | 3 | ::: optimade.server.middleware 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/routers/info.md: -------------------------------------------------------------------------------- 1 | # info 2 | 3 | ::: optimade.server.routers.info 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/routers/links.md: -------------------------------------------------------------------------------- 1 | # links 2 | 3 | ::: optimade.server.routers.links 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/routers/utils.md: -------------------------------------------------------------------------------- 1 | # utils 2 | 3 | ::: optimade.server.routers.utils 4 | -------------------------------------------------------------------------------- /docs/concepts/.pages: -------------------------------------------------------------------------------- 1 | title: "Concepts" 2 | nav: 3 | - validation.md 4 | - filtering.md 5 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/exceptions.md: -------------------------------------------------------------------------------- 1 | # exceptions 2 | 3 | ::: optimade.adapters.exceptions 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/structures/ase.md: -------------------------------------------------------------------------------- 1 | # ase 2 | 3 | ::: optimade.adapters.structures.ase 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/structures/cif.md: -------------------------------------------------------------------------------- 1 | # cif 2 | 3 | ::: optimade.adapters.structures.cif 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/mappers/entries.md: -------------------------------------------------------------------------------- 1 | # entries 2 | 3 | ::: optimade.server.mappers.entries 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/query_params.md: -------------------------------------------------------------------------------- 1 | # query_params 2 | 3 | ::: optimade.server.query_params 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/routers/landing.md: -------------------------------------------------------------------------------- 1 | # landing 2 | 3 | ::: optimade.server.routers.landing 4 | -------------------------------------------------------------------------------- /docs/api_reference/validator/validator.md: -------------------------------------------------------------------------------- 1 | # validator 2 | 3 | ::: optimade.validator.validator 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/structures/aiida.md: -------------------------------------------------------------------------------- 1 | # aiida 2 | 3 | ::: optimade.adapters.structures.aiida 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/structures/utils.md: -------------------------------------------------------------------------------- 1 | # utils 2 | 3 | ::: optimade.adapters.structures.utils 4 | -------------------------------------------------------------------------------- /docs/api_reference/filtertransformers/mongo.md: -------------------------------------------------------------------------------- 1 | # mongo 2 | 3 | ::: optimade.filtertransformers.mongo 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/routers/versions.md: -------------------------------------------------------------------------------- 1 | # versions 2 | 3 | ::: optimade.server.routers.versions 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/references/adapter.md: -------------------------------------------------------------------------------- 1 | # adapter 2 | 3 | ::: optimade.adapters.references.adapter 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/structures/adapter.md: -------------------------------------------------------------------------------- 1 | # adapter 2 | 3 | ::: optimade.adapters.structures.adapter 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/structures/jarvis.md: -------------------------------------------------------------------------------- 1 | # jarvis 2 | 3 | ::: optimade.adapters.structures.jarvis 4 | -------------------------------------------------------------------------------- /docs/api_reference/filterparser/lark_parser.md: -------------------------------------------------------------------------------- 1 | # lark_parser 2 | 3 | ::: optimade.filterparser.lark_parser 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/mappers/references.md: -------------------------------------------------------------------------------- 1 | # references 2 | 3 | ::: optimade.server.mappers.references 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/mappers/structures.md: -------------------------------------------------------------------------------- 1 | # structures 2 | 3 | ::: optimade.server.mappers.structures 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/routers/index_info.md: -------------------------------------------------------------------------------- 1 | # index_info 2 | 3 | ::: optimade.server.routers.index_info 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/routers/references.md: -------------------------------------------------------------------------------- 1 | # references 2 | 3 | ::: optimade.server.routers.references 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/routers/structures.md: -------------------------------------------------------------------------------- 1 | # structures 2 | 3 | ::: optimade.server.routers.structures 4 | -------------------------------------------------------------------------------- /images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Materials-Consortia/optimade-python-tools/main/images/favicon.png -------------------------------------------------------------------------------- /optimade/adapters/references/__init__.py: -------------------------------------------------------------------------------- 1 | from .adapter import Reference 2 | 3 | __all__ = ("Reference",) 4 | -------------------------------------------------------------------------------- /optimade/adapters/structures/__init__.py: -------------------------------------------------------------------------------- 1 | from .adapter import Structure 2 | 3 | __all__ = ("Structure",) 4 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/structures/pymatgen.md: -------------------------------------------------------------------------------- 1 | # pymatgen 2 | 3 | ::: optimade.adapters.structures.pymatgen 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/entry_collections/mongo.md: -------------------------------------------------------------------------------- 1 | # mongo 2 | 3 | ::: optimade.server.entry_collections.mongo 4 | -------------------------------------------------------------------------------- /docs/api_reference/server/exception_handlers.md: -------------------------------------------------------------------------------- 1 | # exception_handlers 2 | 3 | ::: optimade.server.exception_handlers 4 | -------------------------------------------------------------------------------- /images/exampletree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Materials-Consortia/optimade-python-tools/main/images/exampletree.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "providers"] 2 | path = providers 3 | url = https://github.com/Materials-Consortia/providers.git 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: OPTIMADE_CONFIG_FILE=./tests/test_config.json uvicorn optimade.server.main:app --host 0.0.0.0 --port $PORT 2 | -------------------------------------------------------------------------------- /docs/api_reference/filtertransformers/elasticsearch.md: -------------------------------------------------------------------------------- 1 | # elasticsearch 2 | 3 | ::: optimade.filtertransformers.elasticsearch 4 | -------------------------------------------------------------------------------- /docs/deployment/.pages: -------------------------------------------------------------------------------- 1 | title: "Deployment" 2 | nav: 3 | - integrated.md 4 | - multiple_apps.md 5 | - container.md 6 | -------------------------------------------------------------------------------- /docs/api_reference/adapters/structures/proteindatabank.md: -------------------------------------------------------------------------------- 1 | # proteindatabank 2 | 3 | ::: optimade.adapters.structures.proteindatabank 4 | -------------------------------------------------------------------------------- /docs/api_reference/filtertransformers/base_transformer.md: -------------------------------------------------------------------------------- 1 | # base_transformer 2 | 3 | ::: optimade.filtertransformers.base_transformer 4 | -------------------------------------------------------------------------------- /docs/api_reference/models/links.md: -------------------------------------------------------------------------------- 1 | # links 2 | 3 | ::: optimade.models.links 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/models/types.md: -------------------------------------------------------------------------------- 1 | # types 2 | 3 | ::: optimade.models.types 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/models/utils.md: -------------------------------------------------------------------------------- 1 | # utils 2 | 3 | ::: optimade.models.utils 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/server/config.md: -------------------------------------------------------------------------------- 1 | # config 2 | 3 | ::: optimade.server.config 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /optimade/filterparser/__init__.py: -------------------------------------------------------------------------------- 1 | from .lark_parser import LarkParser, ParserError 2 | 3 | __all__ = ("LarkParser", "ParserError") 4 | -------------------------------------------------------------------------------- /docs/api_reference/models/entries.md: -------------------------------------------------------------------------------- 1 | # entries 2 | 3 | ::: optimade.models.entries 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/models/jsonapi.md: -------------------------------------------------------------------------------- 1 | # jsonapi 2 | 3 | ::: optimade.models.jsonapi 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/server/entry_collections/elasticsearch.md: -------------------------------------------------------------------------------- 1 | # elasticsearch 2 | 3 | ::: optimade.server.entry_collections.elasticsearch 4 | -------------------------------------------------------------------------------- /docs/getting_started/.pages: -------------------------------------------------------------------------------- 1 | title: "Getting started" 2 | nav: 3 | - client.md 4 | - setting_up_an_api.md 5 | - use_cases.md 6 | -------------------------------------------------------------------------------- /docs/api_reference/models/baseinfo.md: -------------------------------------------------------------------------------- 1 | # baseinfo 2 | 3 | ::: optimade.models.baseinfo 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/models/responses.md: -------------------------------------------------------------------------------- 1 | # responses 2 | 3 | ::: optimade.models.responses 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/models/references.md: -------------------------------------------------------------------------------- 1 | # references 2 | 3 | ::: optimade.models.references 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/models/structures.md: -------------------------------------------------------------------------------- 1 | # structures 2 | 3 | ::: optimade.models.structures 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/server/entry_collections/entry_collections.md: -------------------------------------------------------------------------------- 1 | # entry_collections 2 | 3 | ::: optimade.server.entry_collections.entry_collections 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | lark==1.3.1 2 | pydantic[email]==2.12.3 3 | pydantic_settings==2.11.0 4 | pyyaml==6.0.3 5 | requests==2.32.5 6 | uvicorn==0.38.0 7 | -------------------------------------------------------------------------------- /docs/api_reference/models/index_metadb.md: -------------------------------------------------------------------------------- 1 | # index_metadb 2 | 3 | ::: optimade.models.index_metadb 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/api_reference/models/optimade_json.md: -------------------------------------------------------------------------------- 1 | # optimade_json 2 | 3 | ::: optimade.models.optimade_json 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /optimade/adapters/exceptions.py: -------------------------------------------------------------------------------- 1 | __all__ = ("ConversionError",) 2 | 3 | 4 | class ConversionError(Exception): 5 | """Could not convert entry to format""" 6 | -------------------------------------------------------------------------------- /optimade/adapters/logger.py: -------------------------------------------------------------------------------- 1 | """Logger for optimade.adapters""" 2 | 3 | import logging 4 | 5 | LOGGER = logging.getLogger("optimade").getChild("adapters") 6 | -------------------------------------------------------------------------------- /requirements-server.txt: -------------------------------------------------------------------------------- 1 | elasticsearch==7.17.12 2 | elasticsearch-dsl==7.4.1 3 | fastapi==0.120.1 4 | mongomock==4.3.0 5 | pymongo==4.15.3 6 | starlette==0.49.1 7 | -------------------------------------------------------------------------------- /requirements-client.txt: -------------------------------------------------------------------------------- 1 | aiida-core==2.7.1 2 | ase==3.26.0 3 | jarvis-tools==2025.5.30; python_version < '3.13' 4 | numpy>=1.20 5 | pymatgen==2025.10.7; python_version < '3.13' 6 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | griffe==1.14.0 2 | mike==2.1.3 3 | mkdocs==1.6.1 4 | mkdocs-autorefs==1.4.3 5 | mkdocs-awesome-pages-plugin==2.10.1 6 | mkdocs-material==9.6.22 7 | mkdocstrings[python]==0.30.1 8 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | build==1.3.0 2 | invoke==2.2.1 3 | jsondiff==2.2.1 4 | mypy==1.18.2 5 | pre-commit==4.3.0 6 | pytest==8.4.2 7 | pytest-asyncio==1.2.0 8 | pytest-cov==7.0.0 9 | ruff==0.14.0 10 | types-pyyaml 11 | types-requests 12 | -------------------------------------------------------------------------------- /tests/server/routers/test_links.py: -------------------------------------------------------------------------------- 1 | from optimade.models import LinksResponse 2 | 3 | from ..utils import EndpointTests 4 | 5 | 6 | class TestLinksEndpoint(EndpointTests): 7 | request_str = "/links" 8 | response_cls = LinksResponse 9 | -------------------------------------------------------------------------------- /docs/.pages: -------------------------------------------------------------------------------- 1 | nav: 2 | - index.md 3 | - INSTALL.md 4 | - configuration.md 5 | - getting_started 6 | - deployment 7 | - concepts 8 | - CONTRIBUTING.md 9 | - CHANGELOG.md 10 | - all_models.md 11 | - api_reference 12 | -------------------------------------------------------------------------------- /docs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block outdated %} 4 | You are viewing an old or unreleased version. 5 | 6 | Click here to go to latest release. 7 | 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /optimade/server/entry_collections/__init__.py: -------------------------------------------------------------------------------- 1 | from .entry_collections import ( 2 | EntryCollection, 3 | PaginationMechanism, 4 | create_entry_collections, 5 | ) 6 | 7 | __all__ = ("EntryCollection", "create_entry_collections", "PaginationMechanism") 8 | -------------------------------------------------------------------------------- /optimade/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | from .exceptions import * # noqa: F403 2 | from .references import * # noqa: F403 3 | from .structures import * # noqa: F403 4 | 5 | __all__ = exceptions.__all__ + references.__all__ + structures.__all__ # type: ignore[name-defined] # noqa: F405 6 | -------------------------------------------------------------------------------- /.github/utils/release_tag_msg.txt: -------------------------------------------------------------------------------- 1 | TAG_NAME 2 | 3 | The full release changelog can be seen in the [online docs](https://www.optimade.org/optimade-python-tools/CHANGELOG/) and in the [repository source file](https://github.com/Materials-Consortia/optimade-python-tools/blob/TAG_NAME/CHANGELOG.md). 4 | -------------------------------------------------------------------------------- /optimade/server/mappers/references.py: -------------------------------------------------------------------------------- 1 | from optimade.models.references import ReferenceResource 2 | from optimade.server.mappers.entries import BaseResourceMapper 3 | 4 | __all__ = ("ReferenceMapper",) 5 | 6 | 7 | class ReferenceMapper(BaseResourceMapper): 8 | ENTRY_RESOURCE_CLASS = ReferenceResource 9 | -------------------------------------------------------------------------------- /docs/css/reference.css: -------------------------------------------------------------------------------- 1 | div.doc-contents:not(.first) { 2 | padding-left: 25px; 3 | border-left: 4px solid rgba(230, 230, 230, 0.5); 4 | margin-bottom: 80px; 5 | } 6 | 7 | td code { 8 | word-break: normal !important; 9 | } 10 | 11 | code { 12 | max-height: 20em; 13 | } 14 | 15 | .md-grid { 16 | max-width: 70rem; 17 | } 18 | -------------------------------------------------------------------------------- /optimade/server/main.py: -------------------------------------------------------------------------------- 1 | """The OPTIMADE server 2 | 3 | This is an example implementation with example data. 4 | To implement your own server see the documentation at https://optimade.org/optimade-python-tools. 5 | """ 6 | 7 | from optimade.server.config import ServerConfig 8 | from optimade.server.create_app import create_app 9 | 10 | app = create_app(ServerConfig()) 11 | -------------------------------------------------------------------------------- /optimade/server/data/test_links.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": { 4 | "$oid": "696e646578706172656e7430" 5 | }, 6 | "id": "index", 7 | "type": "links", 8 | "name": "Index meta-database", 9 | "description": "Index for example's OPTIMADE databases", 10 | "base_url": "http://localhost:5001", 11 | "homepage": "https://example.com", 12 | "link_type": "root" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /optimade/filtertransformers/__init__.py: -------------------------------------------------------------------------------- 1 | """This module implements filter transformer classes for different backends. These 2 | classes typically parse the filter with Lark and produce an appropriate query for the 3 | given backend. 4 | 5 | """ 6 | 7 | from optimade.filtertransformers.base_transformer import BaseTransformer, Quantity 8 | 9 | __all__ = ( 10 | "BaseTransformer", 11 | "Quantity", 12 | ) 13 | -------------------------------------------------------------------------------- /optimade/adapters/warnings.py: -------------------------------------------------------------------------------- 1 | from optimade.warnings import OptimadeWarning 2 | 3 | __all__ = ("AdapterPackageNotFound", "ConversionWarning") 4 | 5 | 6 | class AdapterPackageNotFound(OptimadeWarning): 7 | """The package for an adapter cannot be found.""" 8 | 9 | 10 | class ConversionWarning(OptimadeWarning): 11 | """A non-critical error/fallback/choice happened during conversion of an entry to format.""" 12 | -------------------------------------------------------------------------------- /optimade/server/main_index.py: -------------------------------------------------------------------------------- 1 | """The OPTIMADE Index Meta-Database server 2 | 3 | This is an example implementation with example data. 4 | To implement your own index meta-database server see the documentation at https://optimade.org/optimade-python-tools. 5 | """ 6 | 7 | from optimade.server.config import ServerConfig 8 | from optimade.server.create_app import create_app 9 | 10 | app = create_app(ServerConfig(), index=True) 11 | -------------------------------------------------------------------------------- /optimade/client/__init__.py: -------------------------------------------------------------------------------- 1 | """This module implements an OPTIMADE client that can be called 2 | from Python code with 3 | 4 | ```python 5 | from optimade.client import OptimadeClient 6 | client = OptimadeClient() 7 | client.get('elements HAS "Ag") 8 | ``` 9 | 10 | or from the command-line with 11 | ```shell 12 | optimade-get --filter 'elements HAS "Ag"' 13 | ``` 14 | 15 | """ 16 | 17 | from .client import OptimadeClient 18 | 19 | __all__ = ("OptimadeClient",) 20 | -------------------------------------------------------------------------------- /optimade/server/index_links.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "index", 4 | "type": "links", 5 | "name": "OPTIMADE API", 6 | "description": "The [Open Databases Integration for Materials Design (OPTIMADE) consortium](https://www.optimade.org/) aims to make materials databases interoperational by developing a common REST API.", 7 | "base_url": "http://localhost:5000", 8 | "homepage": "https://example.com", 9 | "link_type": "child" 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | wait_for_ci: yes 4 | require_ci_to_pass: yes 5 | 6 | coverage: 7 | precision: 2 8 | round: down 9 | range: "70...100" 10 | 11 | status: 12 | project: 13 | default: off 14 | optimade: 15 | threshold: 1% 16 | flags: 17 | - project 18 | validator: 19 | threshold: 1% 20 | flags: 21 | - validator 22 | patch: 23 | default: 24 | threshold: 5% 25 | changes: off 26 | -------------------------------------------------------------------------------- /optimade/server/mappers/__init__.py: -------------------------------------------------------------------------------- 1 | from .entries import * # noqa: F403 2 | from .links import * # noqa: F403 3 | from .references import * # noqa: F403 4 | from .structures import * # noqa: F403 5 | 6 | __all__ = ( 7 | entries.__all__ # type: ignore[name-defined] # noqa: F405 8 | + links.__all__ # type: ignore[name-defined] # noqa: F405 9 | + references.__all__ # type: ignore[name-defined] # noqa: F405 10 | + structures.__all__ # type: ignore[name-defined] # noqa: F405 11 | ) 12 | -------------------------------------------------------------------------------- /optimade/server/mappers/structures.py: -------------------------------------------------------------------------------- 1 | from optimade.models.structures import StructureResource 2 | from optimade.server.mappers.entries import BaseResourceMapper 3 | 4 | __all__ = ("StructureMapper",) 5 | 6 | 7 | class StructureMapper(BaseResourceMapper): 8 | LENGTH_ALIASES = ( 9 | ("elements", "nelements"), 10 | ("elements_ratios", "nelements"), 11 | ("cartesian_site_positions", "nsites"), 12 | ("species_at_sites", "nsites"), 13 | ) 14 | ENTRY_RESOURCE_CLASS = StructureResource 15 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | 7 | def pytest_configure(config): 8 | """Method that runs before pytest collects tests so no modules are imported""" 9 | cwd = Path(__file__).parent 10 | os.environ["OPTIMADE_CONFIG_FILE"] = str(cwd / "test_config.json") 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def top_dir() -> Path: 15 | """Return Path instance for the repository's top (root) directory""" 16 | return Path(__file__).parent.parent.resolve() 17 | -------------------------------------------------------------------------------- /tests/models/test_types.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Optional 2 | 3 | 4 | def test_origin_type(): 5 | from optimade.models.types import _get_origin_type 6 | 7 | assert _get_origin_type(int | None) is int 8 | assert _get_origin_type(str | None) is str 9 | assert _get_origin_type(Optional[int]) is int 10 | assert _get_origin_type(Optional[str]) is str 11 | assert _get_origin_type(Annotated[int, "test"]) is int 12 | assert _get_origin_type(Annotated[str, "test"]) is str 13 | assert _get_origin_type(int | str | None) is int 14 | -------------------------------------------------------------------------------- /.github/aiida/profile.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | profile: PLACEHOLDER_PROFILE 3 | email: aiida@localhost 4 | first_name: AiiDA 5 | last_name: OPTIMADE 6 | institution: Materials-Consortia 7 | db_backend: PLACEHOLDER_BACKEND 8 | db_engine: postgresql_psycopg2 9 | db_host: localhost 10 | db_port: 5432 11 | db_name: PLACEHOLDER_DATABASE_NAME 12 | db_username: postgres 13 | db_password: test 14 | repository: PLACEHOLDER_REPOSITORY 15 | broker_protocol: amqp 16 | broker_username: guest 17 | broker_password: guest 18 | broker_host: 127.0.0.1 19 | broker_port: 5672 20 | broker_virtual_host: '' 21 | -------------------------------------------------------------------------------- /tests/server/routers/test_versions.py: -------------------------------------------------------------------------------- 1 | from optimade import __api_version__ 2 | 3 | from ..utils import NoJsonEndpointTests 4 | 5 | 6 | class TestVersionsEndpoint(NoJsonEndpointTests): 7 | request_str = "/versions" 8 | response_cls = str 9 | 10 | def test_versions_endpoint(self): 11 | assert ( 12 | self.response.text 13 | == f"version\n{__api_version__.replace('v', '').split('.')[0]}" 14 | ) 15 | assert "text/csv" in self.response.headers.get("content-type") 16 | assert "header=present" in self.response.headers.get("content-type") 17 | -------------------------------------------------------------------------------- /.github/aiida/setup_aiida.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ev 3 | 4 | # Replace the placeholders in configuration files with actual values 5 | CONFIG="${GITHUB_WORKSPACE}/.github/aiida" 6 | sed -i "s|PLACEHOLDER_BACKEND|${AIIDA_TEST_BACKEND}|" "${CONFIG}/profile.yaml" 7 | sed -i "s|PLACEHOLDER_PROFILE|test_${AIIDA_TEST_BACKEND}|" "${CONFIG}/profile.yaml" 8 | sed -i "s|PLACEHOLDER_DATABASE_NAME|test_aiida|" "${CONFIG}/profile.yaml" 9 | sed -i "s|PLACEHOLDER_REPOSITORY|/tmp/test_repository_test_${AIIDA_TEST_BACKEND}/|" "${CONFIG}/profile.yaml" 10 | 11 | verdi setup --config "${CONFIG}/profile.yaml" 12 | 13 | verdi profile setdefault test_${AIIDA_TEST_BACKEND} 14 | -------------------------------------------------------------------------------- /optimade/server/exceptions.py: -------------------------------------------------------------------------------- 1 | """Reproduced imports from `optimade.exceptions` for backwards-compatibility.""" 2 | 3 | from optimade.exceptions import ( 4 | POSSIBLE_ERRORS, 5 | BadRequest, 6 | Forbidden, 7 | InternalServerError, 8 | NotFound, 9 | NotImplementedResponse, 10 | OptimadeHTTPException, 11 | UnprocessableEntity, 12 | VersionNotSupported, 13 | ) 14 | 15 | __all__ = ( 16 | "OptimadeHTTPException", 17 | "BadRequest", 18 | "VersionNotSupported", 19 | "Forbidden", 20 | "NotFound", 21 | "UnprocessableEntity", 22 | "InternalServerError", 23 | "NotImplementedResponse", 24 | "POSSIBLE_ERRORS", 25 | ) 26 | -------------------------------------------------------------------------------- /tests/adapters/structures/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | 5 | def get_min_ver(dependency: str) -> str: 6 | """Retrieve version of `dependency` from pyproject.toml, raise if not found.""" 7 | pyproject_toml = Path(__file__).parent.joinpath("../../../pyproject.toml") 8 | with open(pyproject_toml) as setup_file: 9 | for line in setup_file.readlines(): 10 | min_ver = re.findall(rf'"{dependency}((=|!|<|>|~)=|>|<)(.+)"', line) 11 | if min_ver: 12 | return min_ver[0][2].split(";")[0].split(",")[0].strip('"') 13 | else: 14 | raise RuntimeError(f"Cannot find {dependency} dependency in pyproject.toml") 15 | -------------------------------------------------------------------------------- /optimade/server/routers/versions.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from fastapi.responses import Response 3 | 4 | from optimade.server.routers.utils import BASE_URL_PREFIXES 5 | 6 | router = APIRouter(redirect_slashes=True) 7 | 8 | 9 | class CsvResponse(Response): 10 | media_type = "text/csv; header=present" 11 | 12 | 13 | @router.get( 14 | "/versions", 15 | tags=["Versions"], 16 | response_class=CsvResponse, 17 | ) 18 | def get_versions() -> CsvResponse: 19 | """Respond with the text/csv representation for the served versions.""" 20 | version = BASE_URL_PREFIXES["major"].replace("/v", "") 21 | response = f"version\n{version}" 22 | return CsvResponse(content=response) 23 | -------------------------------------------------------------------------------- /optimade/server/mappers/links.py: -------------------------------------------------------------------------------- 1 | from optimade.models.links import LinksResource 2 | from optimade.server.mappers.entries import BaseResourceMapper 3 | 4 | __all__ = ("LinksMapper",) 5 | 6 | 7 | class LinksMapper(BaseResourceMapper): 8 | ENTRY_RESOURCE_CLASS = LinksResource 9 | 10 | def map_back(self, doc: dict) -> dict: 11 | """Map properties from MongoDB to OPTIMADE 12 | 13 | :param doc: A resource object in MongoDB format 14 | :type doc: dict 15 | 16 | :return: A resource object in OPTIMADE format 17 | :rtype: dict 18 | """ 19 | type_ = doc["type"] 20 | newdoc = super().map_back(doc) 21 | newdoc["type"] = type_ 22 | return newdoc 23 | -------------------------------------------------------------------------------- /optimade/server/data/__init__.py: -------------------------------------------------------------------------------- 1 | """Test Data to be used with the OPTIMADE server""" 2 | 3 | from pathlib import Path 4 | 5 | import bson.json_util 6 | 7 | data_paths = { 8 | "structures": "test_structures.json", 9 | "references": "test_references.json", 10 | "links": "test_links.json", 11 | "providers": "providers.json", 12 | } 13 | 14 | 15 | for var, path in data_paths.items(): 16 | try: 17 | with open(Path(__file__).parent / path) as f: 18 | globals()[var] = bson.json_util.loads(f.read()) 19 | 20 | if var == "structures": 21 | globals()[var] = sorted(globals()[var], key=lambda x: x["task_id"]) 22 | except FileNotFoundError: 23 | if var != "providers": 24 | raise 25 | -------------------------------------------------------------------------------- /optimade/server/warnings.py: -------------------------------------------------------------------------------- 1 | """This submodule maintains backwards compatibility with the old `optimade.server.warnings` module, 2 | which previously implemented the imported warnings directly. 3 | 4 | """ 5 | 6 | from optimade.warnings import ( 7 | FieldValueNotRecognized, 8 | MissingExpectedField, 9 | OptimadeWarning, 10 | QueryParamNotUsed, 11 | TimestampNotRFCCompliant, 12 | TooManyValues, 13 | UnknownProviderProperty, 14 | UnknownProviderQueryParameter, 15 | ) 16 | 17 | __all__ = ( 18 | "FieldValueNotRecognized", 19 | "MissingExpectedField", 20 | "OptimadeWarning", 21 | "QueryParamNotUsed", 22 | "TimestampNotRFCCompliant", 23 | "TooManyValues", 24 | "UnknownProviderProperty", 25 | "UnknownProviderQueryParameter", 26 | ) 27 | -------------------------------------------------------------------------------- /optimade/adapters/references/adapter.py: -------------------------------------------------------------------------------- 1 | from optimade.adapters.base import EntryAdapter 2 | from optimade.models import ReferenceResource 3 | 4 | 5 | class Reference(EntryAdapter): 6 | """ 7 | Lazy reference resource converter. 8 | 9 | Go to [`EntryAdapter`][optimade.adapters.base.EntryAdapter] to see the full list of methods 10 | and properties. 11 | 12 | Attributes: 13 | ENTRY_RESOURCE (ReferenceResource): This adapter stores entry resources as 14 | [`ReferenceResource`][optimade.models.references.ReferenceResource]s. 15 | _type_converters (Dict[str, Callable]): Dictionary of valid conversion types for entry. 16 | 17 | There are currently no available types. 18 | as_<_type_converters>: Convert entry to a type listed in `_type_converters`. 19 | 20 | """ 21 | 22 | ENTRY_RESOURCE: type[ReferenceResource] = ReferenceResource 23 | -------------------------------------------------------------------------------- /tests/server/middleware/test_cors.py: -------------------------------------------------------------------------------- 1 | """Test CORS middleware""" 2 | 3 | 4 | def test_regular_CORS_request(both_clients): 5 | response = both_clients.get("/info", headers={"Origin": "http://example.org"}) 6 | assert ("access-control-allow-origin", "*") in tuple(response.headers.items()), ( 7 | f"Access-Control-Allow-Origin header not found in response headers: {response.headers}" 8 | ) 9 | 10 | 11 | def test_preflight_CORS_request(both_clients): 12 | headers = { 13 | "Origin": "http://example.org", 14 | "Access-Control-Request-Method": "GET", 15 | } 16 | response = both_clients.options("/info", headers=headers) 17 | for response_header in ( 18 | "Access-Control-Allow-Origin", 19 | "Access-Control-Allow-Methods", 20 | ): 21 | assert response_header.lower() in list(response.headers.keys()), ( 22 | f"{response_header} header not found in response headers: {response.headers}" 23 | ) 24 | -------------------------------------------------------------------------------- /tests/adapters/conftest.py: -------------------------------------------------------------------------------- 1 | from json import JSONDecodeError 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | import requests 6 | 7 | 8 | @pytest.fixture 9 | def mock_requests_get(monkeypatch): 10 | """Patch requests.get to return the desired mock response.""" 11 | 12 | def _mock_requests_get(json_data, status_code=200): 13 | mock_response = Mock() 14 | if not isinstance(json_data, dict): 15 | 16 | def mock_raise(): 17 | raise JSONDecodeError( 18 | msg="Unable to interpret response as JSON", doc="", pos=0 19 | ) 20 | 21 | mock_response.json = mock_raise 22 | else: 23 | mock_response.json.return_value = json_data 24 | 25 | mock_response.status_code = status_code 26 | 27 | def mock_get(*args, **kwargs): 28 | return mock_response 29 | 30 | monkeypatch.setattr(requests, "get", mock_get) 31 | 32 | return _mock_requests_get 33 | -------------------------------------------------------------------------------- /optimade/server/routers/links.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Any 2 | 3 | from fastapi import APIRouter, Depends, Request 4 | 5 | from optimade.models import LinksResponse 6 | from optimade.server.config import ServerConfig 7 | from optimade.server.query_params import EntryListingQueryParams 8 | from optimade.server.routers.utils import get_entries 9 | from optimade.server.schemas import ERROR_RESPONSES 10 | 11 | router = APIRouter(redirect_slashes=True) 12 | 13 | CONFIG = ServerConfig() 14 | 15 | 16 | @router.get( 17 | "/links", 18 | response_model=LinksResponse if CONFIG.validate_api_response else dict, 19 | response_model_exclude_unset=True, 20 | tags=["Links"], 21 | responses=ERROR_RESPONSES, 22 | ) 23 | def get_links( 24 | request: Request, params: Annotated[EntryListingQueryParams, Depends()] 25 | ) -> dict[str, Any]: 26 | links_coll = request.app.state.entry_collections.get("links") 27 | 28 | return get_entries(collection=links_coll, request=request, params=params) 29 | -------------------------------------------------------------------------------- /docs/all_models.md: -------------------------------------------------------------------------------- 1 | # OPTIMADE Data Models 2 | 3 | This page provides documentation for the `optimade.models` submodule, where all the OPTIMADE (and JSON:API)-defined data models are located. 4 | 5 | For example, the three OPTIMADE entry types, `structures`, `references` and `links`, are defined primarily through the corresponding attribute models: 6 | 7 | - [`StructureResourceAttributes`](#optimade.models.structures.StructureResourceAttributes) 8 | - [`ReferenceResourceAttributes`](#optimade.models.references.ReferenceResourceAttributes) 9 | - [`LinksResourceAttributes`](#optimade.models.links.LinksResourceAttributes) 10 | 11 | As well as validating data types when creating instances of these models, this package defines several OPTIMADE-specific validators that ensure consistency between fields (e.g., the value of `nsites` matches the number of positions provided in `cartesian_site_positions`). 12 | 13 | ::: optimade.models 14 | options: 15 | show_submodules: true 16 | show_if_no_docstring: true 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # Distribution / packaging 8 | build/ 9 | dist/ 10 | eggs/ 11 | .eggs/ 12 | sdist/ 13 | wheels/ 14 | *.egg-info/ 15 | *.egg 16 | MANIFEST 17 | 18 | # Installer logs 19 | pip-log.txt 20 | pip-delete-this-directory.txt 21 | 22 | # Unit test / coverage reports 23 | htmlcov/ 24 | .coverage 25 | .coverage.* 26 | .cache 27 | coverage.xml 28 | 29 | # Django stuff: 30 | *.log 31 | .static_storage/ 32 | .media/ 33 | local_settings.py 34 | 35 | # pyenv 36 | .python-version 37 | 38 | # Environments 39 | .env 40 | .venv 41 | env/ 42 | venv/ 43 | ENV/ 44 | env.bak/ 45 | venv.bak/ 46 | 47 | # Spyder project settings 48 | .spyderproject 49 | .spyproject 50 | 51 | # VSCode project settings 52 | .vscode 53 | 54 | # mkdocs documentation 55 | /site 56 | 57 | # pytest 58 | .pytest_cache/ 59 | 60 | # Mac 61 | .DS_Store 62 | .idea/ 63 | 64 | # Package-specific 65 | local_openapi.json 66 | local_index_openapi.json 67 | logs 68 | -------------------------------------------------------------------------------- /tests/adapters/references/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.references import Reference 9 | 10 | 11 | @pytest.fixture 12 | def RAW_REFERENCES() -> "list[dict[str, Any]]": 13 | """Read and return raw_references.json""" 14 | import json 15 | from pathlib import Path 16 | 17 | return json.loads( 18 | Path(__file__).parent.joinpath("raw_test_references.json").read_bytes() 19 | ) 20 | 21 | 22 | @pytest.fixture 23 | def raw_reference(RAW_REFERENCES: "list[dict[str, Any]]") -> "dict[str, Any]": 24 | """Return random raw reference from raw_references.json""" 25 | from random import choice 26 | 27 | return choice(RAW_REFERENCES) 28 | 29 | 30 | @pytest.fixture 31 | def reference(raw_reference: "dict[str, Any]") -> "Reference": 32 | """Create and return adapters.Reference""" 33 | from optimade.adapters.references import Reference 34 | 35 | return Reference(raw_reference) 36 | -------------------------------------------------------------------------------- /.github/fly.toml: -------------------------------------------------------------------------------- 1 | app = "optimade" 2 | kill_signal = "SIGINT" 3 | kill_timeout = 5 4 | swap_size_mb = 1024 5 | processes = {} 6 | 7 | [build] 8 | builder = "paketobuildpacks/builder:base" 9 | 10 | [env] 11 | PORT = "5000" 12 | BPE_OPTIMADE_CONFIG_FILE = "./tests/test_config.json" 13 | BPL_OPTIMADE_CONFIG_FILE = "./tests/test_config.json" 14 | OPTIMADE_CONFIG_FILE = "./tests/test_config.json" 15 | 16 | [experimental] 17 | allowed_public_ports = [] 18 | auto_rollback = true 19 | 20 | [[services]] 21 | http_checks = [] 22 | internal_port = 5000 23 | processes = ["app"] 24 | protocol = "tcp" 25 | script_checks = [] 26 | [services.concurrency] 27 | hard_limit = 25 28 | soft_limit = 20 29 | type = "connections" 30 | 31 | [[services.ports]] 32 | force_https = true 33 | handlers = ["http"] 34 | port = 80 35 | 36 | [[services.ports]] 37 | handlers = ["tls", "http"] 38 | port = 443 39 | 40 | [[services.tcp_checks]] 41 | grace_period = "1s" 42 | interval = "15s" 43 | restart_limit = 0 44 | timeout = "2s" 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2019 OPTIMADE Development Team 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | # Prevent writing .pyc files on the import of source modules 4 | # and set unbuffered mode to ensure logging outputs 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | WORKDIR /app 9 | 10 | # Copy repo contents 11 | COPY pyproject.toml requirements.txt requirements-server.txt LICENSE README.md .docker/run.sh ./ 12 | COPY optimade ./optimade 13 | COPY providers/src/links/v1/providers.json ./optimade/server/data/ 14 | RUN apt-get purge -y --auto-remove \ 15 | && rm -rf /var/lib/apt/lists/* \ 16 | && pip install --no-cache-dir --trusted-host pypi.org --trusted-host files.pythonhosted.org -U pip setuptools wheel flit \ 17 | && pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org -r requirements.txt -r requirements-server.txt \ 18 | && pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org .[server] 19 | 20 | # Setup server configuration 21 | ARG CONFIG_FILE=optimade_config.json 22 | COPY ${CONFIG_FILE} ./optimade_config.json 23 | ENV OPTIMADE_CONFIG_FILE=/app/optimade_config.json 24 | 25 | # Run app 26 | EXPOSE 5000 27 | ENTRYPOINT [ "/app/run.sh" ] 28 | -------------------------------------------------------------------------------- /optimade/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .baseinfo import * # noqa: F403 2 | from .entries import * # noqa: F403 3 | from .index_metadb import * # noqa: F403 4 | from .jsonapi import * # noqa: F403 5 | from .links import * # noqa: F403 6 | from .optimade_json import * # noqa: F403 7 | from .references import * # noqa: F403 8 | from .responses import * # noqa: F403 9 | from .structures import * # noqa: F403 10 | from .utils import * # noqa: F403 11 | 12 | __all__ = ( 13 | jsonapi.__all__ # type: ignore[name-defined] # noqa: F405 14 | + utils.__all__ # type: ignore[name-defined] # noqa: F405 15 | + baseinfo.__all__ # type: ignore[name-defined] # noqa: F405 16 | + entries.__all__ # type: ignore[name-defined] # noqa: F405 17 | + index_metadb.__all__ # type: ignore[name-defined] # noqa: F405 18 | + links.__all__ # type: ignore[name-defined] # noqa: F405 19 | + optimade_json.__all__ # type: ignore[name-defined] # noqa: F405 20 | + references.__all__ # type: ignore[name-defined] # noqa: F405 21 | + responses.__all__ # type: ignore[name-defined] # noqa: F405 22 | + structures.__all__ # type: ignore[name-defined] # noqa: F405 23 | ) 24 | -------------------------------------------------------------------------------- /tests/adapters/structures/test_cif.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .utils import get_min_ver 4 | 5 | min_ver = get_min_ver("numpy") 6 | numpy = pytest.importorskip( 7 | "numpy", 8 | minversion=min_ver, 9 | reason=f"numpy must be installed with minimum version {min_ver} for these tests to" 10 | " be able to run", 11 | ) 12 | 13 | from optimade.adapters import Structure 14 | from optimade.adapters.structures.cif import get_cif 15 | 16 | 17 | def test_successful_conversion(RAW_STRUCTURES): 18 | """Make sure its possible to convert""" 19 | for structure in RAW_STRUCTURES: 20 | assert isinstance(get_cif(Structure(structure)), str) 21 | 22 | 23 | def test_null_lattice_vectors(null_lattice_vector_structure): 24 | """Make sure null lattice vectors are handled""" 25 | assert isinstance(get_cif(null_lattice_vector_structure), str) 26 | 27 | 28 | def test_special_species(SPECIAL_SPECIES_STRUCTURES): 29 | """Make sure vacancies and non-chemical symbols ("X") are handled""" 30 | for special_structure in SPECIAL_SPECIES_STRUCTURES: 31 | structure = Structure(special_structure) 32 | 33 | assert isinstance(get_cif(structure), str) 34 | -------------------------------------------------------------------------------- /.docker/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Bash script to run upon starting the Dockerfile. 3 | # 4 | # Any extra parameters supplied to this script will be passed on to `uvicorn`. 5 | # This can be nice if using the image for development (adding `--reload` and more). 6 | set -e 7 | 8 | if [ "${OPTIMADE_CONFIG_FILE}" == "/app/optimade_config.json" ]; then 9 | echo "Using the demo config file." 10 | echo "Set the environment variable OPTIMADE_CONFIG_FILE to override this behaviour." 11 | echo "For more configuration options, please see https://www.optimade.org/optimade-python-tools/configuration/." 12 | echo "Note, the variable should point to a bound path within the container." 13 | fi 14 | 15 | if [ -z "${OPTIMADE_LOG_LEVEL}" ]; then 16 | export OPTIMADE_LOG_LEVEL=info 17 | fi 18 | 19 | if [ "${OPTIMADE_DEBUG}" == "1" ]; then 20 | export OPTIMADE_LOG_LEVEL=debug 21 | fi 22 | 23 | # Determine the server to run (standard or index meta-db) 24 | if [ -z "${MAIN}" ] || ( [ "${MAIN}" != "main_index" ] && [ "${MAIN}" != "main" ] ); then 25 | MAIN="main" 26 | fi 27 | 28 | uvicorn optimade.server.${MAIN}:app --host 0.0.0.0 --port 5000 --log-level ${OPTIMADE_LOG_LEVEL} "$@" 29 | -------------------------------------------------------------------------------- /tests/adapters/structures/test_pdb.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .utils import get_min_ver 4 | 5 | min_ver = get_min_ver("numpy") 6 | numpy = pytest.importorskip( 7 | "numpy", 8 | minversion=min_ver, 9 | reason=f"numpy must be installed with minimum version {min_ver} for these tests to" 10 | " be able to run", 11 | ) 12 | 13 | from optimade.adapters import Structure 14 | from optimade.adapters.structures.proteindatabank import get_pdb 15 | 16 | 17 | def test_successful_conversion(RAW_STRUCTURES): 18 | """Make sure its possible to convert""" 19 | for structure in RAW_STRUCTURES: 20 | assert isinstance(get_pdb(Structure(structure)), str) 21 | 22 | 23 | def test_null_lattice_vectors(null_lattice_vector_structure): 24 | """Make sure null lattice vectors are handled""" 25 | assert isinstance(get_pdb(null_lattice_vector_structure), str) 26 | 27 | 28 | def test_special_species(SPECIAL_SPECIES_STRUCTURES): 29 | """Make sure vacancies and non-chemical symbols ("X") are handled""" 30 | for special_structure in SPECIAL_SPECIES_STRUCTURES: 31 | structure = Structure(special_structure) 32 | 33 | assert isinstance(get_pdb(structure), str) 34 | -------------------------------------------------------------------------------- /tests/adapters/structures/test_pdbx_mmcif.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .utils import get_min_ver 4 | 5 | min_ver = get_min_ver("numpy") 6 | numpy = pytest.importorskip( 7 | "numpy", 8 | minversion=min_ver, 9 | reason=f"numpy must be installed with minimum version {min_ver} for these tests to" 10 | " be able to run", 11 | ) 12 | 13 | from optimade.adapters import Structure 14 | from optimade.adapters.structures.proteindatabank import get_pdbx_mmcif 15 | 16 | 17 | def test_successful_conversion(RAW_STRUCTURES): 18 | """Make sure its possible to convert""" 19 | for structure in RAW_STRUCTURES: 20 | assert isinstance(get_pdbx_mmcif(Structure(structure)), str) 21 | 22 | 23 | def test_null_lattice_vectors(null_lattice_vector_structure): 24 | """Make sure null lattice vectors are handled""" 25 | assert isinstance(get_pdbx_mmcif(null_lattice_vector_structure), str) 26 | 27 | 28 | def test_special_species(SPECIAL_SPECIES_STRUCTURES): 29 | """Make sure vacancies and non-chemical symbols ("X") are handled""" 30 | for special_structure in SPECIAL_SPECIES_STRUCTURES: 31 | structure = Structure(special_structure) 32 | 33 | assert isinstance(get_pdbx_mmcif(structure), str) 34 | -------------------------------------------------------------------------------- /.github/utils/update_docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | echo -e "\n-o- Setting commit user -o-" 5 | git config --global user.email "${GIT_USER_EMAIL}" 6 | git config --global user.name "${GIT_USER_NAME}" 7 | 8 | echo -e "\n-o- Update 'API Reference' docs -o-" 9 | invoke create-api-reference-docs --pre-clean 10 | 11 | echo -e "\n-o- Commit update - API Reference -o-" 12 | git add docs/api_reference 13 | if [ -n "$(git status --porcelain docs/api_reference)" ]; then 14 | # Only commit if there's something to commit (git will return non-zero otherwise) 15 | git commit -m "Release ${GITHUB_REF#refs/tags/} - API Reference" 16 | fi 17 | 18 | echo -e "\n-o- Update version -o-" 19 | invoke setver --ver="${GITHUB_REF#refs/tags/}" 20 | 21 | echo -e "\n-o- Commit updates - Version & Changelog -o-" 22 | git add optimade/__init__.py docs/static/default_config.json 23 | git add openapi/index_openapi.json openapi/openapi.json 24 | git add CHANGELOG.md 25 | git commit -m "Release ${GITHUB_REF#refs/tags/} - Changelog" 26 | 27 | echo -e "\n-o- Update version tag -o-" 28 | TAG_MSG=.github/utils/release_tag_msg.txt 29 | sed -i "s|TAG_NAME|${GITHUB_REF#refs/tags/}|" "${TAG_MSG}" 30 | git tag -af -F "${TAG_MSG}" ${GITHUB_REF#refs/tags/} 31 | -------------------------------------------------------------------------------- /tests/server/entry_collections/test_entry_collections.py: -------------------------------------------------------------------------------- 1 | """Test the EntryCollection abstract base class""" 2 | 3 | 4 | def test_get_attribute_fields(): 5 | """Test get_attribute_fields() method""" 6 | from optimade.models import ( 7 | LinksResourceAttributes, 8 | ReferenceResourceAttributes, 9 | StructureResourceAttributes, 10 | ) 11 | from optimade.server.config import ServerConfig 12 | from optimade.server.entry_collections import create_entry_collections 13 | 14 | # get default config and entry collections 15 | config = ServerConfig() 16 | entry_collections = create_entry_collections(config) 17 | 18 | entry_name_attributes = { 19 | "links": LinksResourceAttributes, 20 | "references": ReferenceResourceAttributes, 21 | "structures": StructureResourceAttributes, 22 | } 23 | 24 | # Make sure we're hitting all collections 25 | assert set(entry_name_attributes.keys()) == set(entry_collections.keys()) 26 | 27 | for entry_name, attributes_model in entry_name_attributes.items(): 28 | assert ( 29 | set(attributes_model.model_fields.keys()) 30 | == entry_collections[entry_name].get_attribute_fields() 31 | ) 32 | -------------------------------------------------------------------------------- /.docker/docker_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug": true, 3 | "page_limit": 5, 4 | "default_db": "test_server", 5 | "base_url": "http://gh_actions_host:3213", 6 | "implementation": { 7 | "name": "Example implementation", 8 | "source_url": "https://github.com/Materials-Consortia/optimade-python-tools", 9 | "maintainer": {"email": "test@test.org"} 10 | }, 11 | "provider": { 12 | "name": "Example provider", 13 | "description": "Provider used for examples, not to be assigned to a real database", 14 | "prefix": "exmpl", 15 | "homepage": "https://example.com", 16 | "index_base_url": "http://gh_actions_host:3214" 17 | }, 18 | "provider_fields": { 19 | "structures": [ 20 | "band_gap", 21 | "chemsys" 22 | ] 23 | }, 24 | "aliases": { 25 | "structures": { 26 | "id": "task_id", 27 | "chemical_formula_descriptive": "pretty_formula", 28 | "chemical_formula_reduced": "pretty_formula", 29 | "chemical_formula_anonymous": "formula_anonymous" 30 | } 31 | }, 32 | "length_aliases": { 33 | "structures": { 34 | "chemsys": "nelements" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/static/default_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug": false, 3 | "insert_test_data": true, 4 | "mongo_database": "optimade", 5 | "mongo_uri": "localhost:27017", 6 | "links_collection": "links", 7 | "references_collection": "references", 8 | "structures_collection": "structures", 9 | "page_limit": 20, 10 | "page_limit_max": 500, 11 | "default_db": "test_server", 12 | "base_url": null, 13 | "implementation": { 14 | "name": "OPTIMADE Python Tools", 15 | "version": "1.3.1", 16 | "source_url": "https://github.com/Materials-Consortia/optimade-python-tools", 17 | "maintainer": {"email": "dev@optimade.org"} 18 | }, 19 | "index_base_url": null, 20 | "provider": { 21 | "name": "Example provider", 22 | "description": "Provider used for examples, not to be assigned to a real database", 23 | "prefix": "exmpl", 24 | "homepage": "https://example.com" 25 | }, 26 | "provider_fields": {}, 27 | "aliases": {}, 28 | "length_aliases": {}, 29 | "index_links_path": "./optimade/server/index_links.json", 30 | "log_level": "info", 31 | "log_dir": "/var/log/optimade/", 32 | "validate_query_parameters": true, 33 | "validate_api_response": true 34 | } 35 | -------------------------------------------------------------------------------- /tests/test_setup.py: -------------------------------------------------------------------------------- 1 | """Distribution tests.""" 2 | 3 | import pytest 4 | 5 | package_data = [ 6 | ".lark", 7 | "index_links.json", 8 | "test_structures.json", 9 | "test_references.json", 10 | "test_links.json", 11 | "landing_page.html", 12 | "providers.json", 13 | ] 14 | 15 | 16 | @pytest.fixture(scope="module") 17 | def build_dist() -> str: 18 | """Run `python -m build` and return the output.""" 19 | import os 20 | from pathlib import Path 21 | from tempfile import TemporaryDirectory 22 | 23 | repo_root = Path(__file__).parent.parent.resolve() 24 | 25 | with TemporaryDirectory() as tmp_dir: 26 | tmp_path = Path(tmp_dir).resolve() 27 | file_output = tmp_path / "sdist.out" 28 | 29 | os.chdir(repo_root) 30 | os.system(f"python -m build -o {tmp_path / 'dist'} > {file_output}") 31 | 32 | return file_output.read_text(encoding="utf-8") 33 | 34 | 35 | @pytest.mark.parametrize("package_file", package_data) 36 | def test_distribution_package_data(package_file: str, build_dist: str) -> None: 37 | """Make sure a distribution has all the needed package data.""" 38 | import re 39 | 40 | assert re.findall(package_file, build_dist), ( 41 | f"{package_file} file NOT found.\nOUTPUT:\n{build_dist}" 42 | ) 43 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | THIS_FILE=`basename "$0"` 4 | 5 | if [ -z "$OPTIMADE_CONFIG_FILE" ]; then 6 | export OPTIMADE_CONFIG_FILE="./optimade_config.json" 7 | echo -e "INFO:\t[${THIS_FILE}] Using the demo config file at ${OPTIMADE_CONFIG_FILE}." 8 | echo -e "INFO:\t[${THIS_FILE}] Set the environment variable OPTIMADE_CONFIG_FILE to override this behaviour." 9 | echo -e "INFO:\t[${THIS_FILE}] For more configuration options, please see https://www.optimade.org/optimade-python-tools/configuration/." 10 | fi 11 | 12 | export OPTIMADE_LOG_LEVEL=info 13 | if [ "$1" == "debug" ]; then 14 | export OPTIMADE_DEBUG=1 15 | export OPTIMADE_LOG_LEVEL=debug 16 | fi 17 | 18 | if [ "$1" == "index" ]; then 19 | MAIN="main_index" 20 | PORT=5001 21 | if [ "$2" == "debug" ]; then 22 | export OPTIMADE_DEBUG=1 23 | export OPTIMADE_LOG_LEVEL=debug 24 | fi 25 | else 26 | if [ "${MAIN}" == "main_index" ]; then 27 | PORT=5001 28 | else 29 | MAIN="main" 30 | PORT=5000 31 | fi 32 | fi 33 | 34 | echo -e "INFO:\t[${THIS_FILE}] Launching the development server with uvicorn for the ${MAIN} app on port ${PORT} with log level ${OPTIMADE_LOG_LEVEL}." 35 | 36 | uvicorn optimade.server.$MAIN:app --reload --port $PORT --log-level $OPTIMADE_LOG_LEVEL --host 0.0.0.0 37 | -------------------------------------------------------------------------------- /tests/models/test_references.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import ValidationError 3 | 4 | from optimade.models.references import ReferenceResource 5 | from optimade.server.mappers import ReferenceMapper 6 | 7 | 8 | def test_more_good_references(good_references): 9 | """Check well-formed structures with specific edge-cases""" 10 | for index, structure in enumerate(good_references): 11 | try: 12 | ReferenceResource(**ReferenceMapper().map_back(structure)) 13 | except ValidationError: 14 | # Printing to keep the original exception as is, while still being informational 15 | print( 16 | f"Good test structure {index} failed to validate from 'test_good_structures.json'" 17 | ) 18 | raise 19 | 20 | 21 | def test_bad_references(): 22 | """Check badly formed references""" 23 | from pydantic import ValidationError 24 | 25 | bad_refs = [ 26 | {"id": "AAAA", "type": "references", "doi": "10.1234/1234"}, # bad id 27 | {"id": "newton1687", "type": "references"}, # missing all fields 28 | {"id": "newton1687", "type": "reference", "doi": "10.1234/1234"}, # wrong type 29 | ] 30 | 31 | for ref in bad_refs: 32 | with pytest.raises(ValidationError): 33 | ReferenceResource(**ReferenceMapper().map_back(ref)) 34 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | day: monday 8 | time: "05:43" 9 | # Needs to be larger than the number of total requirements (currently 31) 10 | open-pull-requests-limit: 50 11 | target-branch: main 12 | labels: 13 | - dependency_updates 14 | # Turn off automatic rebases so that auto-merge can work without needed N**2 CI runs 15 | rebase-strategy: "disabled" 16 | ignore: 17 | - dependency-name: "elasticsearch*" 18 | versions: [ ">=8" ] 19 | groups: 20 | python-dependencies: 21 | applies-to: version-updates 22 | dependency-type: production 23 | python-dependencies-dev: 24 | applies-to: version-updates 25 | dependency-type: development 26 | python-dependencies-security: 27 | applies-to: security-updates 28 | dependency-type: production 29 | - package-ecosystem: gitsubmodule 30 | directory: "/" 31 | schedule: 32 | interval: daily 33 | time: "05:38" 34 | target-branch: main 35 | labels: 36 | - providers_updates 37 | - package-ecosystem: github-actions 38 | directory: "/" 39 | schedule: 40 | interval: daily 41 | time: "05:33" 42 | target-branch: main 43 | labels: 44 | - CI 45 | groups: 46 | github-actions: 47 | applies-to: version-updates 48 | dependency-type: production 49 | -------------------------------------------------------------------------------- /tests/models/test_data/test_good_references.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "dijkstra1968", 4 | "type": "references", 5 | "last_modified": "2019-11-12T14:24:37.331000", 6 | "authors": [ 7 | { 8 | "name": "Edsger W. Dijkstra", 9 | "firstname": "Edsger", 10 | "lastname": "Dijkstra" 11 | } 12 | ], 13 | "doi": "10.1145/362929.362947", 14 | "journal": "Communications of the ACM", 15 | "title": "Go To Statement Considered Harmful", 16 | "year": "1968" 17 | }, 18 | { 19 | "id": "maddox1988", 20 | "type": "references", 21 | "last_modified": "2019-11-27T14:24:37.331000", 22 | "authors": [ 23 | { 24 | "name": "John Maddox", 25 | "firstname": "John", 26 | "lastname": "Maddox" 27 | } 28 | ], 29 | "doi": "10.1038/335201a0", 30 | "journal": "Nature", 31 | "title": "Crystals From First Principles", 32 | "year": "1988" 33 | }, 34 | { 35 | "id": "dummy/2019", 36 | "type": "references", 37 | "last_modified": "2019-11-23T14:24:37.332000", 38 | "authors": [ 39 | { 40 | "name": "A Nother", 41 | "firstname": "A", 42 | "lastname": "Nother" 43 | } 44 | ], 45 | "doi": "10.1038/00000", 46 | "journal": "JACS", 47 | "title": "Dummy reference that should remain orphaned from all structures for testing purposes", 48 | "year": "2019" 49 | } 50 | ] 51 | -------------------------------------------------------------------------------- /tests/server/routers/test_references.py: -------------------------------------------------------------------------------- 1 | from optimade.models import ReferenceResponseMany, ReferenceResponseOne 2 | 3 | from ..utils import RegularEndpointTests 4 | 5 | 6 | class TestReferencesEndpoint(RegularEndpointTests): 7 | request_str = "/references" 8 | response_cls = ReferenceResponseMany 9 | 10 | 11 | class TestSingleReferenceEndpoint(RegularEndpointTests): 12 | test_id = "dijkstra1968" 13 | request_str = f"/references/{test_id}" 14 | response_cls = ReferenceResponseOne 15 | 16 | 17 | class TestSingleReferenceEndpointDifficult(RegularEndpointTests): 18 | test_id = "dummy/20.19" 19 | request_str = f"/references/{test_id}" 20 | response_cls = ReferenceResponseOne 21 | 22 | 23 | class TestMissingSingleReferenceEndpoint(RegularEndpointTests): 24 | """Tests for /references/ for unknown """ 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 | 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 | --------------------------------------------------------------------------------