diff --git a/openapi/index_openapi.json b/openapi/index_openapi.json index 643780fc3..9b65d6f69 100644 --- a/openapi/index_openapi.json +++ b/openapi/index_openapi.json @@ -1012,8 +1012,29 @@ }, "link_type": { "title": "Link Type", - "type": "string", + "enum": [ + "child", + "root", + "external", + "providers" + ], "description": "The link type of the represented resource in relation to this implementation. MUST be one of these values: 'child', 'root', 'external', 'providers'." + }, + "aggregate": { + "title": "Aggregate", + "enum": [ + "ok", + "test", + "staging", + "no" + ], + "description": "A string indicating whether a client that is following links to aggregate results from different OPTIMADE implementations should follow this link or not.\nThis flag SHOULD NOT be indicated for links where :property:`link_type` is not :val:`child`.\n\nIf not specified, clients MAY assume that the value is :val:`ok`.\nIf specified, and the value is anything different than :val:`ok`, the client MUST assume that the server is suggesting not to follow the link during aggregation by default (also if the value is not among the known ones, in case a future specification adds new accepted values).\n\nSpecific values indicate the reason why the server is providing the suggestion.\nA client MAY follow the link anyway if it has reason to do so (e.g., if the client is looking for all test databases, it MAY follow the links marked with :property:`aggregate`=:val:`test`).\n\nIf specified, it MUST be one of the values listed in section Link Aggregate Options.", + "default": "ok" + }, + "no_aggregate_reason": { + "title": "No Aggregate Reason", + "type": "string", + "description": "An OPTIONAL human-readable string indicating the reason for suggesting not to aggregate results following the link.\nIt SHOULD NOT be present if :property:`aggregate`=:val:`ok`." } }, "description": "Links endpoint resource object attributes" diff --git a/openapi/openapi.json b/openapi/openapi.json index 7bcc315a0..d92170481 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -1108,6 +1108,20 @@ "title": "Sortable", "type": "boolean", "description": "defines whether the entry property can be used for sorting with the \"sort\" parameter. If the entry listing endpoint supports sorting, this key MUST be present for sortable properties with value `true`." + }, + "type": { + "title": "Type", + "enum": [ + "string", + "integer", + "float", + "boolean", + "timestamp", + "list", + "dictionary", + "unknown" + ], + "description": "Data type of value. Must equal a valid OPTIMADE data type as listed and defined under 'Data types'." } } }, @@ -1794,8 +1808,29 @@ }, "link_type": { "title": "Link Type", - "type": "string", + "enum": [ + "child", + "root", + "external", + "providers" + ], "description": "The link type of the represented resource in relation to this implementation. MUST be one of these values: 'child', 'root', 'external', 'providers'." + }, + "aggregate": { + "title": "Aggregate", + "enum": [ + "ok", + "test", + "staging", + "no" + ], + "description": "A string indicating whether a client that is following links to aggregate results from different OPTIMADE implementations should follow this link or not.\nThis flag SHOULD NOT be indicated for links where :property:`link_type` is not :val:`child`.\n\nIf not specified, clients MAY assume that the value is :val:`ok`.\nIf specified, and the value is anything different than :val:`ok`, the client MUST assume that the server is suggesting not to follow the link during aggregation by default (also if the value is not among the known ones, in case a future specification adds new accepted values).\n\nSpecific values indicate the reason why the server is providing the suggestion.\nA client MAY follow the link anyway if it has reason to do so (e.g., if the client is looking for all test databases, it MAY follow the links marked with :property:`aggregate`=:val:`test`).\n\nIf specified, it MUST be one of the values listed in section Link Aggregate Options.", + "default": "ok" + }, + "no_aggregate_reason": { + "title": "No Aggregate Reason", + "type": "string", + "description": "An OPTIONAL human-readable string indicating the reason for suggesting not to aggregate results following the link.\nIt SHOULD NOT be present if :property:`aggregate`=:val:`ok`." } }, "description": "Links endpoint resource object attributes" @@ -2733,6 +2768,22 @@ "title": "Original Name", "type": "string", "description": "Can be any valid Unicode string, and SHOULD contain (if specified) the name of the species that is used internally in the source database.\n\nNote: With regards to \"source database\", we refer to the immediate source being queried via the OPTIMADE API implementation.\nThe main use of this field is for source databases that use species names, containing characters that are not allowed (see description of the list property `species_at_sites`_)." + }, + "attached": { + "title": "Attached", + "type": "array", + "items": { + "type": "string" + }, + "description": "If provided MUST be a list of length 1 or more of strings of chemical symbols for the elements attached to this site, or \"X\" for a non-chemical element." + }, + "nattached": { + "title": "Nattached", + "type": "array", + "items": { + "type": "integer" + }, + "description": "If provided MUST be a list of length 1 or more of integers indicating the number of attached atoms of the kind specified in the value of the :field:`attached` key." } }, "description": "A list describing the species of the sites of this structure.\nSpecies can be pure chemical elements, or virtual-crystal atoms representing a statistical occupation of a given site by multiple chemical elements.\n\n- **Examples**:\n\n - :val:`[ {\"name\": \"Ti\", \"chemical_symbols\": [\"Ti\"], \"concentration\": [1.0]} ]`: any site with this species is occupied by a Ti atom.\n - :val:`[ {\"name\": \"Ti\", \"chemical_symbols\": [\"Ti\", \"vacancy\"], \"concentration\": [0.9, 0.1]} ]`: any site with this species is occupied by a Ti atom with 90 % probability, and has a vacancy with 10 % probability.\n - :val:`[ {\"name\": \"BaCa\", \"chemical_symbols\": [\"vacancy\", \"Ba\", \"Ca\"], \"concentration\": [0.05, 0.45, 0.5], \"mass\": 88.5} ]`: any site with this species is occupied by a Ba atom with 45 % probability, a Ca atom with 50 % probability, and by a vacancy with 5 % probability. The mass of this site is (on average) 88.5 a.m.u.\n - :val:`[ {\"name\": \"C12\", \"chemical_symbols\": [\"C\"], \"concentration\": [1.0], \"mass\": 12.0} ]`: any site with this species is occupied by a carbon isotope with mass 12.\n - :val:`[ {\"name\": \"C13\", \"chemical_symbols\": [\"C\"], \"concentration\": [1.0], \"mass\": 13.0} ]`: any site with this species is occupied by a carbon isotope with mass 13." @@ -2846,8 +2897,8 @@ "dimension_types", "cartesian_site_positions", "nsites", - "species_at_sites", "species", + "species_at_sites", "structure_features" ], "type": "object", @@ -2932,6 +2983,11 @@ ], "description": "List of three integers.\n For each of the three directions indicated by the three lattice vectors (see property `lattice_vectors`_).\n This list indicates if the direction is periodic (value :val:`1`) or non-periodic (value :val:`0`).\n Note: the elements in this list each refer to the direction of the corresponding entry in property `lattice_vectors`_ and *not* the Cartesian x, y, z directions.\n- **Type**: list of integers.\n- **Requirements/Conventions**:\n\n - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`.\n - **Query**: MUST be a queryable property. Support for equality comparison is REQUIRED, support for other comparison operators are OPTIONAL.\n - MUST be a list of length 3.\n - Each integer element MUST assume only the value 0 or 1.\n\n- **Examples**:\n\n - For a molecule: :val:`[0, 0, 0]`\n - For a wire along the direction specified by the third lattice vector: :val:`[0, 0, 1]`\n - For a 2D surface/slab, periodic on the plane defined by the first and third lattice vectors: :val:`[1, 0, 1]`\n - For a bulk 3D system: :val:`[1, 1, 1]`" }, + "nperiodic_dimensions": { + "title": "Nperiodic Dimensions", + "type": "integer", + "description": "An integer specifying the number of periodic dimensions in the structure, equivalent to the number of non-zero entries in :property:`dimension_types`.\n- **Type**: integer\n- **Requirements/Conventions**:\n\n - **Support**: SHOULD be supported by all implementations, i.e., SHOULD NOT be :val:`null`.\n - **Query**: MUST be a queryable property with support for all mandatory filter features.\n - The integer value MUST be between 0 and 3 inclusive and MUST be equal to the sum of the items in the `dimension_types`_ property.\n - This property only reflects the treatment of the lattice vectors provided for the structure, and not any physical interpretation of the dimensionality of its contents.\n\n- **Examples**:\n\n - :val:`2` should be indicated in cases where :property:`dimension_types` is any of :val:`[1, 1, 0]`, :val:`[1, 0, 1]`, :val:`[0, 1, 1]`.\n\n- **Query examples**:\n\n - Match only structures with exactly 3 periodic dimensions: :filter:`nperiodic_dimensions=3`\n - Match all structures with 2 or fewer periodic dimensions: :filter:`nperiodic_dimensions<=2`" + }, "lattice_vectors": { "title": "Lattice Vectors", "type": "array", @@ -2998,21 +3054,13 @@ } ] }, - "description": "Cartesian positions of each site. A site is an atom, a site potentially occupied by an atom, or a placeholder for a virtual mixture of atoms (e.g., in a virtual crystal approximation).\n- **Type**: list of list of floats and/or unknown values\n- **Requirements/Conventions**:\n\n - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`.\n - **Query**: Support for queries on this property is OPTIONAL. If supported, filters MAY support only a subset of comparison operators.\n - It MUST be a list of length N times 3, where N is the number of sites in the structure.\n - An entry MAY have multiple sites at the same Cartesian position (for a relevant use of this, see e.g., the property `assemblies`_).\n - If a component of the position is unknown, the :val:`null` value should be provided instead (see section `Properties with unknown value`_).\n Otherwise, it should be a float value, expressed in angstrom (\u00c5).\n If at least one of the coordinates is unknown, the correct flag in the list property `structure_features`_ MUST be set.\n - **Notes**: (for implementers) While this is unrelated to this OPTIMADE specification: If you decide to store internally the :property: `cartesian_site_positions` as a float array, you might want to represent :val:`null` values with :field-val:`NaN` values.\n The latter being valid float numbers in the IEEE 754 standard in `IEEE 754-1985 `__ and in the updated version `IEEE 754-2008 `__.\n\n- **Examples**:\n\n - :val:`[[0,0,0],[0,0,2]]` indicates a structure with two sites, one sitting at the origin and one along the (positive) *z*-axis, 2 \u00c5 away from the origin." + "description": "Cartesian positions of each site. A site is an atom, a site potentially occupied by an atom, or a placeholder for a virtual mixture of atoms (e.g., in a virtual crystal approximation).\n- **Type**: list of list of floats\n- **Requirements/Conventions**:\n\n - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`.\n - **Query**: Support for queries on this property is OPTIONAL. If supported, filters MAY support only a subset of comparison operators.\n - It MUST be a list of length N times 3, where N is the number of sites in the structure.\n - An entry MAY have multiple sites at the same Cartesian position (for a relevant use of this, see e.g., the property `assemblies`_).\n - If a component of the position is unknown, the :val:`null` value should be provided instead (see section `Properties with unknown value`_).\n Otherwise, it should be a float value, expressed in angstrom (\u00c5).\n If at least one of the coordinates is unknown, the correct flag in the list property `structure_features`_ MUST be set.\n - **Notes**: (for implementers) While this is unrelated to this OPTIMADE specification: If you decide to store internally the :property: `cartesian_site_positions` as a float array, you might want to represent :val:`null` values with :field-val:`NaN` values.\n The latter being valid float numbers in the IEEE 754 standard in `IEEE 754-1985 `__ and in the updated version `IEEE 754-2008 `__.\n\n- **Examples**:\n\n - :val:`[[0,0,0],[0,0,2]]` indicates a structure with two sites, one sitting at the origin and one along the (positive) *z*-axis, 2 \u00c5 away from the origin." }, "nsites": { "title": "Nsites", "type": "integer", "description": "An integer specifying the length of the :property:`cartesian_site_positions` property.\n- **Type**: integer\n- **Requirements/Conventions**:\n\n - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`.\n - **Query**: MUST be a queryable property with support for all mandatory filter operators.\n\n- **Examples**:\n\n - :val:`42`\n\n- **Query examples**:\n\n - Match only structures with exactly 4 sites: :filter:`nsites=4`\n - Match structures that have between 2 and 7 sites: :filter:`nsites>=2 AND nsites<=7`" }, - "species_at_sites": { - "title": "Species At Sites", - "type": "array", - "items": { - "type": "string" - }, - "description": "Name of the species at each site (where values for sites are specified with the same order of the property `cartesian_site_positions`_).\n The properties of the species are found in the property `species`_.\n- **Type**: list of strings.\n- **Requirements/Conventions**:\n\n - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`.\n - **Query**: Support for queries on this property is OPTIONAL. If supported, filters MAY support only a subset of comparison operators.\n - MUST have length equal to the number of sites in the structure (first dimension of the list property `cartesian_site_positions`_).\n - Each species MUST have a unique name.\n - Each species name mentioned in the :property:`species_at_sites` list MUST be described in the list property `species`_ (i.e. for each value in the :property:`species_at_sites` list there MUST exist exactly one dictionary in the :property:`species` list with the :property:`name` attribute equal to the corresponding :property:`species_at_sites` value).\n - Each site MUST be associated only to a single species.\n **Note**: However, species can represent mixtures of atoms, and multiple species MAY be defined for the same chemical element.\n This latter case is useful when different atoms of the same type need to be grouped or distinguished, for instance in simulation codes to assign different initial spin states.\n\n- **Examples**:\n\n - :val:`[\"Ti\",\"O2\"]` indicates that the first site is hosting a species labeled :val:`\"Ti\"` and the second a species labeled :val:`\"O2\"`." - }, "species": { "title": "Species", "type": "array", @@ -3021,6 +3069,14 @@ }, "description": "A list describing the species of the sites of this structure. Species can be pure chemical elements, or virtual-crystal atoms representing a statistical occupation of a given site by multiple chemical elements.\n- **Type**: list of dictionary with keys:\n\n - :property:`name`: string (REQUIRED)\n - :property:`chemical_symbols`: list of strings (REQUIRED)\n - :property:`concentration`: list of float (REQUIRED)\n - :property:`mass`: float (OPTIONAL)\n - :property:`original_name`: string (OPTIONAL).\n\n- **Requirements/Conventions**:\n\n - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`.\n - **Query**: Support for queries on this property is OPTIONAL.\n If supported, filters MAY support only a subset of comparison operators.\n - Each list member MUST be a dictionary with the following keys:\n\n - **name**: REQUIRED; gives the name of the species; the **name** value MUST be unique in the :property:`species` list;\n\n - **chemical_symbols**: REQUIRED; MUST be a list of strings of all chemical elements composing this species.\n\n - It MUST be one of the following:\n\n - a valid chemical-element name, or\n - the special value :val:`\"X\"` to represent a non-chemical element, or\n - the special value :val:`\"vacancy\"` to represent that this site has a non-zero probability of having a vacancy (the respective probability is indicated in the :property:`concentration` list, see below).\n\n - If any one entry in the :property:`species` list has a :property:`chemical_symbols` list that is longer than 1 element, the correct flag MUST be set in the list :property:`structure_features` (see property `structure_features`_).\n\n - **concentration**: REQUIRED; MUST be a list of floats, with same length as :property:`chemical_symbols`. The numbers represent the relative concentration of the corresponding chemical symbol in this species.\n The numbers SHOULD sum to one. Cases in which the numbers do not sum to one typically fall only in the following two categories:\n\n - Numerical errors when representing float numbers in fixed precision, e.g. for two chemical symbols with concentrations :val:`1/3` and :val:`2/3`, the concentration might look something like :val:`[0.33333333333, 0.66666666666]`. If the client is aware that the sum is not one because of numerical precision, it can renormalize the values so that the sum is exactly one.\n - Experimental errors in the data present in the database. In this case, it is the responsibility of the client to decide how to process the data.\n\n Note that concentrations are uncorrelated between different site (even of the same species).\n\n - **mass**: OPTIONAL. If present MUST be a float expressed in a.m.u.\n - **original_name**: OPTIONAL. Can be any valid Unicode string, and SHOULD contain (if specified) the name of the species that is used internally in the source database.\n\n Note: With regards to \"source database\", we refer to the immediate source being queried via the OPTIMADE API implementation.\n The main use of this field is for source databases that use species names, containing characters that are not allowed (see description of the list property `species_at_sites`_).\n\n - For systems that have only species formed by a single chemical symbol, and that have at most one species per chemical symbol, SHOULD use the chemical symbol as species name (e.g., :val:`\"Ti\"` for titanium, :val:`\"O\"` for oxygen, etc.)\n However, note that this is OPTIONAL, and client implementations MUST NOT assume that the key corresponds to a chemical symbol, nor assume that if the species name is a valid chemical symbol, that it represents a species with that chemical symbol.\n This means that a species :val:`{\"name\": \"C\", \"chemical_symbols\": [\"Ti\"], \"concentration\": [1.0]}` is valid and represents a titanium species (and *not* a carbon species).\n - It is NOT RECOMMENDED that a structure includes species that do not have at least one corresponding site.\n\n- **Examples**:\n\n - :val:`[ {\"name\": \"Ti\", \"chemical_symbols\": [\"Ti\"], \"concentration\": [1.0]} ]`: any site with this species is occupied by a Ti atom.\n - :val:`[ {\"name\": \"Ti\", \"chemical_symbols\": [\"Ti\", \"vacancy\"], \"concentration\": [0.9, 0.1]} ]`: any site with this species is occupied by a Ti atom with 90 % probability, and has a vacancy with 10 % probability.\n - :val:`[ {\"name\": \"BaCa\", \"chemical_symbols\": [\"vacancy\", \"Ba\", \"Ca\"], \"concentration\": [0.05, 0.45, 0.5], \"mass\": 88.5} ]`: any site with this species is occupied by a Ba atom with 45 % probability, a Ca atom with 50 % probability, and by a vacancy with 5 % probability. The mass of this site is (on average) 88.5 a.m.u.\n - :val:`[ {\"name\": \"C12\", \"chemical_symbols\": [\"C\"], \"concentration\": [1.0], \"mass\": 12.0} ]`: any site with this species is occupied by a carbon isotope with mass 12.\n - :val:`[ {\"name\": \"C13\", \"chemical_symbols\": [\"C\"], \"concentration\": [1.0], \"mass\": 13.0} ]`: any site with this species is occupied by a carbon isotope with mass 13." }, + "species_at_sites": { + "title": "Species At Sites", + "type": "array", + "items": { + "type": "string" + }, + "description": "Name of the species at each site (where values for sites are specified with the same order of the property `cartesian_site_positions`_).\n The properties of the species are found in the property `species`_.\n- **Type**: list of strings.\n- **Requirements/Conventions**:\n\n - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`.\n - **Query**: Support for queries on this property is OPTIONAL. If supported, filters MAY support only a subset of comparison operators.\n - MUST have length equal to the number of sites in the structure (first dimension of the list property `cartesian_site_positions`_).\n - Each species MUST have a unique name.\n - Each species name mentioned in the :property:`species_at_sites` list MUST be described in the list property `species`_ (i.e. for each value in the :property:`species_at_sites` list there MUST exist exactly one dictionary in the :property:`species` list with the :property:`name` attribute equal to the corresponding :property:`species_at_sites` value).\n - Each site MUST be associated only to a single species.\n **Note**: However, species can represent mixtures of atoms, and multiple species MAY be defined for the same chemical element.\n This latter case is useful when different atoms of the same type need to be grouped or distinguished, for instance in simulation codes to assign different initial spin states.\n\n- **Examples**:\n\n - :val:`[\"Ti\",\"O2\"]` indicates that the first site is hosting a species labeled :val:`\"Ti\"` and the second a species labeled :val:`\"O2\"`." + }, "assemblies": { "title": "Assemblies", "type": "array", @@ -3033,9 +3089,14 @@ "title": "Structure Features", "type": "array", "items": { - "type": "string" + "enum": [ + "disorder", + "implicit_atoms", + "site_attachments", + "assemblies" + ] }, - "description": "A list of strings that flag which special features are used by the structure.\n- **Type**: list of strings\n- **Requirements/Conventions**:\n\n - **Support**: REQUIRED, MUST NOT be :val:`null`.\n - **Query**: MUST be a queryable property. Filters on the list MUST support all mandatory HAS-type queries. Filter operators for comparisons on the string components MUST support equality, support for other comparison operators are OPTIONAL.\n - MUST be an empty list if no special features are used.\n - MUST be sorted alphabetically.\n - If a special feature listed below is used, the list MUST contain the corresponding string.\n - If a special feature listed below is not used, the list MUST NOT contain the corresponding string.\n - **List of strings used to indicate special structure features**:\n\n - :val:`disorder`: This flag MUST be present if any one entry in the :property:`species` list has a :property:`chemical_symbols` list that is longer than 1 element.\n - :val:`unknown_positions`: This flag MUST be present if at least one component of the :property:`cartesian_site_positions` list of lists has value :val:`null`.\n - :val:`assemblies`: This flag MUST be present if the property `assemblies`_ is present.\n\n- **Examples**: A structure having unknown positions and using assemblies: :val:`[\"assemblies\", \"unknown_positions\"]`" + "description": "A list of strings that flag which special features are used by the structure.\n- **Type**: list of strings\n- **Requirements/Conventions**:\n\n - **Support**: REQUIRED, MUST NOT be :val:`null`.\n - **Query**: MUST be a queryable property. Filters on the list MUST support all mandatory HAS-type queries. Filter operators for comparisons on the string components MUST support equality, support for other comparison operators are OPTIONAL.\n - MUST be an empty list if no special features are used.\n - MUST be sorted alphabetically.\n - If a special feature listed below is used, the list MUST contain the corresponding string.\n - If a special feature listed below is not used, the list MUST NOT contain the corresponding string.\n - **List of strings used to indicate special structure features**:\n\n - :val:`disorder`: This flag MUST be present if any one entry in the :property:`species` list has a :property:`chemical_symbols` list that is longer than 1 element.\n - :val:`assemblies`: This flag MUST be present if the property `assemblies`_ is present.\n\n- **Examples**: A structure having implicit atoms and using assemblies: :val:`[\"assemblies\", \"implicit_atoms\"]`" } }, "description": "This class contains the Field for the attributes used to represent a structure, e.g. unit cell, atoms, positions." diff --git a/optimade/adapters/structures/aiida.py b/optimade/adapters/structures/aiida.py index 755c7739a..29baa9639 100644 --- a/optimade/adapters/structures/aiida.py +++ b/optimade/adapters/structures/aiida.py @@ -1,6 +1,6 @@ from optimade.models import StructureResource as OptimadeStructure -from optimade.adapters.structures.utils import pad_cell, pad_positions +from optimade.adapters.structures.utils import pad_cell try: from aiida.orm.nodes.data.structure import StructureData, Kind, Site @@ -54,16 +54,13 @@ def get_aiida_structure_data(optimade_structure: OptimadeStructure) -> Structure Kind(symbols=symbols, weights=concentration, mass=mass, name=kind.name) ) - # Convert null/None values to float("nan") - cartesian_site_positions, _ = pad_positions(attributes.cartesian_site_positions) - # Add Sites for index in range(attributes.nsites): # range() to ensure 1-to-1 between kind and site structure.append_site( Site( kind_name=attributes.species_at_sites[index], - position=cartesian_site_positions[index], + position=attributes.cartesian_site_positions[index], ) ) diff --git a/optimade/adapters/structures/ase.py b/optimade/adapters/structures/ase.py index 2e58b2a6d..759bf2185 100644 --- a/optimade/adapters/structures/ase.py +++ b/optimade/adapters/structures/ase.py @@ -3,6 +3,7 @@ from optimade.models import Species as OptimadeStructureSpecies from optimade.models import StructureResource as OptimadeStructure +from optimade.models import StructureFeatures from optimade.adapters.exceptions import ConversionError @@ -31,7 +32,7 @@ def get_ase_atoms(optimade_structure: OptimadeStructure) -> Atoms: attributes = optimade_structure.attributes # Cannot handle partial occupancies - if "disorder" in attributes.structure_features: + if StructureFeatures.DISORDER in attributes.structure_features: raise ConversionError( "ASE cannot handle structures with partial occupancies, sorry." ) diff --git a/optimade/adapters/structures/cif.py b/optimade/adapters/structures/cif.py index e567b5d5d..51a303692 100644 --- a/optimade/adapters/structures/cif.py +++ b/optimade/adapters/structures/cif.py @@ -5,7 +5,6 @@ from optimade.adapters.structures.utils import ( cell_to_cellpar, - pad_positions, fractional_coordinates, ) @@ -78,9 +77,9 @@ def get_cif( # pylint: disable=too-many-locals,too-many-branches # Since some structure viewers are having issues with cartesian coordinates, # we calculate the fractional coordinates if this is a 3D structure and we have all the necessary information. if not hasattr(attributes, "fractional_site_positions"): - sites, _ = pad_positions(attributes.cartesian_site_positions) attributes.fractional_site_positions = fractional_coordinates( - cell=attributes.lattice_vectors, cartesian_positions=sites + cell=attributes.lattice_vectors, + cartesian_positions=attributes.cartesian_site_positions, ) # NOTE: This is otherwise a bit ahead of its time, since this OPTIMADE property is part of an open PR. @@ -102,9 +101,9 @@ def get_cif( # pylint: disable=too-many-locals,too-many-branches ) if coord_type == "fract": - sites, _ = pad_positions(attributes.fractional_site_positions) + sites = attributes.fractional_site_positions else: - sites, _ = pad_positions(attributes.cartesian_site_positions) + sites = attributes.cartesian_site_positions species: Dict[str, OptimadeStructureSpecies] = { species.name: species for species in attributes.species diff --git a/optimade/adapters/structures/jarvis.py b/optimade/adapters/structures/jarvis.py index c1520001f..32fb8e3f6 100644 --- a/optimade/adapters/structures/jarvis.py +++ b/optimade/adapters/structures/jarvis.py @@ -1,7 +1,7 @@ from warnings import warn from optimade.models import StructureResource as OptimadeStructure +from optimade.models import StructureFeatures from optimade.adapters.exceptions import ConversionError -from optimade.adapters.structures.utils import pad_positions try: from jarvis.core.atoms import Atoms @@ -28,16 +28,14 @@ def get_jarvis_atoms(optimade_structure: OptimadeStructure) -> Atoms: attributes = optimade_structure.attributes # Cannot handle partial occupancies - if "disorder" in attributes.structure_features: + if StructureFeatures.DISORDER in attributes.structure_features: raise ConversionError( "jarvis-tools cannot handle structures with partial occupancies." ) - cartesian_site_positions, _ = pad_positions(attributes.cartesian_site_positions) - return Atoms( lattice_mat=attributes.lattice_vectors, elements=[specie.name for specie in attributes.species], - coords=cartesian_site_positions, + coords=attributes.cartesian_site_positions, cartesian=True, ) diff --git a/optimade/adapters/structures/proteindatabank.py b/optimade/adapters/structures/proteindatabank.py index 0c02dca12..1b6d26430 100644 --- a/optimade/adapters/structures/proteindatabank.py +++ b/optimade/adapters/structures/proteindatabank.py @@ -16,7 +16,6 @@ cell_to_cellpar, cellpar_to_cell, fractional_coordinates, - pad_positions, scaled_cell, ) @@ -80,9 +79,9 @@ def get_pdbx_mmcif( # pylint: disable=too-many-locals # Since some structure viewers are having issues with cartesian coordinates, # we calculate the fractional coordinates if this is a 3D structure and we have all the necessary information. if not hasattr(attributes, "fractional_site_positions"): - sites, _ = pad_positions(attributes.cartesian_site_positions) attributes.fractional_site_positions = fractional_coordinates( - cell=attributes.lattice_vectors, cartesian_positions=sites + cell=attributes.lattice_vectors, + cartesian_positions=attributes.cartesian_site_positions, ) # TODO: The following lines are perhaps needed to create a "valid" PDBx/mmCIF file. @@ -134,9 +133,9 @@ def get_pdbx_mmcif( # pylint: disable=too-many-locals ) if coord_type == "fract": - sites, _ = pad_positions(attributes.fractional_site_positions) + sites = attributes.fractional_site_positions else: - sites, _ = pad_positions(attributes.cartesian_site_positions) + sites = attributes.cartesian_site_positions species: Dict[str, OptimadeStructureSpecies] = { species.name: species for species in attributes.species @@ -216,8 +215,7 @@ def get_pdb( # pylint: disable=too-many-locals species.name: species for species in attributes.species } - cartesian_site_positions, _ = pad_positions(attributes.cartesian_site_positions) - sites = np.asarray(cartesian_site_positions) + sites = np.asarray(attributes.cartesian_site_positions) if rotation is not None: sites = sites.dot(rotation) diff --git a/optimade/adapters/structures/pymatgen.py b/optimade/adapters/structures/pymatgen.py index 9046352f1..0ab5c20f6 100644 --- a/optimade/adapters/structures/pymatgen.py +++ b/optimade/adapters/structures/pymatgen.py @@ -3,8 +3,6 @@ from optimade.models import Species as OptimadeStructureSpecies from optimade.models import StructureResource as OptimadeStructure -from optimade.adapters.structures.utils import pad_positions - try: from pymatgen import Structure, Molecule @@ -40,8 +38,6 @@ def _get_structure(optimade_structure: OptimadeStructure) -> Structure: attributes = optimade_structure.attributes - cartesian_site_positions, _ = pad_positions(attributes.cartesian_site_positions) - return Structure( lattice=attributes.lattice_vectors, species=_pymatgen_species( @@ -49,7 +45,7 @@ def _get_structure(optimade_structure: OptimadeStructure) -> Structure: species=attributes.species, species_at_sites=attributes.species_at_sites, ), - coords=cartesian_site_positions, + coords=attributes.cartesian_site_positions, coords_are_cartesian=True, ) @@ -59,15 +55,13 @@ def _get_molecule(optimade_structure: OptimadeStructure) -> Molecule: attributes = optimade_structure.attributes - cartesian_site_positions, _ = pad_positions(attributes.cartesian_site_positions) - return Molecule( species=_pymatgen_species( nsites=attributes.nsites, species=attributes.species, species_at_sites=attributes.species_at_sites, ), - coords=cartesian_site_positions, + coords=attributes.cartesian_site_positions, ) diff --git a/optimade/adapters/structures/utils.py b/optimade/adapters/structures/utils.py index 89edddd23..d50e570df 100644 --- a/optimade/adapters/structures/utils.py +++ b/optimade/adapters/structures/utils.py @@ -228,15 +228,6 @@ def _pad_iter_of_iters( return iterable, padded_iterable -def pad_positions( - positions: List[Vector3D], padding: float = None -) -> Tuple[List[Vector3D], bool]: - """Turn any null/None values into a float in given list of positions""" - return _pad_iter_of_iters( - iterable=positions, padding=padding, outer=list, inner=tuple, - ) - - def pad_cell( lattice_vectors: Tuple[Vector3D, Vector3D, Vector3D], padding: float = None ) -> Tuple[Tuple[Vector3D, Vector3D, Vector3D], bool]: diff --git a/optimade/models/entries.py b/optimade/models/entries.py index b7d026db2..41fc22280 100644 --- a/optimade/models/entries.py +++ b/optimade/models/entries.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field, validator # pylint: disable=no-name-in-module from .jsonapi import Relationships, Attributes, Resource -from .optimade_json import Relationship +from .optimade_json import Relationship, DataType __all__ = ( @@ -150,6 +150,11 @@ class EntryInfoProperty(BaseModel): "If the entry listing endpoint supports sorting, this key MUST be present for sortable properties with value `true`.", ) + type: Optional[DataType] = Field( + None, + description="Data type of value. Must equal a valid OPTIMADE data type as listed and defined under 'Data types'.", + ) + class EntryInfoResource(BaseModel): diff --git a/optimade/models/index_metadb.py b/optimade/models/index_metadb.py index a574ab143..701fda706 100644 --- a/optimade/models/index_metadb.py +++ b/optimade/models/index_metadb.py @@ -1,5 +1,7 @@ # pylint: disable=no-self-argument -from pydantic import Field, BaseModel, validator # pylint: disable=no-name-in-module +from enum import Enum + +from pydantic import Field, BaseModel # pylint: disable=no-name-in-module from typing import Union, Dict from .jsonapi import BaseResource @@ -14,6 +16,12 @@ ) +class DefaultRelationship(Enum): + """Enumeration of key(s) for relationship dictionary in IndexInfoResource""" + + DEFAULT = "default" + + class IndexInfoAttributes(BaseInfoAttributes): """Attributes for Base URL Info endpoint for an Index Meta-Database""" @@ -46,18 +54,10 @@ class IndexInfoResource(BaseInfoResource): """Index Meta-Database Base URL Info endpoint resource""" attributes: IndexInfoAttributes = Field(...) - relationships: Union[None, Dict[str, IndexRelationship]] = Field( + relationships: Union[None, Dict[DefaultRelationship, IndexRelationship]] = Field( ..., description="Reference to the child identifier object under the links endpoint " "that the provider has chosen as their 'default' OPTIMADE API database. " "A client SHOULD present this database as the first choice when an end-user " "chooses this provider.", ) - - @validator("relationships") - def relationships_key_must_be_default(cls, value): - if value is not None and all([key != "default" for key in value]): - raise ValueError( - "if the relationships value is a dict, the key MUST be 'default'" - ) - return value diff --git a/optimade/models/links.py b/optimade/models/links.py index 5b4ef3661..1d48355e1 100644 --- a/optimade/models/links.py +++ b/optimade/models/links.py @@ -1,11 +1,12 @@ # pylint: disable=no-self-argument +from enum import Enum + from pydantic import ( # pylint: disable=no-name-in-module Field, AnyUrl, - validator, root_validator, ) -from typing import Union +from typing import Union, Optional from .jsonapi import Link, Attributes from .entries import EntryResource @@ -17,6 +18,24 @@ ) +class LinkType(Enum): + """Enumeration of link_type values""" + + CHILD = "child" + ROOT = "root" + EXTERNAL = "external" + PROVIDERS = "providers" + + +class Aggregate(Enum): + """Enumeration of aggregate values""" + + OK = "ok" + TEST = "test" + STAGING = "staging" + NO = "no" + + class LinksResourceAttributes(Attributes): """Links endpoint resource object attributes""" @@ -40,18 +59,30 @@ class LinksResourceAttributes(Attributes): description="JSON API links object, pointing to a homepage URL for this implementation", ) - link_type: str = Field( + link_type: LinkType = Field( ..., description="The link type of the represented resource in relation to this implementation. MUST be one of these values: 'child', 'root', 'external', 'providers'.", ) - @validator("link_type") - def link_type_must_be_in_specific_set(cls, value): - if value not in {"child", "root", "external", "providers"}: - raise ValueError( - "link_type MUST be either 'child, 'root', 'external', or 'providers'" - ) - return value + aggregate: Optional[Aggregate] = Field( + "ok", + description="""A string indicating whether a client that is following links to aggregate results from different OPTIMADE implementations should follow this link or not. +This flag SHOULD NOT be indicated for links where :property:`link_type` is not :val:`child`. + +If not specified, clients MAY assume that the value is :val:`ok`. +If specified, and the value is anything different than :val:`ok`, the client MUST assume that the server is suggesting not to follow the link during aggregation by default (also if the value is not among the known ones, in case a future specification adds new accepted values). + +Specific values indicate the reason why the server is providing the suggestion. +A client MAY follow the link anyway if it has reason to do so (e.g., if the client is looking for all test databases, it MAY follow the links marked with :property:`aggregate`=:val:`test`). + +If specified, it MUST be one of the values listed in section Link Aggregate Options.""", + ) + + no_aggregate_reason: Optional[str] = Field( + None, + description="""An OPTIONAL human-readable string indicating the reason for suggesting not to aggregate results following the link. +It SHOULD NOT be present if :property:`aggregate`=:val:`ok`.""", + ) class LinksResource(EntryResource): diff --git a/optimade/models/optimade_json.py b/optimade/models/optimade_json.py index da8013e08..d64bf8d7e 100644 --- a/optimade/models/optimade_json.py +++ b/optimade/models/optimade_json.py @@ -1,5 +1,7 @@ """Modified JSON API v1.0 for OPTIMADE API""" # pylint: disable=no-self-argument,no-name-in-module +from enum import Enum + from pydantic import Field, root_validator, BaseModel, AnyHttpUrl, AnyUrl, EmailStr from typing import Optional, Union, List @@ -9,6 +11,7 @@ __all__ = ( + "DataType", "ResponseMetaQuery", "Provider", "ImplementationMaintainer", @@ -23,6 +26,102 @@ ) +class DataType(Enum): + """Optimade Data Types + + See the section "Data types" in the OPTIMADE API specification for more information. + """ + + STRING = "string" + INTEGER = "integer" + FLOAT = "float" + BOOLEAN = "boolean" + TIMESTAMP = "timestamp" + LIST = "list" + DICTIONARY = "dictionary" + UNKNOWN = "unknown" + + @classmethod + def get_values(cls): + """Get OPTIMADE data types (enum values) as a (sorted) list""" + return sorted((_.value for _ in cls)) + + @classmethod + def from_python_type(cls, python_type: Union[type, str, object]): + """Get OPTIMADE data type from a Python type""" + mapping = { + "bool": cls.BOOLEAN, + "int": cls.INTEGER, + "float": cls.FLOAT, + "complex": None, + "generator": cls.LIST, + "list": cls.LIST, + "tuple": cls.LIST, + "range": cls.LIST, + "hash": cls.INTEGER, + "str": cls.STRING, + "bytes": cls.STRING, + "bytearray": None, + "memoryview": None, + "set": cls.LIST, + "frozenset": cls.LIST, + "dict": cls.DICTIONARY, + "dict_keys": cls.LIST, + "dict_values": cls.LIST, + "dict_items": cls.LIST, + "NoneType": cls.UNKNOWN, + "None": cls.UNKNOWN, + "datetime": cls.TIMESTAMP, + "date": cls.TIMESTAMP, + "time": cls.TIMESTAMP, + "datetime.datetime": cls.TIMESTAMP, + "datetime.date": cls.TIMESTAMP, + "datetime.time": cls.TIMESTAMP, + } + + if isinstance(python_type, type): + python_type = python_type.__name__ + elif isinstance(python_type, object): + if str(python_type) in mapping: + python_type = str(python_type) + else: + python_type = type(python_type).__name__ + + return mapping.get(python_type, None) + + @classmethod + def from_json_type(cls, json_type: str): + """Get OPTIMADE data type from a named JSON type""" + mapping = { + "string": cls.STRING, + "integer": cls.INTEGER, + "number": cls.FLOAT, # actually includes both integer and float + "object": cls.DICTIONARY, + "array": cls.LIST, + "boolean": cls.BOOLEAN, + "null": cls.UNKNOWN, + # OpenAPI "format"s: + "double": cls.FLOAT, + "float": cls.FLOAT, + "int32": cls.INTEGER, + "int64": cls.INTEGER, + "date": cls.TIMESTAMP, + "date-time": cls.TIMESTAMP, + "password": cls.STRING, + "byte": cls.STRING, + "binary": cls.STRING, + # Non-OpenAPI "format"s, but may still be used by pydantic/FastAPI + "email": cls.STRING, + "uuid": cls.STRING, + "uri": cls.STRING, + "hostname": cls.STRING, + "ipv4": cls.STRING, + "ipv6": cls.STRING, + } + + return mapping.get(json_type, None) + + class OptimadeError(jsonapi.Error): """detail MUST be present""" diff --git a/optimade/models/references.py b/optimade/models/references.py index 2e885eeb3..5d07b4807 100644 --- a/optimade/models/references.py +++ b/optimade/models/references.py @@ -1,5 +1,10 @@ # pylint: disable=line-too-long,no-self-argument -from pydantic import Field, BaseModel, AnyUrl, validator +from pydantic import ( # pylint: disable=no-name-in-module + Field, + BaseModel, + AnyUrl, + validator, +) from typing import List, Optional from .entries import EntryResource, EntryResourceAttributes diff --git a/optimade/models/structures.py b/optimade/models/structures.py index 06afbf8ba..4f27ce724 100644 --- a/optimade/models/structures.py +++ b/optimade/models/structures.py @@ -1,9 +1,9 @@ # pylint: disable=no-self-argument,line-too-long,no-name-in-module -from enum import IntEnum +from enum import IntEnum, Enum from sys import float_info from typing import List, Optional, Tuple, Union -from pydantic import Field, BaseModel, validator +from pydantic import Field, BaseModel, validator, root_validator from .entries import EntryResourceAttributes, EntryResource from .utils import CHEMICAL_SYMBOLS, EXTRA_SYMBOLS @@ -12,13 +12,22 @@ EXTENDED_CHEMICAL_SYMBOLS = CHEMICAL_SYMBOLS + EXTRA_SYMBOLS -__all__ = ("Species", "Assembly", "StructureResourceAttributes", "StructureResource") +__all__ = ( + "Vector3D", + "Periodicity", + "StructureFeatures", + "Species", + "Assembly", + "StructureResourceAttributes", + "StructureResource", +) EPS = float_info.epsilon -Vector3D = Tuple[Union[float, None], Union[float, None], Union[float, None]] +Vector3D = Tuple[float, float, float] +Vector3D_unknown = Tuple[Union[float, None], Union[float, None], Union[float, None]] class Periodicity(IntEnum): @@ -26,6 +35,15 @@ class Periodicity(IntEnum): PERIODIC = 1 +class StructureFeatures(Enum): + """Enumeration of structure_features values""" + + DISORDER = "disorder" + IMPLICIT_ATOMS = "implicit_atoms" + SITE_ATTACHMENTS = "site_attachments" + ASSEMBLIES = "assemblies" + + class Species(BaseModel): """A list describing the species of the sites of this structure. Species can be pure chemical elements, or virtual-crystal atoms representing a statistical occupation of a given site by multiple chemical elements. @@ -83,6 +101,16 @@ class Species(BaseModel): The main use of this field is for source databases that use species names, containing characters that are not allowed (see description of the list property `species_at_sites`_).""", ) + attached: Optional[List[str]] = Field( + None, + description="""If provided MUST be a list of length 1 or more of strings of chemical symbols for the elements attached to this site, or "X" for a non-chemical element.""", + ) + + nattached: Optional[List[int]] = Field( + None, + description="""If provided MUST be a list of length 1 or more of integers indicating the number of attached atoms of the kind specified in the value of the :field:`attached` key.""", + ) + @validator("chemical_symbols", each_item=True) def validate_chemical_symbols(cls, v): if not (v in EXTENDED_CHEMICAL_SYMBOLS): @@ -97,6 +125,38 @@ def validate_concentration(cls, v, values): ) return v + @validator("attached", "nattached") + def validate_minimum_list_length(cls, v): + if v is not None and len(v) < 1: + raise ValueError( + f"The list's length MUST be 1 or more, instead it was found to be {len(v)}" + ) + return v + + @root_validator + def attached_nattached_mutually_exclusive(cls, values): + attached, nattached = ( + values.get("attached", None), + values.get("nattached", None), + ) + if (attached is None and nattached is not None) or ( + attached is not None and nattached is None + ): + raise ValueError( + f"Either both or none of attached ({attached}) and nattached ({nattached}) MUST be set." + ) + + if ( + attached is not None + and nattached is not None + and len(attached) != len(nattached) + ): + raise ValueError( + f"attached ({attached}) and nattached ({nattached}) MUST be lists of equal length." + ) + + return values + class Assembly(BaseModel): """A description of groups of sites that are statistically correlated. @@ -338,7 +398,30 @@ class StructureResourceAttributes(EntryResourceAttributes): - For a bulk 3D system: :val:`[1, 1, 1]`""", ) - lattice_vectors: Optional[Tuple[Vector3D, Vector3D, Vector3D]] = Field( + nperiodic_dimensions: Optional[int] = Field( + None, + description="""An integer specifying the number of periodic dimensions in the structure, equivalent to the number of non-zero entries in :property:`dimension_types`. +- **Type**: integer +- **Requirements/Conventions**: + + - **Support**: SHOULD be supported by all implementations, i.e., SHOULD NOT be :val:`null`. + - **Query**: MUST be a queryable property with support for all mandatory filter features. + - The integer value MUST be between 0 and 3 inclusive and MUST be equal to the sum of the items in the `dimension_types`_ property. + - This property only reflects the treatment of the lattice vectors provided for the structure, and not any physical interpretation of the dimensionality of its contents. + +- **Examples**: + + - :val:`2` should be indicated in cases where :property:`dimension_types` is any of :val:`[1, 1, 0]`, :val:`[1, 0, 1]`, :val:`[0, 1, 1]`. + +- **Query examples**: + + - Match only structures with exactly 3 periodic dimensions: :filter:`nperiodic_dimensions=3` + - Match all structures with 2 or fewer periodic dimensions: :filter:`nperiodic_dimensions<=2`""", + ) + + lattice_vectors: Optional[ + Tuple[Vector3D_unknown, Vector3D_unknown, Vector3D_unknown] + ] = Field( None, description="""The three lattice vectors in Cartesian coordinates, in ångström (Å). - **Type**: list of list of floats. @@ -364,7 +447,7 @@ class StructureResourceAttributes(EntryResourceAttributes): cartesian_site_positions: List[Vector3D] = Field( ..., description="""Cartesian positions of each site. A site is an atom, a site potentially occupied by an atom, or a placeholder for a virtual mixture of atoms (e.g., in a virtual crystal approximation). -- **Type**: list of list of floats and/or unknown values +- **Type**: list of list of floats - **Requirements/Conventions**: - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`. @@ -402,27 +485,6 @@ class StructureResourceAttributes(EntryResourceAttributes): - Match structures that have between 2 and 7 sites: :filter:`nsites>=2 AND nsites<=7`""", ) - species_at_sites: List[str] = Field( - ..., - description="""Name of the species at each site (where values for sites are specified with the same order of the property `cartesian_site_positions`_). - The properties of the species are found in the property `species`_. -- **Type**: list of strings. -- **Requirements/Conventions**: - - - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`. - - **Query**: Support for queries on this property is OPTIONAL. If supported, filters MAY support only a subset of comparison operators. - - MUST have length equal to the number of sites in the structure (first dimension of the list property `cartesian_site_positions`_). - - Each species MUST have a unique name. - - Each species name mentioned in the :property:`species_at_sites` list MUST be described in the list property `species`_ (i.e. for each value in the :property:`species_at_sites` list there MUST exist exactly one dictionary in the :property:`species` list with the :property:`name` attribute equal to the corresponding :property:`species_at_sites` value). - - Each site MUST be associated only to a single species. - **Note**: However, species can represent mixtures of atoms, and multiple species MAY be defined for the same chemical element. - This latter case is useful when different atoms of the same type need to be grouped or distinguished, for instance in simulation codes to assign different initial spin states. - -- **Examples**: - - - :val:`["Ti","O2"]` indicates that the first site is hosting a species labeled :val:`"Ti"` and the second a species labeled :val:`"O2"`.""", - ) - species: List[Species] = Field( ..., description="""A list describing the species of the sites of this structure. Species can be pure chemical elements, or virtual-crystal atoms representing a statistical occupation of a given site by multiple chemical elements. @@ -481,6 +543,27 @@ class StructureResourceAttributes(EntryResourceAttributes): - :val:`[ {"name": "C13", "chemical_symbols": ["C"], "concentration": [1.0], "mass": 13.0} ]`: any site with this species is occupied by a carbon isotope with mass 13.""", ) + species_at_sites: List[str] = Field( + ..., + description="""Name of the species at each site (where values for sites are specified with the same order of the property `cartesian_site_positions`_). + The properties of the species are found in the property `species`_. +- **Type**: list of strings. +- **Requirements/Conventions**: + + - **Support**: SHOULD be supported, i.e., SHOULD NOT be :val:`null`. Is REQUIRED in this implementation, i.e., MUST NOT be :val:`null`. + - **Query**: Support for queries on this property is OPTIONAL. If supported, filters MAY support only a subset of comparison operators. + - MUST have length equal to the number of sites in the structure (first dimension of the list property `cartesian_site_positions`_). + - Each species MUST have a unique name. + - Each species name mentioned in the :property:`species_at_sites` list MUST be described in the list property `species`_ (i.e. for each value in the :property:`species_at_sites` list there MUST exist exactly one dictionary in the :property:`species` list with the :property:`name` attribute equal to the corresponding :property:`species_at_sites` value). + - Each site MUST be associated only to a single species. + **Note**: However, species can represent mixtures of atoms, and multiple species MAY be defined for the same chemical element. + This latter case is useful when different atoms of the same type need to be grouped or distinguished, for instance in simulation codes to assign different initial spin states. + +- **Examples**: + + - :val:`["Ti","O2"]` indicates that the first site is hosting a species labeled :val:`"Ti"` and the second a species labeled :val:`"O2"`.""", + ) + assemblies: Optional[List[Assembly]] = Field( None, description="""A description of groups of sites that are statistically correlated. @@ -592,7 +675,7 @@ class StructureResourceAttributes(EntryResourceAttributes): However, the presence or absence of sites 0 and 1 is not correlated with the presence or absence of sites 2 and 3 (in the specific example, the pair of sites (0, 2) can occur with 0.2*0.3 = 6 % probability; the pair (0, 3) with 0.2*0.7 = 14 % probability; the pair (1, 2) with 0.8*0.3 = 24 % probability; and the pair (1, 3) with 0.8*0.7 = 56 % probability).""", ) - structure_features: List[str] = Field( + structure_features: List[StructureFeatures] = Field( ..., description="""A list of strings that flag which special features are used by the structure. - **Type**: list of strings @@ -607,10 +690,9 @@ class StructureResourceAttributes(EntryResourceAttributes): - **List of strings used to indicate special structure features**: - :val:`disorder`: This flag MUST be present if any one entry in the :property:`species` list has a :property:`chemical_symbols` list that is longer than 1 element. - - :val:`unknown_positions`: This flag MUST be present if at least one component of the :property:`cartesian_site_positions` list of lists has value :val:`null`. - :val:`assemblies`: This flag MUST be present if the property `assemblies`_ is present. -- **Examples**: A structure having unknown positions and using assemblies: :val:`["assemblies", "unknown_positions"]`""", +- **Examples**: A structure having implicit atoms and using assemblies: :val:`["assemblies", "implicit_atoms"]`""", ) @validator("elements", each_item=True) @@ -639,6 +721,21 @@ def no_spaces_in_reduced(cls, v): raise ValueError(f"Spaces are not allowed, you passed: {v}") return v + @validator("nperiodic_dimensions") + def check_periodic_dimensions(cls, v, values): + if values.get("dimension_types", []) and v is None: + raise ValueError( + "nperiodic_dimensions is REQUIRED, since dimension_types was provided." + ) + + if v != sum(values.get("dimension_types")): + raise ValueError( + f"nperiodic_dimensions ({v}) does not match expected value of {sum(values['dimension_types'])} " + f"from dimension_types ({values['dimension_types']})" + ) + + return v + @validator("lattice_vectors", always=True) def required_if_dimension_types_has_one(cls, v, values): if ( @@ -686,58 +783,85 @@ def validate_species_at_sites(cls, v, values): raise ValueError( f"Number of species_at_sites (value: {len(v)}) MUST equal number of sites (value: {values.get('nsites', 'Not specified')})" ) + all_species_names = { + getattr(_, "name", None) for _ in values.get("species", [{}]) + } + all_species_names -= {None} + for value in v: + if value not in all_species_names: + raise ValueError( + f"species_at_sites MUST be represented by a species' name, but {value} was not found in the list of species names: {all_species_names}" + ) return v - @validator("species", each_item=True) - def validate_species(cls, v, values): - if v.name not in values.get("species_at_sites", []): + @validator("species") + def validate_species(cls, v): + all_species = [_.name for _ in v] + unique_species = set(all_species) + if len(all_species) != len(unique_species): raise ValueError( - f"{v.name} not found in species_at_sites: {values.get('species_at_sites', 'Not specified')}" + f"Species MUST be unique based on their 'name'. Found species names: {all_species}" ) return v @validator("structure_features", always=True) def validate_structure_features(cls, v, values): - if sorted(v) != v: + if [StructureFeatures(value) for value in sorted((_.value for _ in v))] != v: raise ValueError( f"structure_features MUST be sorted alphabetically, given value: {v}" ) # disorder for species in values.get("species", []): if len(species.chemical_symbols) > 1: - if "disorder" not in v: + if StructureFeatures.DISORDER not in v: raise ValueError( - "disorder MUST be present when any one entry in species has a chemical_symbols list greater than one element" + f"{StructureFeatures.DISORDER.value} MUST be present when any one entry in species has a chemical_symbols list greater than one element" ) break else: - if "disorder" in v: + if StructureFeatures.DISORDER in v: + raise ValueError( + f"{StructureFeatures.DISORDER.value} MUST NOT be present, since all species' chemical_symbols lists are equal to or less than one element" + ) + # assemblies + if values.get("assemblies", None) is not None: + if StructureFeatures.ASSEMBLIES not in v: + raise ValueError( + f"{StructureFeatures.ASSEMBLIES.value} MUST be present, since the property of the same name is present" + ) + else: + if StructureFeatures.ASSEMBLIES in v: raise ValueError( - "disorder MUST NOT be present, since all species' chemical_symbols lists are equal to or less than one element" + f"{StructureFeatures.ASSEMBLIES.value} MUST NOT be present, since the property of the same name is not present" ) - # unknown_positions - for site in values.get("cartesian_site_positions", []): - if None in site or float("nan") in site: - if "unknown_positions" not in v: + # site_attachments + for species in values.get("species", []): + # There is no need to also test "nattached", + # since a Species validator makes sure either both are present or both are None. + if getattr(species, "attached", None) is not None: + if StructureFeatures.SITE_ATTACHMENTS not in v: raise ValueError( - "unknown_positions MUST be present when a single component of cartesian_site_positions has value null" + f"{StructureFeatures.SITE_ATTACHMENTS.value} MUST be present when any one entry in species includes attached and nattached" ) break else: - if "unknown_positions" in v: + if StructureFeatures.SITE_ATTACHMENTS in v: raise ValueError( - "unknown_positions MUST NOT be present, since there are no null values in cartesian_site_positions" - ) - # assemblies - if values.get("assemblies", None) is not None: - if "assemblies" not in v: - raise ValueError( - "assemblies MUST be present, since the property of the same name is present" + f"{StructureFeatures.SITE_ATTACHMENTS.value} MUST NOT be present, since no species includes the attached and nattached fields" ) + # implicit_atoms + species_names = [_.name for _ in values.get("species", [])] + for name in species_names: + if name not in values.get("species_at_sites", []): + if StructureFeatures.IMPLICIT_ATOMS not in v: + raise ValueError( + f"{StructureFeatures.IMPLICIT_ATOMS.value} MUST be present when any one entry in species is not represented in species_at_sites" + ) + break else: - if "assemblies" in v: + if StructureFeatures.IMPLICIT_ATOMS in v: raise ValueError( - "assemblies MUST NOT be present, since the property of the same name is not present" + f"{StructureFeatures.IMPLICIT_ATOMS.value} MUST NOT be present, since all species are represented in species_at_sites" ) return v diff --git a/optimade/server/data/test_structures.json b/optimade/server/data/test_structures.json index ae68ba4d2..65bf537a8 100644 --- a/optimade/server/data/test_structures.json +++ b/optimade/server/data/test_structures.json @@ -16,6 +16,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac" ], @@ -102,6 +103,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "Ag", @@ -213,6 +215,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "Ag", @@ -319,6 +322,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "Mg" @@ -416,6 +420,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "O" @@ -515,6 +520,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "Cu", @@ -616,6 +622,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag" ], @@ -755,6 +762,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "Br", @@ -912,6 +920,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "C", @@ -1155,6 +1164,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "C", @@ -1406,6 +1416,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "C", @@ -1648,6 +1659,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "C", @@ -1885,6 +1897,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "Br", @@ -2386,6 +2399,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "B", @@ -2740,6 +2754,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "C", @@ -3113,6 +3128,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ba", "Ce", @@ -3512,6 +3528,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ba", "F", diff --git a/optimade/server/routers/info.py b/optimade/server/routers/info.py index 12c27006a..84b0202c5 100644 --- a/optimade/server/routers/info.py +++ b/optimade/server/routers/info.py @@ -73,7 +73,7 @@ def get_entry_info(request: Request, entry: str): if entry not in valid_entry_info_endpoints: raise StarletteHTTPException( status_code=404, - detail=f"Entry info not found for {entry}, valid entry info endpoints are: {valid_entry_info_endpoints}", + detail=f"Entry info not found for {entry}, valid entry info endpoints are: {', '.join(valid_entry_info_endpoints)}", ) schema = ENTRY_INFO_SCHEMAS[entry]() diff --git a/optimade/server/routers/utils.py b/optimade/server/routers/utils.py index 31f1ea53d..65a4cd4dd 100644 --- a/optimade/server/routers/utils.py +++ b/optimade/server/routers/utils.py @@ -15,6 +15,7 @@ ToplevelLinks, ReferenceResource, StructureResource, + DataType, ) from optimade.server.config import CONFIG @@ -285,6 +286,10 @@ def retrieve_queryable_properties(schema: dict, queryable_properties: list) -> d # All properties are sortable with the MongoDB backend. # While the result for sorting lists may not be as expected, they are still sorted. properties[name]["sortable"] = True + # Try to get OpenAPI-specific "format" if possible, else get "type"; a mandatory OpenAPI key. + properties[name]["type"] = DataType.from_json_type( + value.get("format", value["type"]) + ) return properties diff --git a/tasks.py b/tasks.py index 6c831d1e1..31bedd931 100644 --- a/tasks.py +++ b/tasks.py @@ -128,9 +128,9 @@ def generate_openapi(_): if not TOP_DIR.joinpath("openapi").exists(): os.mkdir(TOP_DIR.joinpath("openapi")) - with open(TOP_DIR.joinpath("openapi/local_openapi.json"), "w") as handle: - json.dump(app.openapi(), handle, indent=2) - handle.write("\n") # Final empty EOL + with open(TOP_DIR.joinpath("openapi/local_openapi.json"), "w") as f: + json.dump(app.openapi(), f, indent=2) + print("", file=f) # Empty EOL @task @@ -140,9 +140,9 @@ def generate_index_openapi(_): if not TOP_DIR.joinpath("openapi").exists(): os.mkdir(TOP_DIR.joinpath("openapi")) - with open(TOP_DIR.joinpath("openapi/local_index_openapi.json"), "w") as handle: - json.dump(app_index.openapi(), handle, indent=2) - handle.write("\n") # Final empty EOL + with open(TOP_DIR.joinpath("openapi/local_index_openapi.json"), "w") as f: + json.dump(app_index.openapi(), f, indent=2) + print("", file=f) # Empty EOL @task(pre=[generate_openapi, generate_index_openapi]) diff --git a/tests/adapters/structures/conftest.py b/tests/adapters/structures/conftest.py index 72b51434b..a14ede9a4 100644 --- a/tests/adapters/structures/conftest.py +++ b/tests/adapters/structures/conftest.py @@ -42,23 +42,10 @@ def structures(RAW_STRUCTURES) -> List[Structure]: return [Structure(_) for _ in RAW_STRUCTURES] -@pytest.fixture -def null_position_structure(raw_structure) -> Structure: - """Create and return adapters.Structure with sites that have None values""" - raw_structure["attributes"]["cartesian_site_positions"][0] = [None] * 3 - if "structure_features" in raw_structure["attributes"]: - if "unknown_positions" not in raw_structure["attributes"]["structure_features"]: - raw_structure["attributes"]["structure_features"].append( - "unknown_positions" - ) - else: - raw_structure["attributes"]["structure_feature"] = ["unknown_positions"] - return Structure(raw_structure) - - @pytest.fixture def null_lattice_vector_structure(raw_structure) -> Structure: """Create and return adapters.Structure with lattice_vectors that have None values""" raw_structure["attributes"]["lattice_vectors"][0] = [None] * 3 raw_structure["attributes"]["dimension_types"][0] = 0 + raw_structure["attributes"]["nperiodic_dimensions"] = 2 return Structure(raw_structure) diff --git a/tests/adapters/structures/raw_test_structures.json b/tests/adapters/structures/raw_test_structures.json index da57f17fb..082d55e05 100644 --- a/tests/adapters/structures/raw_test_structures.json +++ b/tests/adapters/structures/raw_test_structures.json @@ -19,6 +19,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 1.2503264826932692, @@ -96,6 +97,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 8.79234151692028, @@ -209,6 +211,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 7.698325441636717, @@ -320,6 +323,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 1.2755343366952576, @@ -406,6 +410,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 5.270508031864836, @@ -508,6 +513,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 8.877504144188517, @@ -622,6 +628,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 9.637799319633432, @@ -691,6 +698,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 5.327614134703666, @@ -881,6 +889,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 7.7673999365129625, @@ -1051,6 +1060,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 6.436155587878937, @@ -1296,6 +1306,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 2.9527224047457192, @@ -1551,6 +1562,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 1.2419933831646812, @@ -1810,6 +1822,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 5.132691156529571, @@ -2034,6 +2047,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 5.055370731514176, @@ -2610,6 +2624,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 4.438269887249414, @@ -2909,6 +2924,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 3.6001175409341037, @@ -3309,6 +3325,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 0.541264110585089, diff --git a/tests/adapters/structures/special_species.json b/tests/adapters/structures/special_species.json index b0f6b92a4..e5cfe9f55 100644 --- a/tests/adapters/structures/special_species.json +++ b/tests/adapters/structures/special_species.json @@ -19,6 +19,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 1.2503264826932692, @@ -88,6 +89,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 1.2503264826932692, @@ -157,6 +159,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "lattice_vectors": [ [ 1.2503264826932692, diff --git a/tests/adapters/structures/test_aiida.py b/tests/adapters/structures/test_aiida.py index 856f02b7d..7b1fd5a9e 100644 --- a/tests/adapters/structures/test_aiida.py +++ b/tests/adapters/structures/test_aiida.py @@ -28,11 +28,6 @@ def test_successful_conversion(RAW_STRUCTURES): assert isinstance(get_aiida_structure_data(Structure(structure)), StructureData) -def test_null_positions(null_position_structure): - """Make sure null positions are handled""" - assert isinstance(get_aiida_structure_data(null_position_structure), StructureData) - - def test_null_lattice_vectors(null_lattice_vector_structure): """Make sure null lattice vectors are handled""" assert isinstance( diff --git a/tests/adapters/structures/test_ase.py b/tests/adapters/structures/test_ase.py index b5a9b1ee1..13ed014d0 100644 --- a/tests/adapters/structures/test_ase.py +++ b/tests/adapters/structures/test_ase.py @@ -24,11 +24,6 @@ def test_successful_conversion(RAW_STRUCTURES): assert isinstance(get_ase_atoms(Structure(structure)), Atoms) -def test_null_positions(null_position_structure): - """Make sure null positions are handled""" - assert isinstance(get_ase_atoms(null_position_structure), Atoms) - - def test_null_lattice_vectors(null_lattice_vector_structure): """Make sure null lattice vectors are handled""" assert isinstance(get_ase_atoms(null_lattice_vector_structure), Atoms) diff --git a/tests/adapters/structures/test_cif.py b/tests/adapters/structures/test_cif.py index 59052e803..60b4bcb7a 100644 --- a/tests/adapters/structures/test_cif.py +++ b/tests/adapters/structures/test_cif.py @@ -22,11 +22,6 @@ def test_successful_conversion(RAW_STRUCTURES): assert isinstance(get_cif(Structure(structure)), str) -def test_null_positions(null_position_structure): - """Make sure null positions are handled""" - assert isinstance(get_cif(null_position_structure), str) - - def test_null_lattice_vectors(null_lattice_vector_structure): """Make sure null lattice vectors are handled""" assert isinstance(get_cif(null_lattice_vector_structure), str) diff --git a/tests/adapters/structures/test_jarvis.py b/tests/adapters/structures/test_jarvis.py index 929ba425d..d5055756c 100644 --- a/tests/adapters/structures/test_jarvis.py +++ b/tests/adapters/structures/test_jarvis.py @@ -23,11 +23,6 @@ def test_successful_conversion(RAW_STRUCTURES): assert isinstance(get_jarvis_atoms(Structure(structure)), Atoms) -def test_null_positions(null_position_structure): - """Make sure null positions are handled""" - assert isinstance(get_jarvis_atoms(null_position_structure), Atoms) - - def test_null_lattice_vectors(null_lattice_vector_structure): """Make sure null lattice vectors are handled""" assert isinstance(get_jarvis_atoms(null_lattice_vector_structure), Atoms) diff --git a/tests/adapters/structures/test_pdb.py b/tests/adapters/structures/test_pdb.py index 3cac3c503..9920488d8 100644 --- a/tests/adapters/structures/test_pdb.py +++ b/tests/adapters/structures/test_pdb.py @@ -21,11 +21,6 @@ def test_successful_conversion(RAW_STRUCTURES): assert isinstance(get_pdb(Structure(structure)), str) -def test_null_positions(null_position_structure): - """Make sure null positions are handled""" - assert isinstance(get_pdb(null_position_structure), str) - - def test_null_lattice_vectors(null_lattice_vector_structure): """Make sure null lattice vectors are handled""" assert isinstance(get_pdb(null_lattice_vector_structure), str) diff --git a/tests/adapters/structures/test_pdbx_mmcif.py b/tests/adapters/structures/test_pdbx_mmcif.py index 5dc8180b6..bce2ace4f 100644 --- a/tests/adapters/structures/test_pdbx_mmcif.py +++ b/tests/adapters/structures/test_pdbx_mmcif.py @@ -21,11 +21,6 @@ def test_successful_conversion(RAW_STRUCTURES): assert isinstance(get_pdbx_mmcif(Structure(structure)), str) -def test_null_positions(null_position_structure): - """Make sure null positions are handled""" - assert isinstance(get_pdbx_mmcif(null_position_structure), str) - - def test_null_lattice_vectors(null_lattice_vector_structure): """Make sure null lattice vectors are handled""" assert isinstance(get_pdbx_mmcif(null_lattice_vector_structure), str) diff --git a/tests/adapters/structures/test_pymatgen.py b/tests/adapters/structures/test_pymatgen.py index d4119fa48..9b349e182 100644 --- a/tests/adapters/structures/test_pymatgen.py +++ b/tests/adapters/structures/test_pymatgen.py @@ -44,13 +44,6 @@ def test_null_lattice_vectors(null_lattice_vector_structure): assert isinstance(get_pymatgen(null_lattice_vector_structure), Molecule) -def test_null_positions(null_position_structure): - """Make sure null positions are handled""" - assert isinstance(get_pymatgen(null_position_structure), PymatgenStructure) - assert isinstance(_get_structure(null_position_structure), PymatgenStructure) - assert isinstance(_get_molecule(null_position_structure), Molecule) - - def test_special_species(SPECIAL_SPECIES_STRUCTURES): """Make sure vacancies and non-chemical symbols ("X") are handled""" for special_structure in SPECIAL_SPECIES_STRUCTURES: diff --git a/tests/adapters/structures/test_utils.py b/tests/adapters/structures/test_utils.py index 7575aa244..e4b3c2a59 100644 --- a/tests/adapters/structures/test_utils.py +++ b/tests/adapters/structures/test_utils.py @@ -15,7 +15,6 @@ from optimade.adapters.structures.utils import ( fractional_coordinates, pad_cell, - pad_positions, scaled_cell, ) @@ -23,21 +22,6 @@ # TODO: Add tests for cell_to_cellpar, unit_vector, cellpar_to_cell -def test_pad_positions(null_position_structure): - """Make sure None values in cartesian_site_positions are converted to padding float value""" - positions, padded_position = pad_positions( - null_position_structure.attributes.cartesian_site_positions - ) - - assert not any(value is None for vector in positions for value in vector) - assert padded_position - - positions, padded_position = pad_positions(positions) - - assert not any(value is None for vector in positions for value in vector) - assert not padded_position - - def test_pad_cell(null_lattice_vector_structure): """Make sure None values in lattice_vectors are converted to padding float value""" lattice_vectors, padded_cell = pad_cell( diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/models/conftest.py b/tests/models/conftest.py new file mode 100644 index 000000000..cb964f4be --- /dev/null +++ b/tests/models/conftest.py @@ -0,0 +1,65 @@ +import pytest + + +def load_test_data(filename: str) -> list: + """Utility function to load JSON files from 'test_data'""" + import json + from pathlib import Path + + json_file_path = ( + Path(__file__).parent.joinpath("test_data").joinpath(filename).resolve() + ) + if not json_file_path.exists(): + raise RuntimeError(f"Could not find {filename!r} in 'tests.models.test_data'") + + with open(json_file_path, "r") as handle: + data = json.load(handle) + + return data + + +def remove_mongo_date(resources: list) -> list: + """Utility function to remove and flatten nested $date properties""" + res = [] + for document in resources: + updated_document = document.copy() + for field, value in document.items(): + if isinstance(value, dict) and "$date" in value: + updated_document.update({field: value["$date"]}) + res.append(updated_document) + del updated_document + return res + + +@pytest.fixture(scope="session") +def bad_structures() -> list: + """Load and return list of bad structures resources""" + filename = "test_bad_structures.json" + structures = load_test_data(filename) + structures = remove_mongo_date(structures) + return structures + + +@pytest.fixture(scope="session") +def good_structures() -> list: + """Load and return list of good structures resources""" + filename = "test_good_structures.json" + structures = load_test_data(filename) + structures = remove_mongo_date(structures) + return structures + + +@pytest.fixture +def starting_links() -> dict: + """A good starting links resource""" + return { + "id": "test", + "type": "links", + "name": "Test", + "description": "This is a test", + "base_url": "https://example.org/optimade", + "homepage": "https://example.org", + "link_type": "child", + "aggregate": "test", + "no_aggregate_reason": "This is a test database", + } diff --git a/tests/models/test_baseinfo.py b/tests/models/test_baseinfo.py new file mode 100644 index 000000000..6e1e2ca89 --- /dev/null +++ b/tests/models/test_baseinfo.py @@ -0,0 +1,42 @@ +import pytest + +from optimade.models.baseinfo import AvailableApiVersion + + +def test_available_api_versions(): + """Check version formatting for available_api_versions""" + + bad_urls = [ + {"url": "asfdsafhttps://example.com/v0.0", "version": "0.0.0"}, + {"url": "https://example.com/optimade", "version": "1.0.0"}, + {"url": "https://example.com/v0999", "version": "0999.0.0"}, + {"url": "http://example.com/v2.3", "version": "2.3.0"}, + ] + good_urls = [ + {"url": "https://example.com/v0", "version": "0.1.9"}, + {"url": "https://example.com/v1.0.2", "version": "1.0.2"}, + {"url": "https://example.com/optimade/v1.2", "version": "1.2.3"}, + ] + bad_combos = [ + {"url": "https://example.com/v0", "version": "1.0.0"}, + {"url": "https://example.com/v1.0.2", "version": "1.0.3"}, + {"url": "https://example.com/optimade/v1.2", "version": "1.3.2"}, + ] + + for data in bad_urls: + with pytest.raises(ValueError) as exc: + AvailableApiVersion(**data) + assert ( + "url MUST be a versioned base URL" in exc.exconly() + or "URL scheme not permitted" in exc.exconly() + ), f"Validator 'url_must_be_versioned_base_url' not triggered as it should.\nException message: {exc.exconly()}.\nInputs: {data}" + + for data in bad_combos: + with pytest.raises(ValueError) as exc: + AvailableApiVersion(**data) + assert "is not compatible with url version" in exc.exconly(), ( + f"Validator 'crosscheck_url_and_version' not triggered as it should.\nException message: {exc.exconly()}.\nInputs: {data}", + ) + + for data in good_urls: + assert isinstance(AvailableApiVersion(**data), AvailableApiVersion) diff --git a/tests/models/test_bad_structures.json b/tests/models/test_data/test_bad_structures.json similarity index 76% rename from tests/models/test_bad_structures.json rename to tests/models/test_data/test_bad_structures.json index 28575c9b7..126976591 100644 --- a/tests/models/test_bad_structures.json +++ b/tests/models/test_data/test_bad_structures.json @@ -16,6 +16,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Acc" ], @@ -94,6 +95,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "Ag", @@ -193,6 +195,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "Ag", @@ -292,6 +295,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "Mg" @@ -389,6 +393,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "O" @@ -488,6 +493,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac", "Cu", @@ -590,6 +596,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag" ], @@ -729,6 +736,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "Cl", @@ -886,6 +894,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "C", @@ -1129,6 +1138,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "C", @@ -1240,7 +1250,7 @@ "N", "N" ], - "structure_features": ["unknown_positions"], + "structure_features": ["implicit_atoms"], "task_id": "mpf_272" }, { @@ -1380,6 +1390,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ag", "C", @@ -1516,6 +1527,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac" ], @@ -1584,6 +1596,7 @@ 1, 1 ], + "nperiodic_dimensions": 3, "elements": [ "Ac" ], @@ -1648,6 +1661,7 @@ 1, 0 ], + "nperiodic_dimensions": 3, "elements": [ "Ac" ], @@ -1710,6 +1724,7 @@ "pretty_formula": "GeSi", "formula_anonymous": "AB", "dimension_types": [1, 1, 1], + "nperiodic_dimensions": 3, "lattice_vectors": [[4.0,0.0,0.0],[0.0,4.0,0.0],[0.0,1.0,4.0]], "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0] ], "species": [ @@ -1741,6 +1756,7 @@ "pretty_formula": "GeSi", "formula_anonymous": "AB", "dimension_types": [1, 1, 1], + "nperiodic_dimensions": 3, "lattice_vectors": [[4.0,0.0,0.0],[0.0,4.0,0.0],[0.0,1.0,4.0]], "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0,0] ], "species": [ @@ -1772,6 +1788,7 @@ "pretty_formula": "GeSi", "formula_anonymous": "AB", "dimension_types": [1, 1, 1], + "nperiodic_dimensions": 3, "lattice_vectors": [[4.0,0.0,0.0],[0.0,4.0,0.0],[0.0,1.0]], "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0] ], "species": [ @@ -1803,6 +1820,7 @@ "pretty_formula": "GeSi", "formula_anonymous": "AB", "dimension_types": [1, 1, 1], + "nperiodic_dimensions": 3, "lattice_vectors": [[4.0,0.0,0.0],[0.0,4.0,0.0],[0.0,1.0,4.0,5.0]], "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0] ], "species": [ @@ -1823,11 +1841,7 @@ "_id": { "$oid": "5cfb441f053b174410700d02" }, - "type": "structure", - "links": ["C-H"], - "relationships": "single", - "id": "some_id", - "_mp_chemsys": "Ac", + "chemsys": "Ac", "cartesian_site_positions": [ [ 0.17570227444196573, @@ -1840,6 +1854,7 @@ 0, 1 ], + "nperiodic_dimensions": 2, "elements": [ "Ac" ], @@ -1886,5 +1901,507 @@ ], "structure_features": [], "task_id": "mpf_1" + }, + { + "_id": { + "$oid": "5cfb441f053b174410700d02" + }, + "chemsys": "Ac", + "cartesian_site_positions": [ + [ + 0.17570227444196573, + 0.17570227444196573, + 0.17570227444196573 + ] + ], + "dimension_types": [ + 1, + 0, + 0 + ], + "nperiodic_dimensions": 3, + "elements": [ + "Ac" + ], + "elements_ratios": [ + 1.0 + ], + "formula_anonymous": "A", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "lattice_vectors": [ + [ + 1.2503264826932692, + 0, + 0 + ], + [ + 0, + 9.888509716321765, + 0 + ], + [ + 0, + 0, + 0.2972637673241818 + ] + ], + "nelements": 1, + "nsites": 1, + "pretty_formula": "Ac", + "species": [ + { + "chemical_symbols": [ + "Ac" + ], + "concentration": [ + 1.0 + ], + "name": "Ac" + } + ], + "species_at_sites": [ + "Ac" + ], + "structure_features": [], + "task_id": "mpf_1" + }, + { + "_id": { + "$oid": "5cfb441f053b174410700d02" + }, + "chemsys": "Ac", + "cartesian_site_positions": [ + [ + 0.17570227444196573, + 0.17570227444196573, + null + ] + ], + "dimension_types": [ + 1, + 1, + 1 + ], + "nperiodic_dimensions": 3, + "elements": [ + "Ac" + ], + "elements_ratios": [ + 1.0 + ], + "formula_anonymous": "A", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "lattice_vectors": [ + [ + 1.2503264826932692, + 0, + 0 + ], + [ + 0, + 9.888509716321765, + 0 + ], + [ + 0, + 0, + 0.2972637673241818 + ] + ], + "nelements": 1, + "nsites": 1, + "pretty_formula": "Ac", + "species": [ + { + "chemical_symbols": [ + "Ac" + ], + "concentration": [ + 1.0 + ], + "name": "Ac" + } + ], + "species_at_sites": [ + "Ac" + ], + "structure_features": [], + "task_id": "mpf_1", + "relationships": { + "references": { + "data": [ + {"type": "references", "id": "dijkstra1968"} + ] + } + } + }, + { + "_id": { + "$oid": "5cfb441f053b174410700d02" + }, + "chemsys": "Ac", + "cartesian_site_positions": [ + [ + 0.17570227444196573, + 0.17570227444196573, + 0.17570227444196573 + ] + ], + "dimension_types": [ + 1, + 1, + 1 + ], + "nperiodic_dimensions": 3, + "elements": [ + "Ac" + ], + "elements_ratios": [ + 1.0 + ], + "formula_anonymous": "A", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "lattice_vectors": [ + [ + 1.2503264826932692, + 0, + 0 + ], + [ + 0, + 9.888509716321765, + 0 + ], + [ + 0, + 0, + 0.2972637673241818 + ] + ], + "nelements": 1, + "nsites": 1, + "pretty_formula": "Ac", + "species": [ + { + "chemical_symbols": [ + "Ac" + ], + "concentration": [ + 1.0 + ], + "name": "Ac" + } + ], + "species_at_sites": [ + "Ac" + ], + "structure_features": ["site_attachments"], + "task_id": "mpf_1", + "relationships": { + "references": { + "data": [ + {"type": "references", "id": "dijkstra1968"} + ] + } + } + }, + { + "_id": { + "$oid": "5cfb441f053b174410700d02" + }, + "chemsys": "Ac", + "cartesian_site_positions": [ + [ + 0.17570227444196573, + 0.17570227444196573, + 0.17570227444196573 + ] + ], + "dimension_types": [ + 1, + 1, + 1 + ], + "nperiodic_dimensions": 3, + "elements": [ + "Ac" + ], + "elements_ratios": [ + 1.0 + ], + "formula_anonymous": "A", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "lattice_vectors": [ + [ + 1.2503264826932692, + 0, + 0 + ], + [ + 0, + 9.888509716321765, + 0 + ], + [ + 0, + 0, + 0.2972637673241818 + ] + ], + "nelements": 1, + "nsites": 1, + "pretty_formula": "Ac", + "species": [ + { + "chemical_symbols": [ + "Ac" + ], + "concentration": [ + 1.0 + ], + "name": "Ac", + "attached": ["H"], + "nattached": [3, 1] + } + ], + "species_at_sites": [ + "Ac" + ], + "structure_features": ["site_attachments"], + "task_id": "mpf_1", + "relationships": { + "references": { + "data": [ + {"type": "references", "id": "dijkstra1968"} + ] + } + } + }, + { + "_id": { + "$oid": "5cfb441f053b174410700d02" + }, + "chemsys": "Ac", + "cartesian_site_positions": [ + [ + 0.17570227444196573, + 0.17570227444196573, + 0.17570227444196573 + ] + ], + "dimension_types": [ + 1, + 1, + 1 + ], + "nperiodic_dimensions": 3, + "elements": [ + "Ac" + ], + "elements_ratios": [ + 1.0 + ], + "formula_anonymous": "A", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "lattice_vectors": [ + [ + 1.2503264826932692, + 0, + 0 + ], + [ + 0, + 9.888509716321765, + 0 + ], + [ + 0, + 0, + 0.2972637673241818 + ] + ], + "nelements": 1, + "nsites": 1, + "pretty_formula": "Ac", + "species": [ + { + "chemical_symbols": [ + "Ac" + ], + "concentration": [ + 1.0 + ], + "name": "Ac", + "attached": ["H"], + "nattached": [4] + } + ], + "species_at_sites": [ + "Ac" + ], + "structure_features": [], + "task_id": "mpf_1", + "relationships": { + "references": { + "data": [ + {"type": "references", "id": "dijkstra1968"} + ] + } + } + }, + { + "_id": { + "$oid": "5cfb441f053b174410700d02" + }, + "chemsys": "Ac", + "cartesian_site_positions": [ + [ + 0.17570227444196573, + 0.17570227444196573, + 0.17570227444196573 + ] + ], + "dimension_types": [ + 1, + 1, + 1 + ], + "nperiodic_dimensions": 3, + "elements": [ + "Ac", "Ag" + ], + "elements_ratios": [ + 0.5, 0.5 + ], + "formula_anonymous": "AB", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "lattice_vectors": [ + [ + 1.2503264826932692, + 0, + 0 + ], + [ + 0, + 9.888509716321765, + 0 + ], + [ + 0, + 0, + 0.2972637673241818 + ] + ], + "nelements": 2, + "nsites": 1, + "pretty_formula": "AcAg", + "species": [ + { + "chemical_symbols": [ + "Ac" + ], + "concentration": [ + 1.0 + ], + "name": "Ac" + }, + { + "chemical_symbols": [ + "Ag" + ], + "concentration": [ + 1.0 + ], + "name": "Ag" + } + ], + "species_at_sites": [ + "Ac" + ], + "structure_features": [], + "task_id": "mpf_1", + "relationships": { + "references": { + "data": [ + {"type": "references", "id": "dijkstra1968"} + ] + } + } + }, + { + "task_id": "db/1234567", + "type": "structure", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "band_gap": 1.23456, + "chemsys": "Ge-Si", + "elements": ["Ge", "Si"], + "nsites": 3, + "nelements": 2, + "elements_ratios": [0.5, 0.5], + "pretty_formula": "GeSi", + "formula_anonymous": "AB", + "dimension_types": [1, 1, 1], + "nperiodic_dimensions": 3, + "lattice_vectors": [[4.0,0.0,0.0],[0.0,4.0,0.0],[0.0,1.0,4.0]], + "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0] ], + "species": [ + {"name": "Si", "chemical_symbols": ["Si"], "concentration": [1.0] }, + {"name": "Ge", "chemical_symbols": ["Ge"], "concentration": [1.0] }, + {"name": "vac", "chemical_symbols": ["vacancy"], "concentration": [1.0] } + ], + "species_at_sites": ["Si", "Ge", "vac"], + "assemblies": [ + { + "sites_in_groups": [ [0], [1], [2] ], + "group_probabilities": [0.3, 0.5, 0.2] + } + ], + "structure_features": [] + }, + { + "task_id": "db/1234567", + "type": "structure", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "band_gap": 1.23456, + "chemsys": "Ge-Si", + "elements": ["Ge", "Si"], + "nsites": 3, + "nelements": 2, + "elements_ratios": [0.5, 0.5], + "pretty_formula": "GeSi", + "formula_anonymous": "AB", + "dimension_types": [1, 1, 1], + "nperiodic_dimensions": null, + "lattice_vectors": [[4.0,0.0,0.0],[0.0,4.0,0.0],[0.0,1.0,4.0]], + "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0] ], + "species": [ + {"name": "Si", "chemical_symbols": ["Si"], "concentration": [1.0] }, + {"name": "Ge", "chemical_symbols": ["Ge"], "concentration": [1.0] }, + {"name": "vac", "chemical_symbols": ["vacancy"], "concentration": [1.0] } + ], + "species_at_sites": ["Si", "Ge", "vac"], + "assemblies": [ + { + "sites_in_groups": [ [0], [1], [2] ], + "group_probabilities": [0.3, 0.5, 0.2] + } + ], + "structure_features": ["assemblies"] } ] diff --git a/tests/models/test_more_structures.json b/tests/models/test_data/test_good_structures.json similarity index 55% rename from tests/models/test_more_structures.json rename to tests/models/test_data/test_good_structures.json index cb45f4323..a44bb018b 100644 --- a/tests/models/test_more_structures.json +++ b/tests/models/test_data/test_good_structures.json @@ -14,6 +14,7 @@ "pretty_formula": "GeSi", "formula_anonymous": "AB", "dimension_types": [1, 1, 1], + "nperiodic_dimensions": 3, "lattice_vectors": [[4.0,0.0,0.0],[0.0,4.0,0.0],[0.0,1.0,4.0]], "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0] ], "species": [ @@ -45,6 +46,7 @@ "pretty_formula": "GeSi", "formula_anonymous": "AB", "dimension_types": [1, 0, 1], + "nperiodic_dimensions": 2, "lattice_vectors": [[4.0,0.0,0.0],[null, null, null],[0.0,1.0,4.0]], "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0] ], "species_at_sites": ["Si", "Ge", "vac"], @@ -76,8 +78,9 @@ "pretty_formula": "GeSi", "formula_anonymous": "AB", "dimension_types": [1, 0, 1], - "lattice_vectors": [[4.0,0.0,0.0],[null, null, null],[0.0,1.0,4.0]], - "cartesian_site_positions": [ [0,null,0], [0,0,0], [0,"nan",0] ], + "nperiodic_dimensions": 2, + "lattice_vectors": [[4.0,0.0,0.0],[0.0, 4.0, 0.0],[0.0,1.0,4.0]], + "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0] ], "species_at_sites": ["Si", "Ge", "vac"], "species": [ {"name": "Si", "chemical_symbols": ["Si"], "concentration": [1.0] }, @@ -90,6 +93,70 @@ "group_probabilities": [0.3, 0.5, 0.2] } ], - "structure_features": ["assemblies", "unknown_positions"] + "structure_features": ["assemblies"] + }, + { + "task_id": "db/1234567", + "type": "structure", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "band_gap": 1.23456, + "chemsys": "Ge-Si", + "elements": ["Ge", "Si"], + "nelements": 2, + "nsites": 3, + "elements_ratios": [0.5, 0.5], + "pretty_formula": "GeSi", + "formula_anonymous": "AB", + "dimension_types": [1, 0, 1], + "nperiodic_dimensions": 2, + "lattice_vectors": [[4.0,0.0,0.0],[0.0, 4.0, 0.0],[0.0,1.0,4.0]], + "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0] ], + "species_at_sites": ["Si", "Ge", "Ge"], + "species": [ + {"name": "Si", "chemical_symbols": ["Si"], "concentration": [1.0] }, + {"name": "Ge", "chemical_symbols": ["Ge"], "concentration": [1.0] }, + {"name": "vac", "chemical_symbols": ["vacancy"], "concentration": [1.0] } + ], + "assemblies": [ + { + "sites_in_groups": [ [0], [1], [2] ], + "group_probabilities": [0.3, 0.5, 0.2] + } + ], + "structure_features": ["assemblies", "implicit_atoms"] + }, + { + "task_id": "db/1234567", + "type": "structure", + "last_modified": { + "$date": "2019-06-08T05:13:37.331Z" + }, + "band_gap": 1.23456, + "chemsys": "Ge-Si", + "elements": ["Ge", "Si"], + "nelements": 2, + "nsites": 3, + "elements_ratios": [0.5, 0.5], + "pretty_formula": "GeSi", + "formula_anonymous": "AB", + "dimension_types": [1, 0, 1], + "nperiodic_dimensions": 2, + "lattice_vectors": [[4.0,0.0,0.0],[null, null, null],[0.0,1.0,4.0]], + "cartesian_site_positions": [ [0,0,0], [0,0,0], [0,0,0] ], + "species_at_sites": ["Si", "Ge", "vac"], + "species": [ + {"name": "Si", "chemical_symbols": ["Si"], "concentration": [1.0], "attached": ["O"], "nattached": [4] }, + {"name": "Ge", "chemical_symbols": ["Ge"], "concentration": [1.0] }, + {"name": "vac", "chemical_symbols": ["vacancy"], "concentration": [1.0] } + ], + "assemblies": [ + { + "sites_in_groups": [ [0], [1], [2] ], + "group_probabilities": [0.3, 0.5, 0.2] + } + ], + "structure_features": ["assemblies", "site_attachments"] } ] diff --git a/tests/models/test_entries.py b/tests/models/test_entries.py new file mode 100644 index 000000000..ff5f28b1e --- /dev/null +++ b/tests/models/test_entries.py @@ -0,0 +1,51 @@ +import pytest + +from pydantic import ValidationError + +from optimade.models.entries import EntryRelationships + + +def test_simple_relationships(): + """Make sure relationship resources are added to the correct relationship""" + + good_relationships = ( + {"references": {"data": [{"id": "dijkstra1968", "type": "references"}]}}, + {"structures": {"data": [{"id": "dijkstra1968", "type": "structures"}]}}, + ) + for relationship in good_relationships: + EntryRelationships(**relationship) + + bad_relationships = ( + {"references": {"data": [{"id": "dijkstra1968", "type": "structures"}]}}, + {"structures": {"data": [{"id": "dijkstra1968", "type": "references"}]}}, + ) + for relationship in bad_relationships: + with pytest.raises(ValidationError): + EntryRelationships(**relationship) + + +def test_advanced_relationships(): + """Make sure the rules for the base resource 'meta' field are upheld""" + + relationship = { + "references": { + "data": [ + { + "id": "dijkstra1968", + "type": "references", + "meta": { + "description": "Reference for the search algorithm Dijkstra." + }, + } + ] + } + } + EntryRelationships(**relationship) + + relationship = { + "references": { + "data": [{"id": "dijkstra1968", "type": "references", "meta": {}}] + } + } + with pytest.raises(ValidationError): + EntryRelationships(**relationship) diff --git a/tests/models/test_jsonapi.py b/tests/models/test_jsonapi.py new file mode 100644 index 000000000..a63f5dc30 --- /dev/null +++ b/tests/models/test_jsonapi.py @@ -0,0 +1,6 @@ +from optimade.models.jsonapi import Error + + +def test_hashability(): + error = Error(id="test") + assert set([error]) diff --git a/tests/models/test_links.py b/tests/models/test_links.py new file mode 100644 index 000000000..e42c39035 --- /dev/null +++ b/tests/models/test_links.py @@ -0,0 +1,38 @@ +# pylint: disable=no-member +import pytest + +from optimade.models.links import LinksResource +from optimade.server.mappers import LinksMapper + + +def test_good_links(starting_links): + """Check well-formed links used as example data""" + import optimade.server.data + + good_refs = optimade.server.data.links + for doc in good_refs: + LinksResource(**LinksMapper.map_back(doc)) + + # Test starting_links is a good links resource + LinksResource(**LinksMapper.map_back(starting_links)) + + +def test_bad_links(starting_links): + """Check badly formed links""" + from pydantic import ValidationError + + bad_links = [ + {"aggregate": "wrong"}, + {"link_type": "wrong"}, + {"base_url": "example.org"}, + {"homepage": "www.example.org"}, + {"relationships": {}}, + ] + + for index, links in enumerate(bad_links): + print(f"Now testing number {index}") + bad_link = starting_links.copy() + bad_link.update(links) + with pytest.raises(ValidationError): + LinksResource(**LinksMapper.map_back(bad_link)) + del bad_link diff --git a/tests/models/test_models.py b/tests/models/test_models.py deleted file mode 100644 index d2c627774..000000000 --- a/tests/models/test_models.py +++ /dev/null @@ -1,159 +0,0 @@ -from pathlib import Path - -import pytest -import unittest -import json - -from pydantic import ValidationError # pylint: disable=no-name-in-module -from optimade.models import ( - StructureResource, - EntryRelationships, - ReferenceResource, - AvailableApiVersion, -) -from optimade.models.jsonapi import Error -from optimade.server.mappers import StructureMapper, ReferenceMapper -import optimade.server.data - - -class TestPydanticValidation(unittest.TestCase): - def test_good_structures(self): - - good_structures = optimade.server.data.structures - - for structure in good_structures: - StructureResource(**StructureMapper.map_back(structure)) - - def test_more_good_structures(self): - good_structures = optimade.server.data.structures - - for structure in good_structures: - StructureResource(**StructureMapper.map_back(structure)) - - def test_bad_structures(self): - test_structures_path = ( - Path(__file__).resolve().parent.joinpath("test_bad_structures.json") - ) - with open(test_structures_path, "r") as f: - bad_structures = json.load(f) - for doc in bad_structures: - doc["last_modified"] = doc["last_modified"]["$date"] - - for ind, structure in enumerate(bad_structures): - with self.assertRaises( - ValidationError, - msg="Bad test structure {} failed to raise an error\nContents: {}".format( - ind, json.dumps(structure, indent=2) - ), - ): - StructureResource(**StructureMapper.map_back(structure)) - - def test_simple_relationships(self): - """Make sure relationship resources are added to the correct relationship""" - - good_relationships = ( - {"references": {"data": [{"id": "dijkstra1968", "type": "references"}]}}, - {"structures": {"data": [{"id": "dijkstra1968", "type": "structures"}]}}, - ) - for relationship in good_relationships: - EntryRelationships(**relationship) - - bad_relationships = ( - {"references": {"data": [{"id": "dijkstra1968", "type": "structures"}]}}, - {"structures": {"data": [{"id": "dijkstra1968", "type": "references"}]}}, - ) - for relationship in bad_relationships: - with self.assertRaises(ValidationError): - EntryRelationships(**relationship) - - def test_advanced_relationships(self): - """Make sure the rules for the base resource 'meta' field are upheld""" - - relationship = { - "references": { - "data": [ - { - "id": "dijkstra1968", - "type": "references", - "meta": { - "description": "Reference for the search algorithm Dijkstra." - }, - } - ] - } - } - EntryRelationships(**relationship) - - relationship = { - "references": { - "data": [{"id": "dijkstra1968", "type": "references", "meta": {}}] - } - } - with self.assertRaises(ValidationError): - EntryRelationships(**relationship) - - def test_good_references(self): - good_refs = optimade.server.data.references - for doc in good_refs: - ReferenceResource(**ReferenceMapper.map_back(doc)) - - def test_bad_references(self): - bad_refs = [ - {"id": "AAAA", "type": "references", "doi": "10.1234/1234"}, # bad id - {"id": "newton1687", "type": "references"}, # missing all fields - { - "id": "newton1687", - "type": "reference", - "doi": "10.1234/1234", - }, # wrong type - ] - - for ref in bad_refs: - with self.assertRaises(ValidationError): - ReferenceResource(**ReferenceMapper.map_back(ref)) - - def test_available_api_versions(self): - bad_urls = [ - {"url": "asfdsafhttps://example.com/v0.0", "version": "0.0.0"}, - {"url": "https://example.com/optimade", "version": "1.0.0"}, - {"url": "https://example.com/v0999", "version": "0999.0.0"}, - {"url": "http://example.com/v2.3", "version": "2.3.0"}, - ] - good_urls = [ - {"url": "https://example.com/v0", "version": "0.1.9"}, - {"url": "https://example.com/v1.0.2", "version": "1.0.2"}, - {"url": "https://example.com/optimade/v1.2", "version": "1.2.3"}, - ] - bad_combos = [ - {"url": "https://example.com/v0", "version": "1.0.0"}, - {"url": "https://example.com/v1.0.2", "version": "1.0.3"}, - {"url": "https://example.com/optimade/v1.2", "version": "1.3.2"}, - ] - - for data in bad_urls: - with pytest.raises(ValueError) as exc: - AvailableApiVersion(**data) - pytest.fail(f"Url {data['url']} should have failed") - self.assertTrue( - "url MUST be a versioned base URL" in exc.exconly() - or "URL scheme not permitted" in exc.exconly(), - msg=f"Validator 'url_must_be_versioned_base_url' not triggered as it should.\nException message: {exc.exconly()}.\nInputs: {data}", - ) - - for data in bad_combos: - with pytest.raises(ValueError) as exc: - AvailableApiVersion(**data) - pytest.fail( - f"{data['url']} should have failed with version {data['version']}" - ) - self.assertTrue( - "is not compatible with url version" in exc.exconly(), - msg=f"Validator 'crosscheck_url_and_version' not triggered as it should.\nException message: {exc.exconly()}.\nInputs: {data}", - ) - - for data in good_urls: - self.assertIsInstance(AvailableApiVersion(**data), AvailableApiVersion) - - def test_hashability(self): - error = Error(id="test") - set([error]) diff --git a/tests/models/test_optimade_json.py b/tests/models/test_optimade_json.py new file mode 100644 index 000000000..548ae1897 --- /dev/null +++ b/tests/models/test_optimade_json.py @@ -0,0 +1,88 @@ +from optimade.models import DataType + + +def test_convert_python_types(): + """Convert various Python types to OPTIMADE Data types""" + from datetime import datetime + + expected_data_type = [ + DataType.STRING, + DataType.INTEGER, + DataType.FLOAT, + DataType.LIST, + DataType.DICTIONARY, + DataType.UNKNOWN, + DataType.TIMESTAMP, + ] + + python_types_as_strings = [ + "str", + "int", + "float", + "list", + "dict", + "None", + "datetime", + ] + python_types_as_types = [str, int, float, list, dict, None, datetime] + + test_none = None + python_types_as_objects = [ + str("Test"), + 42, + 42.42, + ["Test", 42], + {"Test": 42}, + test_none, + datetime.now(), + ] + + for list_of_python_types in [ + python_types_as_strings, + python_types_as_types, + python_types_as_objects, + ]: + for index, python_type in enumerate(list_of_python_types): + assert isinstance( + DataType.from_python_type(python_type), DataType + ), f"python_type: {python_type}" + assert DataType.from_python_type(python_type) == expected_data_type[index] + + +def test_convert_json_types(): + """Convert various JSON and OpenAPI types to OPTIMADE Data types""" + json_types = [ + ("string", DataType.STRING), + ("integer", DataType.INTEGER), + ("number", DataType.FLOAT), + ("array", DataType.LIST), + ("object", DataType.DICTIONARY), + ("null", DataType.UNKNOWN), + ] + openapi_formats = [ + ("date-time", DataType.TIMESTAMP), + ("email", DataType.STRING), + ("uri", DataType.STRING), + ] + + for list_of_schema_types in [json_types, openapi_formats]: + for schema_type, optimade_type in list_of_schema_types: + assert isinstance( + DataType.from_json_type(schema_type), DataType + ), f"json_type: {schema_type}" + assert DataType.from_json_type(schema_type) == optimade_type + + +def test_get_values(): + """Check all data values are returned sorted with get_values()""" + sorted_data_types = [ + "boolean", + "dictionary", + "float", + "integer", + "list", + "string", + "timestamp", + "unknown", + ] + assert DataType.get_values() == sorted_data_types diff --git a/tests/models/test_references.py b/tests/models/test_references.py new file mode 100644 index 000000000..8baef4632 --- /dev/null +++ b/tests/models/test_references.py @@ -0,0 +1,29 @@ +# pylint: disable=no-member +import pytest + +from optimade.models.references import ReferenceResource +from optimade.server.mappers import ReferenceMapper + + +def test_good_references(): + """Check well-formed references used as example data""" + import optimade.server.data + + good_refs = optimade.server.data.references + for doc in good_refs: + ReferenceResource(**ReferenceMapper.map_back(doc)) + + +def test_bad_references(): + """Check badly formed references""" + from pydantic import ValidationError + + bad_refs = [ + {"id": "AAAA", "type": "references", "doi": "10.1234/1234"}, # bad id + {"id": "newton1687", "type": "references"}, # missing all fields + {"id": "newton1687", "type": "reference", "doi": "10.1234/1234"}, # wrong type + ] + + for ref in bad_refs: + with pytest.raises(ValidationError): + ReferenceResource(**ReferenceMapper.map_back(ref)) diff --git a/tests/models/test_structures.py b/tests/models/test_structures.py new file mode 100644 index 000000000..a0d2530a2 --- /dev/null +++ b/tests/models/test_structures.py @@ -0,0 +1,37 @@ +# pylint: disable=no-member +import pytest + +from pydantic import ValidationError + +from optimade.models.structures import StructureResource +from optimade.server.mappers import StructureMapper + + +def test_good_structures(): + """Check well-formed structures used as example data""" + import optimade.server.data + + good_structures = optimade.server.data.structures + + for structure in good_structures: + StructureResource(**StructureMapper.map_back(structure)) + + +def test_more_good_structures(good_structures): + """Check well-formed structures with specific edge-cases""" + for index, structure in enumerate(good_structures): + try: + StructureResource(**StructureMapper.map_back(structure)) + except ValidationError: + print( + f"Good test structure {index} failed to validate from 'test_more_structures.json'" + ) + raise + + +def test_bad_structures(bad_structures): + """Check badly formed structures""" + for index, structure in enumerate(bad_structures): + print(f"Trying structure number {index} from 'test_bad_structures.json'") + with pytest.raises(ValidationError): + StructureResource(**StructureMapper.map_back(structure)) diff --git a/tests/server/routers/test_info.py b/tests/server/routers/test_info.py index 987eb4e79..c90565d02 100644 --- a/tests/server/routers/test_info.py +++ b/tests/server/routers/test_info.py @@ -1,7 +1,7 @@ # pylint: disable=relative-beyond-top-level import unittest -from optimade.models import InfoResponse, EntryInfoResponse, IndexInfoResponse +from optimade.models import InfoResponse, EntryInfoResponse, IndexInfoResponse, DataType from ..utils import EndpointTestsMixin @@ -36,12 +36,37 @@ def test_info_structures_endpoint_data(self): data_keys = ["description", "properties", "formats", "output_fields_by_format"] self.check_keys(data_keys, self.json_response["data"]) + def test_properties_type(self): + types = { + _.get("type", None) + for _ in self.json_response.get("data", {}).get("properties", {}).values() + } + for data_type in types: + if data_type is None: + continue + assert isinstance(DataType(data_type), DataType) + class InfoReferencesEndpointTests(EndpointTestsMixin, unittest.TestCase): request_str = "/info/references" response_cls = EntryInfoResponse + def test_info_references_endpoint_data(self): + self.assertTrue("data" in self.json_response) + data_keys = ["description", "properties", "formats", "output_fields_by_format"] + self.check_keys(data_keys, self.json_response["data"]) + + def test_properties_type(self): + types = { + _.get("type", None) + for _ in self.json_response.get("data", {}).get("properties", {}).values() + } + for data_type in types: + if data_type is None: + continue + assert isinstance(DataType(data_type), DataType) + class IndexInfoEndpointTests(EndpointTestsMixin, unittest.TestCase):