From b072d31651dff0bb8b1878fb2551be5e41be10e8 Mon Sep 17 00:00:00 2001 From: Alexander Goscinski Date: Thu, 25 Jul 2024 18:26:17 +0200 Subject: [PATCH] Implement download_formats endpoint on FastAPI side This endpoint needed to be implemented on the FastAPI side as graphql does not all dynamic field entries. An implementation on the GraphQL would require to change the format of the output of download_formats from the old restapi. I moved part of the code of aiida.restapi.common.identifiers and aiida.restapi.common.exceptions to this repo that is required to get all download formats. --- aiida_restapi/exceptions.py | 32 ++++++ aiida_restapi/identifiers.py | 175 ++++++++++++++++++++++++++++++ aiida_restapi/models.py | 41 +++++++ aiida_restapi/routers/nodes.py | 9 +- docs/source/user_guide/graphql.md | 5 +- tests/test_nodes.py | 29 +++++ 6 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 aiida_restapi/exceptions.py create mode 100644 aiida_restapi/identifiers.py diff --git a/aiida_restapi/exceptions.py b/aiida_restapi/exceptions.py new file mode 100644 index 0000000..e13acfb --- /dev/null +++ b/aiida_restapi/exceptions.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""This file contains the exceptions that are raised by the RESTapi at the +highest level, namely that of the interaction with the client. Their +specificity resides into the fact that they return a message that is embedded +into the HTTP response. + +Example: +-------- + .../api/v1/nodes/ ... (TODO compete this with an actual example) + +Other errors arising at deeper level, e.g. those raised by the QueryBuilder +or internal errors, are not embedded into the HTTP response. + +""" + +from aiida.common.exceptions import FeatureNotAvailable, InputValidationError + + +class RestInputValidationError(InputValidationError): + """If inputs passed in query strings are wrong""" + + +class RestFeatureNotAvailable(FeatureNotAvailable): + """If endpoint is not emplemented for given node type""" diff --git a/aiida_restapi/identifiers.py b/aiida_restapi/identifiers.py new file mode 100644 index 0000000..1148388 --- /dev/null +++ b/aiida_restapi/identifiers.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +########################################################################### +# Copyright (c), The AiiDA team. All rights reserved. # +# This file is part of the AiiDA code. # +# # +# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core # +# For further information on the license, see the LICENSE.txt file # +# For further information please visit http://www.aiida.net # +########################################################################### +"""Utility functions to work with node "full types" which are unique node identifiers. + +A node's `full_type` is defined as a string that uniquely defines the node type. A valid `full_type` is constructed by +concatenating the `node_type` and `process_type` of a node with the `FULL_TYPE_CONCATENATOR`. Each segment of the full +type can optionally be terminated by a single `LIKE_OPERATOR_CHARACTER` to indicate that the `node_type` or +`process_type` should start with that value but can be followed by any amount of other characters. A full type is +invalid if it does not contain exactly one `FULL_TYPE_CONCATENATOR` character. Additionally, each segment can contain +at most one occurrence of the `LIKE_OPERATOR_CHARACTER` and it has to be at the end of the segment. + +Examples of valid full types: + + 'data.bool.Bool.|' + 'process.calculation.calcfunction.%|%' + 'process.calculation.calcjob.CalcJobNode.|aiida.calculations:arithmetic.add' + 'process.calculation.calcfunction.CalcFunctionNode.|aiida.workflows:codtools.primitive_structure_from_cif' + +Examples of invalid full types: + + 'data.bool' # Only a single segment without concatenator + 'data.|bool.Bool.|process.' # More than one concatenator + 'process.calculation%.calcfunction.|aiida.calculations:arithmetic.add' # Like operator not at end of segment + 'process.calculation%.calcfunction.%|aiida.calculations:arithmetic.add' # More than one operator in segment + +""" + +from collections.abc import MutableMapping +from typing import Any + +from aiida.common.escaping import escape_for_sql_like + +FULL_TYPE_CONCATENATOR = "|" +LIKE_OPERATOR_CHARACTER = "%" +DEFAULT_NAMESPACE_LABEL = "~no-entry-point~" + + +def validate_full_type(full_type: str) -> None: + """Validate that the `full_type` is a valid full type unique node identifier. + + :param full_type: a `Node` full type + :raises ValueError: if the `full_type` is invalid + :raises TypeError: if the `full_type` is not a string type + """ + from aiida.common.lang import type_check + + type_check(full_type, str) + + if FULL_TYPE_CONCATENATOR not in full_type: + raise ValueError( + f"full type `{full_type}` does not include the required concatenator symbol `{FULL_TYPE_CONCATENATOR}`." + ) + elif full_type.count(FULL_TYPE_CONCATENATOR) > 1: + raise ValueError( + f"full type `{full_type}` includes the concatenator symbol `{FULL_TYPE_CONCATENATOR}` more than once." + ) + + +def construct_full_type(node_type: str, process_type: str) -> str: + """Return the full type, which uniquely identifies any `Node` with the given `node_type` and `process_type`. + + :param node_type: the `node_type` of the `Node` + :param process_type: the `process_type` of the `Node` + :return: the full type, which is a unique identifier + """ + if node_type is None: + node_type = "" + + if process_type is None: + process_type = "" + + return f"{node_type}{FULL_TYPE_CONCATENATOR}{process_type}" + + +def get_full_type_filters(full_type: str) -> dict[str, Any]: + """Return the `QueryBuilder` filters that will return all `Nodes` identified by the given `full_type`. + + :param full_type: the `full_type` unique node identifier + :return: dictionary of filters to be passed for the `filters` keyword in `QueryBuilder.append` + :raises ValueError: if the `full_type` is invalid + :raises TypeError: if the `full_type` is not a string type + """ + validate_full_type(full_type) + + filters: dict[str, Any] = {} + node_type, process_type = full_type.split(FULL_TYPE_CONCATENATOR) + + for entry in (node_type, process_type): + if entry.count(LIKE_OPERATOR_CHARACTER) > 1: + raise ValueError( + f"full type component `{entry}` contained more than one like-operator character" + ) + + if LIKE_OPERATOR_CHARACTER in entry and entry[-1] != LIKE_OPERATOR_CHARACTER: + raise ValueError( + f"like-operator character in full type component `{entry}` is not at the end" + ) + + if LIKE_OPERATOR_CHARACTER in node_type: + # Remove the trailing `LIKE_OPERATOR_CHARACTER`, escape the string and reattach the character + node_type = node_type[:-1] + node_type = escape_for_sql_like(node_type) + LIKE_OPERATOR_CHARACTER + filters["node_type"] = {"like": node_type} + else: + filters["node_type"] = {"==": node_type} + + if LIKE_OPERATOR_CHARACTER in process_type: + # Remove the trailing `LIKE_OPERATOR_CHARACTER` () + # If that was the only specification, just ignore this filter (looking for any process_type) + # If there was more: escape the string and reattach the character + process_type = process_type[:-1] + if process_type: + process_type = escape_for_sql_like(process_type) + LIKE_OPERATOR_CHARACTER + filters["process_type"] = {"like": process_type} + elif process_type: + filters["process_type"] = {"==": process_type} + else: + # A `process_type=''` is used to represents both `process_type='' and `process_type=None`. + # This is because there is no simple way to single out null `process_types`, and therefore + # we consider them together with empty-string process_types. + # Moreover, the existence of both is most likely a bug of migrations and thus both share + # this same "erroneous" origin. + filters["process_type"] = {"or": [{"==": ""}, {"==": None}]} + + return filters + + +def load_entry_point_from_full_type(full_type: str) -> Any: + """Return the loaded entry point for the given `full_type` unique node identifier. + + :param full_type: the `full_type` unique node identifier + :raises ValueError: if the `full_type` is invalid + :raises TypeError: if the `full_type` is not a string type + :raises `~aiida.common.exceptions.EntryPointError`: if the corresponding entry point cannot be loaded + """ + from aiida.common import EntryPointError + from aiida.common.utils import strip_prefix + from aiida.plugins.entry_point import ( + is_valid_entry_point_string, + load_entry_point, + load_entry_point_from_string, + ) + + data_prefix = "data." + + validate_full_type(full_type) + + node_type, process_type = full_type.split(FULL_TYPE_CONCATENATOR) + + if is_valid_entry_point_string(process_type): + try: + return load_entry_point_from_string(process_type) + except EntryPointError: + raise EntryPointError(f"could not load entry point `{process_type}`") + + elif node_type.startswith(data_prefix): + base_name = strip_prefix(node_type, data_prefix) + entry_point_name = base_name.rsplit(".", 2)[0] + + try: + return load_entry_point("aiida.data", entry_point_name) + except EntryPointError: + raise EntryPointError(f"could not load entry point `{process_type}`") + + # Here we are dealing with a `ProcessNode` with a `process_type` that is not an entry point string. + # Which means it is most likely a full module path (the fallback option) and we cannot necessarily load the + # class from this. We could try with `importlib` but not sure that we should + raise EntryPointError("entry point of the given full type cannot be loaded") diff --git a/aiida_restapi/models.py b/aiida_restapi/models.py index 7dccf0d..21c02ba 100644 --- a/aiida_restapi/models.py +++ b/aiida_restapi/models.py @@ -13,9 +13,14 @@ from uuid import UUID from aiida import orm +from aiida.common.exceptions import EntryPointError, LoadingEntryPointError +from aiida.plugins.entry_point import get_entry_point_names, load_entry_point from fastapi import Form from pydantic import BaseModel, Field +from .exceptions import RestFeatureNotAvailable, RestInputValidationError +from .identifiers import construct_full_type, load_entry_point_from_full_type + # Template type for subclasses of `AiidaModel` ModelType = TypeVar("ModelType", bound="AiidaModel") @@ -62,6 +67,42 @@ def get_projectable_properties(cls) -> List[str]: """Return projectable properties.""" return list(cls.schema()["properties"].keys()) + @staticmethod + def get_download_formats(full_type: str | None = None) -> dict: + """Returns dict of possible node formats for all available node types""" + all_formats = {} + + if full_type: + try: + node_cls = load_entry_point_from_full_type(full_type) + except (TypeError, ValueError): + raise RestInputValidationError(f"The full type {full_type} is invalid.") + except EntryPointError: + raise RestFeatureNotAvailable( + "The download formats for this node type are not available." + ) + + try: + available_formats = node_cls.get_export_formats() + all_formats[full_type] = available_formats + except AttributeError: + pass + else: + entry_point_group = "aiida.data" + + for name in get_entry_point_names(entry_point_group): + try: + node_cls = load_entry_point(entry_point_group, name) + available_formats = node_cls.get_export_formats() + except (AttributeError, LoadingEntryPointError): + continue + + if available_formats: + full_type = construct_full_type(node_cls.class_node_type, "") + all_formats[full_type] = available_formats + + return all_formats + @classmethod def get_entities( cls: Type[ModelType], diff --git a/aiida_restapi/routers/nodes.py b/aiida_restapi/routers/nodes.py index b6c315b..4488b4f 100644 --- a/aiida_restapi/routers/nodes.py +++ b/aiida_restapi/routers/nodes.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """Declaration of FastAPI application.""" -from typing import List, Optional +from typing import Any, List, Optional from aiida import orm from aiida.cmdline.utils.decorators import with_dbenv @@ -29,6 +29,13 @@ async def get_nodes_projectable_properties() -> List[str]: return models.Node.get_projectable_properties() +@router.get("/nodes/download_formats", response_model=dict[str, Any]) +async def get_nodes_download_formats() -> dict[str, Any]: + """Get download formats for nodes endpoint""" + + return models.Node.get_download_formats() + + @router.get("/nodes/{nodes_id}", response_model=models.Node) @with_dbenv() async def read_node(nodes_id: int) -> Optional[models.Node]: diff --git a/docs/source/user_guide/graphql.md b/docs/source/user_guide/graphql.md index 18214a5..1e3191d 100644 --- a/docs/source/user_guide/graphql.md +++ b/docs/source/user_guide/graphql.md @@ -377,7 +377,10 @@ NOT YET SPECIFICALLY IMPLEMENTED http://localhost:5000/api/v4/nodes/download_formats ``` -NOT YET IMPLEMENTED +Still accesible under the same address, since we cannot implement the same behavior in GraphQL. +```html +http://localhost:5000/nodes/download_formats +``` ```html diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 94d1999..f3f27bd 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -27,6 +27,35 @@ def test_get_nodes_projectable(client): ] +def test_get_download_formats(client): + """Test get projectable properites for nodes.""" + response = client.get("/nodes/download_formats") + + assert response.status_code == 200 + assert response.json() == { + "data.core.array.ArrayData.|": ["json"], + "data.core.array.bands.BandsData.|": [ + "agr", + "agr_batch", + "dat_blocks", + "dat_multicolumn", + "gnuplot", + "json", + "mpl_pdf", + "mpl_png", + "mpl_singlefile", + "mpl_withjson", + ], + "data.core.array.kpoints.KpointsData.|": ["json"], + "data.core.array.projection.ProjectionData.|": ["json"], + "data.core.array.trajectory.TrajectoryData.|": ["cif", "json", "xsf"], + "data.core.array.xy.XyData.|": ["json"], + "data.core.cif.CifData.|": ["cif"], + "data.core.structure.StructureData.|": ["chemdoodle", "cif", "xsf", "xyz"], + "data.core.upf.UpfData.|": ["json", "upf"], + } + + def test_get_single_nodes(default_nodes, client): # pylint: disable=unused-argument """Test retrieving a single nodes."""