From 27ea22c30609cdbed6b56107a6817804d002915a Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen <43357585+CasperWA@users.noreply.github.com> Date: Fri, 31 Jul 2020 17:06:12 +0200 Subject: [PATCH] Introduce logging (#432) * Add OPTIMADE logger: The logger can be imported from optimade.server.logger as LOGGER. It will write out logs at DEBUG level to a file `optimade.log` at the OPTIMADE_LOG_DIR environment variable, default is /var/log/optimade/, rotating between 5 files when reaching 1 MB. The validator logger now get it's logger as a Child logger from this. `run.sh` has been updated to set the new `OPTIMADE_LOG_LEVEL` configuration option, since this is the only way we can get the logging level to the terminal/console handler, while avoiding circular imports. If `OPTIMADE_DEBUG` is set to "true", the `OPTIMADE_LOG_LEVEL` will be overwritten to "DEBUG". * Convert various print-statements to log-statements: Keeping some print-statements still, mainly in tests, where it's nicer to print than log in order to not unneccesarily pollute the logs. * Add `log_dir` configuration option: The default for `log_dir` is `/var/log/optimade/`. If a `PermissionError` is raised during setting up the logging folder, a warning will be logged (to the console), which will include instructions on how to deal with the issue. --- .gitignore | 1 + optimade/adapters/base.py | 6 +- optimade/adapters/logger.py | 4 + optimade/server/config.py | 41 ++- optimade/server/entry_collections/mongo.py | 9 +- optimade/server/exception_handlers.py | 7 +- optimade/server/logger.py | 67 +++++ optimade/server/main.py | 36 ++- optimade/server/main_index.py | 16 +- optimade/validator/validator.py | 2 +- run.sh | 8 +- tests/adapters/references/test_references.py | 157 ++++++----- tests/adapters/structures/test_structures.py | 257 +++++++++---------- tests/models/test_links.py | 1 + tests/models/test_structures.py | 2 + 15 files changed, 368 insertions(+), 246 deletions(-) create mode 100644 optimade/adapters/logger.py create mode 100644 optimade/server/logger.py diff --git a/.gitignore b/.gitignore index c0051f9a0..6a836353d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ venv.bak/ # Package-specific local_openapi.json local_index_openapi.json +logs diff --git a/optimade/adapters/base.py b/optimade/adapters/base.py index f1b8b36d3..8e1955206 100644 --- a/optimade/adapters/base.py +++ b/optimade/adapters/base.py @@ -5,6 +5,8 @@ from optimade.models import EntryResource +from optimade.adapters.logger import LOGGER + class EntryAdapter: """Base class for lazy resource entry adapters @@ -34,12 +36,12 @@ def entry(self): @entry.setter def entry(self, value: dict): """Set OPTIMADE entry - If already set, print that this can _only_ be set once. + If already set, report that this can _only_ be set once. """ if self._entry is None: self._entry = self.ENTRY_RESOURCE(**value) else: - print("entry can only be set once and is already set.") + LOGGER.warning("entry can only be set once and is already set.") def convert(self, format: str) -> Any: """Convert OPTIMADE entry to desired format""" diff --git a/optimade/adapters/logger.py b/optimade/adapters/logger.py new file mode 100644 index 000000000..8859d5ed9 --- /dev/null +++ b/optimade/adapters/logger.py @@ -0,0 +1,4 @@ +"""Logger for optimade.adapters""" +import logging + +LOGGER = logging.getLogger("optimade").getChild("adapters") diff --git a/optimade/server/config.py b/optimade/server/config.py index 33261cb23..e93e07ffb 100644 --- a/optimade/server/config.py +++ b/optimade/server/config.py @@ -1,6 +1,6 @@ # pylint: disable=no-self-argument +from enum import Enum import json -import logging from typing import Optional, Dict, List try: @@ -22,11 +22,17 @@ DEFAULT_CONFIG_FILE_PATH = str(Path.home().joinpath(".optimade.json")) -logger = logging.getLogger("optimade") -class NoFallback(Exception): - """No fallback value can be found.""" +class LogLevel(Enum): + """Replication of logging LogLevels""" + + NOTSET = "notset" + DEBUG = "debug" + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" class ServerConfig(BaseSettings): @@ -102,7 +108,6 @@ class ServerConfig(BaseSettings): {}, description="A mapping between field names in the database with their corresponding OPTIMADE field names, broken down by endpoint.", ) - length_aliases: Dict[ Literal["links", "references", "structures"], Dict[str, str] ] = Field( @@ -113,35 +118,53 @@ class ServerConfig(BaseSettings): "API fields, not the database fields." ), ) - index_links_path: Path = Field( Path(__file__).parent.joinpath("index_links.json"), description="Absolute path to a JSON file containing the MongoDB collection of /links resources for the index meta-database", ) + log_level: LogLevel = Field( + LogLevel.INFO, description="Logging level for the OPTIMADE server." + ) + log_dir: Path = Field( + Path("/var/log/optimade/"), + description="Folder in which log files will be saved.", + ) @validator("implementation", pre=True) def set_implementation_version(cls, v): - """Set defaults and modify by passed value(s)""" + """Set defaults and modify bypassed value(s)""" res = {"version": __version__} res.update(v) return res + @validator("log_level") + def force_debug_log_level(cls, v, values): + """If `debug` is `True`, then force `log_level` to be `DEBUG` as well""" + from optimade.server.logger import CONSOLE_HANDLER + + if values.get("debug", False): + v = LogLevel.DEBUG + CONSOLE_HANDLER.setLevel(v.value.upper()) + return v + @root_validator(pre=True) def load_default_settings(cls, values): # pylint: disable=no-self-argument """ Loads settings from a root file if available and uses that as defaults in place of built in defaults """ + from optimade.server.logger import LOGGER + config_file_path = Path(values.get("config_file", DEFAULT_CONFIG_FILE_PATH)) new_values = {} if config_file_path.exists() and config_file_path.is_file(): - logger.debug("Found config file at: %s", config_file_path) + LOGGER.debug("Found config file at: %s", config_file_path) with open(config_file_path) as f: new_values = json.load(f) else: - logger.debug( # pragma: no cover + LOGGER.debug( # pragma: no cover "Did not find config file at: %s", config_file_path ) diff --git a/optimade/server/entry_collections/mongo.py b/optimade/server/entry_collections/mongo.py index 5b896bb8d..df6abd57c 100644 --- a/optimade/server/entry_collections/mongo.py +++ b/optimade/server/entry_collections/mongo.py @@ -7,11 +7,12 @@ from optimade.filterparser import LarkParser from optimade.filtertransformers.mongo import MongoTransformer -from optimade.server.config import CONFIG from optimade.models import EntryResource +from optimade.server.config import CONFIG +from optimade.server.entry_collections import EntryCollection +from optimade.server.logger import LOGGER from optimade.server.mappers import BaseResourceMapper from optimade.server.query_params import EntryListingQueryParams, SingleEntryQueryParams -from .entry_collections import EntryCollection try: CI_FORCE_MONGO = bool(int(os.environ.get("OPTIMADE_CI_FORCE_MONGO", 0))) @@ -23,12 +24,12 @@ from pymongo import MongoClient client = MongoClient(CONFIG.mongo_uri) - print("Using: Real MongoDB (pymongo)") + LOGGER.info("Using: Real MongoDB (pymongo)") else: from mongomock import MongoClient client = MongoClient() - print("Using: Mock MongoDB (mongomock)") + LOGGER.info("Using: Mock MongoDB (mongomock)") class MongoCollection(EntryCollection): diff --git a/optimade/server/exception_handlers.py b/optimade/server/exception_handlers.py index 72972b544..dd3e33079 100644 --- a/optimade/server/exception_handlers.py +++ b/optimade/server/exception_handlers.py @@ -11,8 +11,9 @@ from optimade.models import OptimadeError, ErrorResponse, ErrorSource -from .config import CONFIG -from .routers.utils import meta_values +from optimade.server.config import CONFIG +from optimade.server.logger import LOGGER +from optimade.server.routers.utils import meta_values def general_exception( @@ -26,7 +27,7 @@ def general_exception( tb = "".join( traceback.format_exception(etype=type(exc), value=exc, tb=exc.__traceback__) ) - print(tb) + LOGGER.error("Traceback:\n%s", tb) debug_info[f"_{CONFIG.provider.prefix}_traceback"] = tb try: diff --git a/optimade/server/logger.py b/optimade/server/logger.py new file mode 100644 index 000000000..f59d44fba --- /dev/null +++ b/optimade/server/logger.py @@ -0,0 +1,67 @@ +"""Logging to both file and terminal""" +import logging +import os +from pathlib import Path +import sys + +from uvicorn.logging import DefaultFormatter + + +# Instantiate LOGGER +LOGGER = logging.getLogger("optimade") +LOGGER.setLevel(logging.DEBUG) + +# Handler +CONSOLE_HANDLER = logging.StreamHandler(sys.stdout) +try: + from optimade.server.config import CONFIG + + CONSOLE_HANDLER.setLevel(CONFIG.log_level.value.upper()) +except ImportError: + CONSOLE_HANDLER.setLevel(os.getenv("OPTIMADE_LOG_LEVEL", "INFO").upper()) + +# Formatter +CONSOLE_FORMATTER = DefaultFormatter("%(levelprefix)s [%(name)s] %(message)s") +CONSOLE_HANDLER.setFormatter(CONSOLE_FORMATTER) + +# Add handler to LOGGER +LOGGER.addHandler(CONSOLE_HANDLER) + +# Save a file with all messages (DEBUG level) +try: + from optimade.server.config import CONFIG + + LOGS_DIR = CONFIG.log_dir +except ImportError: + LOGS_DIR = Path(os.getenv("OPTIMADE_LOG_DIR", "/var/log/optimade/")).resolve() + +try: + LOGS_DIR.mkdir(exist_ok=True) +except PermissionError: + LOGGER.warning( + """Log files are not saved. + + This is usually due to not being able to access a specified log folder or write to files + in the specified log location, i.e., a `PermissionError` has been raised. + + To solve this, either set the OPTIMADE_LOG_DIR environment variable to a location + you have permission to write to or create the /var/log/optimade folder, which is + the default logging folder, with write permissions for the Unix user running the server. + """ + ) +else: + # Handlers + FILE_HANDLER = logging.handlers.RotatingFileHandler( + LOGS_DIR.joinpath("optimade.log"), maxBytes=1000000, backupCount=5 + ) + FILE_HANDLER.setLevel(logging.DEBUG) + + # Formatter + FILE_FORMATTER = logging.Formatter( + "[%(levelname)-8s %(asctime)s %(filename)s:%(lineno)d][%(name)s] %(message)s", + "%d-%m-%Y %H:%M:%S", + ) + FILE_HANDLER.setFormatter(FILE_FORMATTER) + + # Add handler to LOGGER + LOGGER.addHandler(FILE_HANDLER) diff --git a/optimade/server/main.py b/optimade/server/main.py index 2e97e6a52..83d6e62b1 100644 --- a/optimade/server/main.py +++ b/optimade/server/main.py @@ -1,5 +1,11 @@ -# pylint: disable=line-too-long +"""The OPTIMADE server + +The server is based on MongoDB, using either `pymongo` or `mongomock`. +This is an example implementation with example data. +To implement your own server see the documentation at https://optimade.org/optimade-python-tools. +""" +# pylint: disable=line-too-long from lark.exceptions import VisitError from pydantic import ValidationError @@ -10,19 +16,27 @@ from optimade import __api_version__, __version__ import optimade.server.exception_handlers as exc_handlers -from .entry_collections import MongoCollection -from .config import CONFIG -from .middleware import ( +from optimade.server.entry_collections import MongoCollection +from optimade.server.config import CONFIG +from optimade.server.logger import LOGGER +from optimade.server.middleware import ( AddWarnings, CheckWronglyVersionedBaseUrls, EnsureQueryParamIntegrity, ) -from .routers import info, links, references, structures, landing, versions -from .routers.utils import get_providers, BASE_URL_PREFIXES +from optimade.server.routers import ( + info, + landing, + links, + references, + structures, + versions, +) +from optimade.server.routers.utils import get_providers, BASE_URL_PREFIXES if CONFIG.debug: # pragma: no cover - print("DEBUG MODE") + LOGGER.info("DEBUG MODE") app = FastAPI( @@ -45,15 +59,17 @@ from .routers import ENTRY_COLLECTIONS def load_entries(endpoint_name: str, endpoint_collection: MongoCollection): - print(f"loading test {endpoint_name}...") + LOGGER.debug(f"Loading test {endpoint_name}...") endpoint_collection.collection.insert_many(getattr(data, endpoint_name, [])) if endpoint_name == "links": - print("adding Materials-Consortia providers to links from optimade.org") + LOGGER.debug( + "Adding Materials-Consortia providers to links from optimade.org" + ) endpoint_collection.collection.insert_many( bson.json_util.loads(bson.json_util.dumps(get_providers())) ) - print(f"done inserting test {endpoint_name}...") + LOGGER.debug(f"Done inserting test {endpoint_name}...") for name, collection in ENTRY_COLLECTIONS.items(): load_entries(name, collection) diff --git a/optimade/server/main_index.py b/optimade/server/main_index.py index c60c20574..7fd7b3de2 100644 --- a/optimade/server/main_index.py +++ b/optimade/server/main_index.py @@ -1,3 +1,10 @@ +"""The OPTIMADE Index Meta-Database server + +The server is based on MongoDB, using either `pymongo` or `mongomock`. + +This is an example implementation with example data. +To implement your own index meta-database server see the documentation at https://optimade.org/optimade-python-tools. +""" # pylint: disable=line-too-long import json @@ -12,6 +19,7 @@ import optimade.server.exception_handlers as exc_handlers from optimade.server.config import CONFIG +from optimade.server.logger import LOGGER from optimade.server.middleware import ( AddWarnings, CheckWronglyVersionedBaseUrls, @@ -22,7 +30,7 @@ if CONFIG.debug: # pragma: no cover - print("DEBUG MODE") + LOGGER.info("DEBUG MODE") app = FastAPI( @@ -45,7 +53,7 @@ from .routers.links import links_coll from .routers.utils import mongo_id_for_database - print("loading index links...") + LOGGER.debug("Loading index links...") with open(CONFIG.index_links_path) as f: data = json.load(f) @@ -55,11 +63,11 @@ db["_id"] = {"$oid": mongo_id_for_database(db["id"], db["type"])} processed.append(db) - print("inserting index links into collection...") + LOGGER.debug("Inserting index links into collection...") links_coll.collection.insert_many( bson.json_util.loads(bson.json_util.dumps(processed)) ) - print("done inserting index links...") + LOGGER.debug("Done inserting index links...") # Add various middleware diff --git a/optimade/validator/validator.py b/optimade/validator/validator.py index 50f1f0c0d..ba1d1a597 100644 --- a/optimade/validator/validator.py +++ b/optimade/validator/validator.py @@ -387,7 +387,7 @@ def __init__( # pylint: disable=too-many-arguments def _setup_log(self): """ Define stdout log based on given verbosity. """ - self._log = logging.getLogger(__name__) + self._log = logging.getLogger("optimade").getChild("validator") self._log.handlers = [] stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter( diff --git a/run.sh b/run.sh index 31548ab1a..c225b82b7 100755 --- a/run.sh +++ b/run.sh @@ -1,9 +1,9 @@ #!/bin/bash -LOG_LEVEL=info +export OPTIMADE_LOG_LEVEL=info if [ "$1" == "debug" ]; then export OPTIMADE_DEBUG=1 - LOG_LEVEL=debug + export OPTIMADE_LOG_LEVEL=debug fi if [ "$1" == "index" ]; then @@ -11,7 +11,7 @@ if [ "$1" == "index" ]; then PORT=5001 if [ "$2" == "debug" ]; then export OPTIMADE_DEBUG=1 - LOG_LEVEL=debug + export OPTIMADE_LOG_LEVEL=debug fi else if [ "${MAIN}" == "main_index" ]; then @@ -22,4 +22,4 @@ else fi fi -uvicorn optimade.server.$MAIN:app --reload --port $PORT --log-level $LOG_LEVEL +uvicorn optimade.server.$MAIN:app --reload --port $PORT --log-level $OPTIMADE_LOG_LEVEL diff --git a/tests/adapters/references/test_references.py b/tests/adapters/references/test_references.py index e81b0185d..7b500d265 100644 --- a/tests/adapters/references/test_references.py +++ b/tests/adapters/references/test_references.py @@ -1,86 +1,83 @@ +"""Test Reference adapter""" import pytest from optimade.adapters import Reference from optimade.models import ReferenceResource -class TestReference: - """Test Reference adapter""" - - def test_instantiate(self, RAW_REFERENCES): - """Try instantiating Reference for all raw test references""" - for reference in RAW_REFERENCES: - new_Reference = Reference(reference) - assert isinstance(new_Reference.entry, ReferenceResource) - - def test_setting_entry(self, capfd, RAW_REFERENCES): - """Make sure entry can only be set once""" - reference = Reference(RAW_REFERENCES[0]) - reference.entry = RAW_REFERENCES[1] - captured = capfd.readouterr() - assert "entry can only be set once and is already set." in captured.out - - @pytest.mark.skip( - "Currently, there are no conversion types available for references" - ) - def test_convert(self, reference): - """Test convert() works - Choose currently known entry type - must be updated if no longer available. - """ - if not reference._type_converters: - pytest.fail("_type_converters is seemingly empty. This should not be.") - - chosen_type = "SOME_VALID_TYPE" - if chosen_type not in reference._type_converters: - pytest.fail( - f"{chosen_type} not found in _type_converters: {reference._type_converters} - " - "please update test tests/adapters/references/test_references.py:TestReference." - "test_convert()" - ) - - converted_reference = reference.convert(chosen_type) - assert isinstance(converted_reference, (str, None.__class__)) - assert converted_reference == reference._converted[chosen_type] - - def test_convert_wrong_format(self, reference): - """Test AttributeError is raised if format does not exist""" - nonexistant_format = 0 - right_wrong_format_found = False - while not right_wrong_format_found: - if str(nonexistant_format) not in reference._type_converters: - nonexistant_format = str(nonexistant_format) - right_wrong_format_found = True - else: - nonexistant_format += 1 - - with pytest.raises( - AttributeError, - match=f"Non-valid entry type to convert to: {nonexistant_format}", - ): - reference.convert(nonexistant_format) - - def test_getattr_order(self, reference): - """The order of getting an attribute should be: - 1. `as_` - 2. `` - 3. `` - 3. `raise AttributeError with custom message` - """ - # If passing attribute starting with `as_`, it should call `self.convert()` - with pytest.raises( - AttributeError, match="Non-valid entry type to convert to: " - ): - reference.as_ - - # If passing valid ReferenceResource attribute, it should return said attribute - for attribute, attribute_type in ( - ("id", str), - ("authors", list), - ("attributes.authors", list), - ): - assert isinstance(getattr(reference, attribute), attribute_type) - - # Otherwise, it should raise AttributeError - for attribute in ("nonexistant_attribute", "attributes.nonexistant_attribute"): - with pytest.raises(AttributeError, match=f"Unknown attribute: {attribute}"): - getattr(reference, attribute) +def test_instantiate(RAW_REFERENCES): + """Try instantiating Reference for all raw test references""" + for reference in RAW_REFERENCES: + new_Reference = Reference(reference) + assert isinstance(new_Reference.entry, ReferenceResource) + + +def test_setting_entry(caplog, RAW_REFERENCES): + """Make sure entry can only be set once""" + reference = Reference(RAW_REFERENCES[0]) + reference.entry = RAW_REFERENCES[1] + assert "entry can only be set once and is already set." in caplog.text + + +@pytest.mark.skip("Currently, there are no conversion types available for references") +def test_convert(reference): + """Test convert() works + Choose currently known entry type - must be updated if no longer available. + """ + if not reference._type_converters: + pytest.fail("_type_converters is seemingly empty. This should not be.") + + chosen_type = "SOME_VALID_TYPE" + if chosen_type not in reference._type_converters: + pytest.fail( + f"{chosen_type} not found in _type_converters: {reference._type_converters} - " + "please update test tests/adapters/references/test_references.py:TestReference." + "test_convert()" + ) + + converted_reference = reference.convert(chosen_type) + assert isinstance(converted_reference, (str, None.__class__)) + assert converted_reference == reference._converted[chosen_type] + + +def test_convert_wrong_format(reference): + """Test AttributeError is raised if format does not exist""" + nonexistant_format = 0 + right_wrong_format_found = False + while not right_wrong_format_found: + if str(nonexistant_format) not in reference._type_converters: + nonexistant_format = str(nonexistant_format) + right_wrong_format_found = True + else: + nonexistant_format += 1 + + with pytest.raises( + AttributeError, + match=f"Non-valid entry type to convert to: {nonexistant_format}", + ): + reference.convert(nonexistant_format) + + +def test_getattr_order(reference): + """The order of getting an attribute should be: + 1. `as_` + 2. `` + 3. `` + 3. `raise AttributeError with custom message` + """ + # If passing attribute starting with `as_`, it should call `self.convert()` + with pytest.raises(AttributeError, match="Non-valid entry type to convert to: "): + reference.as_ + + # If passing valid ReferenceResource attribute, it should return said attribute + for attribute, attribute_type in ( + ("id", str), + ("authors", list), + ("attributes.authors", list), + ): + assert isinstance(getattr(reference, attribute), attribute_type) + + # Otherwise, it should raise AttributeError + for attribute in ("nonexistant_attribute", "attributes.nonexistant_attribute"): + with pytest.raises(AttributeError, match=f"Unknown attribute: {attribute}"): + getattr(reference, attribute) diff --git a/tests/adapters/structures/test_structures.py b/tests/adapters/structures/test_structures.py index 1e950c9fa..ff3354877 100644 --- a/tests/adapters/structures/test_structures.py +++ b/tests/adapters/structures/test_structures.py @@ -1,3 +1,4 @@ +"""Test Structure adapter""" import pytest from optimade.adapters import Structure @@ -15,132 +16,130 @@ all_modules_found = True -class TestStructure: - """Test Structure adapter""" - - def test_instantiate(self, RAW_STRUCTURES): - """Try instantiating Structure for all raw test structures""" - for structure in RAW_STRUCTURES: - new_Structure = Structure(structure) - assert isinstance(new_Structure.entry, StructureResource) - - def test_setting_entry(self, capfd, RAW_STRUCTURES): - """Make sure entry can only be set once""" - structure = Structure(RAW_STRUCTURES[0]) - structure.entry = RAW_STRUCTURES[1] - captured = capfd.readouterr() - assert "entry can only be set once and is already set." in captured.out - - def test_convert(self, structure): - """Test convert() works - Choose currently known entry type - must be updated if no longer available. - """ - if not structure._type_converters: - pytest.fail("_type_converters is seemingly empty. This should not be.") - - chosen_type = "cif" - if chosen_type not in structure._type_converters: - pytest.fail( - f"{chosen_type} not found in _type_converters: {structure._type_converters} - " - "please update test tests/adapters/structures/test_structures.py:TestStructure." - "test_convert()" - ) - - converted_structure = structure.convert(chosen_type) - assert isinstance(converted_structure, (str, None.__class__)) - assert converted_structure == structure._converted[chosen_type] - - def test_convert_wrong_format(self, structure): - """Test AttributeError is raised if format does not exist""" - nonexistant_format = 0 - right_wrong_format_found = False - while not right_wrong_format_found: - if str(nonexistant_format) not in structure._type_converters: - nonexistant_format = str(nonexistant_format) - right_wrong_format_found = True - else: - nonexistant_format += 1 - - with pytest.raises( - AttributeError, - match=f"Non-valid entry type to convert to: {nonexistant_format}", - ): - structure.convert(nonexistant_format) - - def test_getattr_order(self, structure): - """The order of getting an attribute should be: - 1. `as_` - 2. `` - 3. `` - 4. `raise AttributeError` with custom message - """ - # If passing attribute starting with `as_`, it should call `self.convert()` - with pytest.raises( - AttributeError, match="Non-valid entry type to convert to: " - ): - structure.as_ - - # If passing valid StructureResource attribute, it should return said attribute - # Test also nested attributes with `getattr()`. - for attribute, attribute_type in ( - ("id", str), - ("species", list), - ("attributes.species", list), - ): - assert isinstance(getattr(structure, attribute), attribute_type) - - # Otherwise, it should raise AttributeError - for attribute in ("nonexistant_attribute", "attributes.nonexistant_attribute"): - with pytest.raises(AttributeError, match=f"Unknown attribute: {attribute}"): - getattr(structure, attribute) - - @pytest.mark.skipif( - all_modules_found, - reason="This test checks what happens if a conversion-dependent module cannot be found. " - "All could be found, i.e., it has no meaning.", - ) - def test_no_module_conversion(self, structure): - """Make sure a warnings is raised and None is returned for conversions with non-existing modules""" - import importlib - - CONVERSION_MAPPING = { - "aiida": ["aiida_structuredata"], - "ase": ["ase"], - "numpy": ["cif", "pdb", "pdbx_mmcif"], - "pymatgen": ["pymatgen"], - "jarvis": ["jarvis"], - } - - modules_to_test = [] - for module in ("aiida", "ase", "numpy", "pymatgen", "jarvis"): - try: - importlib.import_module(module) - except (ImportError, ModuleNotFoundError): - modules_to_test.append(module) - - if not modules_to_test: - pytest.fail( - "No modules found to test - it seems all modules are installed." - ) - - for module in modules_to_test: - for conversion_function in CONVERSION_MAPPING[module]: - with pytest.warns( - UserWarning, match="not found, cannot convert structure to" - ): - converted_structure = structure.convert(conversion_function) - assert converted_structure is None - - def test_common_converters(self, raw_structure, RAW_STRUCTURES): - """Test common converters""" - structure = Structure(raw_structure) - - assert structure.as_json == StructureResource(**raw_structure).json() - assert structure.as_dict == StructureResource(**raw_structure).dict() - - # Since calling .dict() and .json() will return also all default-valued properties, - # the raw structure should at least be a sub-set of the resource's full list of properties. - for raw_structure in RAW_STRUCTURES: - raw_structure_property_set = set(raw_structure.keys()) - resource_property_set = set(Structure(raw_structure).as_dict.keys()) - assert raw_structure_property_set.issubset(resource_property_set) +def test_instantiate(RAW_STRUCTURES): + """Try instantiating Structure for all raw test structures""" + for structure in RAW_STRUCTURES: + new_Structure = Structure(structure) + assert isinstance(new_Structure.entry, StructureResource) + + +def test_setting_entry(caplog, RAW_STRUCTURES): + """Make sure entry can only be set once""" + structure = Structure(RAW_STRUCTURES[0]) + structure.entry = RAW_STRUCTURES[1] + assert "entry can only be set once and is already set." in caplog.text + + +def test_convert(structure): + """Test convert() works + Choose currently known entry type - must be updated if no longer available. + """ + if not structure._type_converters: + pytest.fail("_type_converters is seemingly empty. This should not be.") + + chosen_type = "cif" + if chosen_type not in structure._type_converters: + pytest.fail( + f"{chosen_type} not found in _type_converters: {structure._type_converters} - " + "please update test tests/adapters/structures/test_structures.py:TestStructure." + "test_convert()" + ) + + converted_structure = structure.convert(chosen_type) + assert isinstance(converted_structure, (str, None.__class__)) + assert converted_structure == structure._converted[chosen_type] + + +def test_convert_wrong_format(structure): + """Test AttributeError is raised if format does not exist""" + nonexistant_format = 0 + right_wrong_format_found = False + while not right_wrong_format_found: + if str(nonexistant_format) not in structure._type_converters: + nonexistant_format = str(nonexistant_format) + right_wrong_format_found = True + else: + nonexistant_format += 1 + + with pytest.raises( + AttributeError, + match=f"Non-valid entry type to convert to: {nonexistant_format}", + ): + structure.convert(nonexistant_format) + + +def test_getattr_order(structure): + """The order of getting an attribute should be: + 1. `as_` + 2. `` + 3. `` + 4. `raise AttributeError` with custom message + """ + # If passing attribute starting with `as_`, it should call `self.convert()` + with pytest.raises(AttributeError, match="Non-valid entry type to convert to: "): + structure.as_ + + # If passing valid StructureResource attribute, it should return said attribute + # Test also nested attributes with `getattr()`. + for attribute, attribute_type in ( + ("id", str), + ("species", list), + ("attributes.species", list), + ): + assert isinstance(getattr(structure, attribute), attribute_type) + + # Otherwise, it should raise AttributeError + for attribute in ("nonexistant_attribute", "attributes.nonexistant_attribute"): + with pytest.raises(AttributeError, match=f"Unknown attribute: {attribute}"): + getattr(structure, attribute) + + +@pytest.mark.skipif( + all_modules_found, + reason="This test checks what happens if a conversion-dependent module cannot be found. " + "All could be found, i.e., it has no meaning.", +) +def test_no_module_conversion(structure): + """Make sure a warnings is raised and None is returned for conversions with non-existing modules""" + import importlib + + CONVERSION_MAPPING = { + "aiida": ["aiida_structuredata"], + "ase": ["ase"], + "numpy": ["cif", "pdb", "pdbx_mmcif"], + "pymatgen": ["pymatgen"], + "jarvis": ["jarvis"], + } + + modules_to_test = [] + for module in ("aiida", "ase", "numpy", "pymatgen", "jarvis"): + try: + importlib.import_module(module) + except (ImportError, ModuleNotFoundError): + modules_to_test.append(module) + + if not modules_to_test: + pytest.fail("No modules found to test - it seems all modules are installed.") + + for module in modules_to_test: + for conversion_function in CONVERSION_MAPPING[module]: + with pytest.warns( + UserWarning, match="not found, cannot convert structure to" + ): + converted_structure = structure.convert(conversion_function) + assert converted_structure is None + + +def test_common_converters(raw_structure, RAW_STRUCTURES): + """Test common converters""" + structure = Structure(raw_structure) + + assert structure.as_json == StructureResource(**raw_structure).json() + assert structure.as_dict == StructureResource(**raw_structure).dict() + + # Since calling .dict() and .json() will return also all default-valued properties, + # the raw structure should at least be a sub-set of the resource's full list of properties. + for raw_structure in RAW_STRUCTURES: + raw_structure_property_set = set(raw_structure.keys()) + resource_property_set = set(Structure(raw_structure).as_dict.keys()) + assert raw_structure_property_set.issubset(resource_property_set) diff --git a/tests/models/test_links.py b/tests/models/test_links.py index 2c3e43de5..838bb813b 100644 --- a/tests/models/test_links.py +++ b/tests/models/test_links.py @@ -32,6 +32,7 @@ def test_bad_links(starting_links, mapper): ] for index, links in enumerate(bad_links): + # This is for helping devs finding any errors that may occur print(f"Now testing number {index}") bad_link = starting_links.copy() bad_link.update(links) diff --git a/tests/models/test_structures.py b/tests/models/test_structures.py index 64ea37434..31ad9d2f9 100644 --- a/tests/models/test_structures.py +++ b/tests/models/test_structures.py @@ -25,6 +25,7 @@ def test_more_good_structures(good_structures, mapper): try: StructureResource(**mapper(MAPPER).map_back(structure)) except ValidationError: + # Printing to keep the original exception as is, while still being informational print( f"Good test structure {index} failed to validate from 'test_more_structures.json'" ) @@ -34,6 +35,7 @@ def test_more_good_structures(good_structures, mapper): def test_bad_structures(bad_structures, mapper): """Check badly formed structures""" for index, structure in enumerate(bad_structures): + # This is for helping devs finding any errors that may occur print(f"Trying structure number {index} from 'test_bad_structures.json'") with pytest.raises(ValidationError): StructureResource(**mapper(MAPPER).map_back(structure))