diff --git a/build/lib/optimade_client/__init__.py b/build/lib/optimade_client/__init__.py new file mode 100644 index 00000000..f9d8b4b6 --- /dev/null +++ b/build/lib/optimade_client/__init__.py @@ -0,0 +1,20 @@ +""" +OPTIMADE Client + +Voilà/Jupyter client for searching through OPTIMADE databases. +""" +from .informational import OptimadeClientFAQ, HeaderDescription, OptimadeLog +from .query_provider import OptimadeQueryProviderWidget +from .query_filter import OptimadeQueryFilterWidget +from .summary import OptimadeSummaryWidget + + +__version__ = "2022.9.19" +__all__ = ( + "HeaderDescription", + "OptimadeClientFAQ", + "OptimadeLog", + "OptimadeQueryProviderWidget", + "OptimadeQueryFilterWidget", + "OptimadeSummaryWidget", +) diff --git a/build/lib/optimade_client/cli/__init__.py b/build/lib/optimade_client/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/optimade_client/cli/run.py b/build/lib/optimade_client/cli/run.py new file mode 100644 index 00000000..ec81d577 --- /dev/null +++ b/build/lib/optimade_client/cli/run.py @@ -0,0 +1,105 @@ +import argparse +import logging +import os +from pathlib import Path +import subprocess +import sys + +try: + from voila.app import main as voila +except ImportError: + voila = None + + +LOGGING_LEVELS = [logging.getLevelName(level).lower() for level in range(0, 51, 10)] +VERSION = "2022.9.19" # Avoid importing optimade-client package + + +def main(args: list = None): + """Run the OPTIMADE Client.""" + parser = argparse.ArgumentParser( + description=main.__doc__, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--version", + action="version", + help="Show the version and exit.", + version=f"OPTIMADE Client version {VERSION}", + ) + parser.add_argument( + "--log-level", + type=str, + help="Set the log-level of the server.", + choices=LOGGING_LEVELS, + default="info", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Will overrule log-level option and set the log-level to 'debug'.", + ) + parser.add_argument( + "--open-browser", + action="store_true", + help="Attempt to open a browser upon starting the Voilà tornado server.", + ) + parser.add_argument( + "--template", + type=str, + help="Use another template than the default.", + ) + parser.add_argument( + "--dev", + action="store_true", + help="Use development servers where applicable.", + ) + + args = parser.parse_args(args) + log_level = args.log_level + debug = args.debug + open_browser = args.open_browser + template = args.template + dev = args.dev + + # Make sure Voilà is installed + if voila is None: + sys.exit( + "Voilà is not installed.\nPlease run:\n\n pip install optimade-client[server]\n\n" + "Or the equivalent, matching the installation in your environment, to install Voilà " + "(and ASE for a larger download format selection)." + ) + + notebook = str( + Path(__file__).parent.joinpath("static/OPTIMADE-Client.ipynb").resolve() + ) + config_path = str(Path(__file__).parent.joinpath("static").resolve()) + + # "Trust" notebook + subprocess.run(["jupyter", "trust", notebook], check=False) + + argv = [notebook] + + argv.append(f"--Voila.config_file_paths={config_path}") + + if debug: + if log_level not in ("debug", "info"): + print("[OPTIMADE-Client] Overwriting requested log-level to: 'debug'") + os.environ["OPTIMADE_CLIENT_DEBUG"] = "True" + argv.append("--debug") + argv.append("--show_tracebacks=True") + else: + os.environ.pop("OPTIMADE_CLIENT_DEBUG", None) + + os.environ["OPTIMADE_CLIENT_DEVELOPMENT_MODE"] = "1" if dev else "0" + + if not open_browser: + argv.append("--no-browser") + + if "--debug" not in argv: + argv.append(f"--Voila.log_level={getattr(logging, log_level.upper())}") + + if template: + argv.append(f"--template={template}") + + voila(argv) diff --git a/build/lib/optimade_client/cli/static/OPTIMADE-Client.ipynb b/build/lib/optimade_client/cli/static/OPTIMADE-Client.ipynb new file mode 100644 index 00000000..35bb4f36 --- /dev/null +++ b/build/lib/optimade_client/cli/static/OPTIMADE-Client.ipynb @@ -0,0 +1,131 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "source": [ + "# import os\n", + "\n", + "# This line will include DEBUG level log messages from the start of the app,\n", + "# as well as include a \"Local server\" as provider (at http://localhost:5000/optimade/v),\n", + "# where is the major version number of the currently supported OPTIMADE spec version.\n", + "#os.environ[\"OPTIMADE_CLIENT_DEBUG\"] = \"True\"" + ], + "outputs": [], + "metadata": {} + }, + { + "cell_type": "code", + "execution_count": null, + "source": [ + "from optimade_client import (\n", + " HeaderDescription,\n", + " OptimadeClientFAQ,\n", + " OptimadeLog,\n", + " OptimadeQueryProviderWidget,\n", + " OptimadeQueryFilterWidget,\n", + " OptimadeSummaryWidget,\n", + ")\n", + "from ipywidgets import dlink, HTML\n", + "from IPython.display import display\n", + "\n", + "# NOTE: Temporarily disable providers NOT properly satisfying the OPTIMADE specification\n", + "# Follow issue #206: https://github.com/CasperWA/voila-optimade-client/issues/206\n", + "# For omdb: Follow issue #246: https://github.com/CasperWA/voila-optimade-client/issues/246\n", + "disable_providers = [\n", + " \"cod\",\n", + " \"tcod\",\n", + " \"nmd\",\n", + " \"oqmd\",\n", + " \"aflow\",\n", + " \"matcloud\",\n", + " \"mpds\",\n", + " \"necro\",\n", + " \"jarvis\",\n", + "]\n", + "# Curate and group Materials Cloud databases\n", + "skip_databases = {\"Materials Cloud\": [\"li-ion-conductors\"]}\n", + "database_grouping = {\n", + " \"Materials Cloud\": {\n", + " \"General\": [\"curated-cofs\"],\n", + " \"Projects\": [\n", + " \"2dstructures\",\n", + " \"2dtopo\",\n", + " \"pyrene-mofs\",\n", + " \"scdm\",\n", + " \"sssp\",\n", + " \"stoceriaitf\",\n", + " \"tc-applicability\",\n", + " \"threedd\",\n", + " ]}\n", + "}\n", + "\n", + "selector = OptimadeQueryProviderWidget(\n", + " disable_providers=disable_providers,\n", + " skip_providers=[\"exmpl\", \"optimade\", \"aiida\"],\n", + " skip_databases=skip_databases,\n", + " provider_database_groupings=database_grouping,\n", + ")\n", + "filters = OptimadeQueryFilterWidget()\n", + "summary = OptimadeSummaryWidget(direction=\"horizontal\")\n", + "\n", + "_ = dlink((selector, 'database'), (filters, 'database'))\n", + "_ = dlink((filters, 'structure'), (summary, 'entity'))\n", + "\n", + "HeaderDescription()" + ], + "outputs": [], + "metadata": {} + }, + { + "cell_type": "code", + "execution_count": null, + "source": [ + "OptimadeClientFAQ()" + ], + "outputs": [], + "metadata": {} + }, + { + "cell_type": "code", + "execution_count": null, + "source": [ + "OptimadeLog()" + ], + "outputs": [], + "metadata": {} + }, + { + "cell_type": "code", + "execution_count": null, + "source": [ + "display(HTML('

Query a provider\\'s database

'))\n", + "\n", + "display(selector, filters, summary)" + ], + "outputs": [], + "metadata": {} + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/build/lib/optimade_client/cli/static/voila.json b/build/lib/optimade_client/cli/static/voila.json new file mode 100644 index 00000000..6c7b4bea --- /dev/null +++ b/build/lib/optimade_client/cli/static/voila.json @@ -0,0 +1,12 @@ +{ + "VoilaConfiguration": { + "enable_nbextensions": true + }, + "VoilaExecutePreprocessor": { + "timeout": 180 + }, + "MappingKernelManager": { + "cull_idle_timeout": 900, + "cull_interval": 60 + } +} diff --git a/build/lib/optimade_client/default_parameters.py b/build/lib/optimade_client/default_parameters.py new file mode 100644 index 00000000..33d9872b --- /dev/null +++ b/build/lib/optimade_client/default_parameters.py @@ -0,0 +1,51 @@ +"""Default initialization parameters + +The lists are set, based on the status of providers from +https://www.optimade.org/providers-dashboard/. + +If the provider has no available databases, it should be put into the SKIP_PROVIDERS list, +meaning it will not be supported. +Providers in the DISABLE_PROVIDERS list are ones the client should support, +but cannot because of one issue or another. +""" + +SKIP_PROVIDERS = [ + "exmpl", + "optimade", + "aiida", + "ccpnc", + "matcloud", + "necro", + "httk", + "pcod", +] + +DISABLE_PROVIDERS = [ + "cod", + "tcod", + "nmd", + "oqmd", + "aflow", + "mpds", + "jarvis", +] + +PROVIDER_DATABASE_GROUPINGS = { + "Materials Cloud": { + "Main Projects": ["mc3d", "mc2d"], + "Contributed Projects": [ + "2dtopo", + "pyrene-mofs", + "scdm", + "stoceriaitf", + "tc-applicability", + "tin-antimony-sulfoiodide", + "curated-cofs", + ], + } +} + + +SKIP_DATABASE = { + "Materials Cloud": ["optimade-sample", "li-ion-conductors", "sssp"], +} diff --git a/build/lib/optimade_client/exceptions.py b/build/lib/optimade_client/exceptions.py new file mode 100644 index 00000000..140a0d04 --- /dev/null +++ b/build/lib/optimade_client/exceptions.py @@ -0,0 +1,156 @@ +from typing import Any, List, Union, Tuple, Sequence + +from optimade.models import Resource + +from optimade_client.logger import LOGGER + + +class OptimadeClientError(Exception): + """Top-most exception class for OPTIMADE Client""" + + def __init__(self, *args: Tuple[Any]): + LOGGER.error( + "%s raised.\nError message: %s\nAbout this exception: %s", + args[0].__class__.__name__ + if args and isinstance(args[0], Exception) + else self.__class__.__name__, + str(args[0]) if args else "", + args[0].__doc__ + if args and isinstance(args[0], Exception) + else self.__doc__, + ) + super().__init__(*args) + + +class ApiVersionError(OptimadeClientError): + """API Version Error + The API version does not have the correct semantic. + The API version cannot be recognized. + """ + + +class NonExistent(OptimadeClientError): + """Non Existent + An entity does not exist, e.g. a URL location. + """ + + +class InputError(OptimadeClientError): + """Input Error + Base input error exception. + Wrong input to a method. + """ + + +class DisplayInputError(InputError): + """Display Input Error + The input to display method cannot be used. + If 'all' is True, 'part' must be None. + """ + + +class NotOkResponse(OptimadeClientError): + """Did not receive a `200 OK` response""" + + +class OptimadeToolsError(OptimadeClientError): + """Base error related to `optimade-python-tools` (`optimade` package)""" + + +class AdaptersError(OptimadeToolsError): + """Base error related to `optimade.adapters` module""" + + +class WrongPymatgenType(AdaptersError): + """Wrong `pymatgen` type, either `Structure` or `Molecule` was needed instead""" + + +class ParserError(OptimadeClientError): + """Error during FilterInputParser parsing""" + + def __init__( + self, + msg: str = None, + field: str = None, + value: Any = None, + extras: Union[Sequence[Tuple[str, Any]], Tuple[str, Any]] = None, + ): + self.field = field if field is not None else "General (no field given)" + self.value = value if value is not None else "" + self.extras = extras if extras is not None else [] + self.msg = msg if msg is not None else "A general error occured during parsing." + + super().__init__( + f""" +{self.__class__.__name__} + Message: {self.msg} + Field: {self.field!r}, Value: {self.value!r} + Extras: {self.extras!r}""" + ) + + +class ImplementationError(OptimadeClientError): + """Base error related to the current OPTIMADE implementation being handled/queried""" + + +class BadResource(ImplementationError): + """Resource does not fulfill requirements from supported version of the OPTIMADE API spec""" + + def __init__( # pylint: disable=too-many-arguments + self, resource: Resource, msg: str = None, fields: Union[List[str], str] = None + ): + self.resource = resource + self.fields = fields if fields is not None else [] + self.msg = ( + msg + if msg is not None + else ( + "A bad resource broke my flow: " + f"" + ) + ) + + if not isinstance(self.fields, list): + self.fields = [self.fields] + + self.values = [] + for field in self.fields: + value = getattr(self.resource, field, None) + + if value is None: + value = getattr(self.resource.attributes, field, None) + + if value is None: + # Cannot find value for field + value = f"" + + self.values.append(value) + + fields_msg = "\n".join( + [ + f" Field: {field!r}, Value: {value!r}" + for field, value in zip(self.fields, self.values) + ] + ) + super().__init__( + f""" +{self.__class__.__name__} + + Message: {self.msg} +{fields_msg}""" + ) + + +class QueryError(ImplementationError): + """Error while querying specific implementation (or provider)""" + + def __init__(self, msg: str = None, remove_target: bool = False): + msg = msg if msg is not None else "" + self.remove_target = remove_target + + super().__init__( + f""" +{self.__class__.__name__} + Message: {msg} + Remove target: {self.remove_target!r}""" + ) diff --git a/build/lib/optimade_client/img/optimade-text-right-transparent-bg.png b/build/lib/optimade_client/img/optimade-text-right-transparent-bg.png new file mode 100644 index 00000000..1bf697dc Binary files /dev/null and b/build/lib/optimade_client/img/optimade-text-right-transparent-bg.png differ diff --git a/build/lib/optimade_client/informational.py b/build/lib/optimade_client/informational.py new file mode 100644 index 00000000..8599baba --- /dev/null +++ b/build/lib/optimade_client/informational.py @@ -0,0 +1,391 @@ +import logging +import os +from pathlib import Path +import shutil +from typing import Union +from urllib.parse import urlencode + +import ipywidgets as ipw + +from optimade_client.logger import LOG_DIR, LOGGER, REPORT_HANDLER, WIDGET_HANDLER +from optimade_client.utils import __optimade_version__, ButtonStyle, CACHE_DIR + + +IMG_DIR = Path(__file__).parent.joinpath("img") +SOURCE_URL = "https://github.com/CasperWA/voila-optimade-client/" + + +class HeaderDescription(ipw.VBox): + """Top logo and description of the OPTIMADE Client + + Special buttons are needed for reporting, hence HTML widgets are instantiated. + Each button is also different from each other, hence the templates below are either + HTML-encoded or not. + The bug report button utilizes the special REPORT_LOGGER, which stays below a certain maximum + number of bytes-length (of logs), in order to not surpass the allowed URL length for GitHub and + get an errornous 414 response. + After some testing I am estimating the limit to be at 8 kB. + The suggestion report button utilizes instead the HTML Form element to "submit" the GitHub issue + template. While an actual markdown template could be used, it seems GitHub is coercing its users + to create these templates via their GUI and the documentation for creating them directly in the + repository is disappearing. Hence, I have chosen to use templates in this manner instead, where + I have more control over them. + """ + + HEADER = f"""

+Currently valid OPTIMADE API version: v{__optimade_version__[0]}
+Client version: 2022.9.19
+Source code: GitHub +

+""" + DESCRIPTION = f"""

+This is a friendly client to search through databases and other implementations exposing an OPTIMADE RESTful API. +To get more information about the OPTIMADE API, +please see the offical web page. +All providers are retrieved from the OPTIMADE consortium's list of providers. +

+

+Note: The structure property assemblies is currently not supported. +Follow the issue on GitHub to learn more. +

+""" + BUG_TEMPLATE = { + "title": "[BUG] - TITLE", + "body": ( + "## Bug description\n\nWhat happened?\n\n" + "### Expected behaviour (optional)\n\nWhat should have happened?\n\n" + "### Actual behavior (optional)\n\nWhat happened instead?\n\n" + "## Reproducibility (optional)\n\nHow may it be reproduced?\n\n" + "### For developers (do not alter this section)\n" + ), + } + SUGGESTION_TEMPLATE = { + "title": "[FEATURE/CHANGE] - TITLE", + "body": ( + "## Feature/change description\n\n" + "What should it be able to do? Or what should be changed?\n\n" + "### Reasoning (optional)\n\n" + "Why is this feature or change needed?" + ), + } + + def __init__( + self, logo: str = None, button_style: Union[ButtonStyle, str] = None, **kwargs + ): + logo = logo if logo is not None else "optimade-text-right-transparent-bg.png" + logo = self._get_file(str(IMG_DIR.joinpath(logo))) + logo = ipw.Image(value=logo, format="png", width=375, height=137.5) + + if button_style: + if isinstance(button_style, str): + button_style = ButtonStyle[button_style.upper()] + elif isinstance(button_style, ButtonStyle): + pass + else: + raise TypeError( + "button_style should be either a string or a ButtonStyle Enum. " + f"You passed type {type(button_style)!r}." + ) + else: + button_style = ButtonStyle.DEFAULT + + header = ipw.HTML(self.HEADER) + + # Hidden input HTML element, storing the log + self._debug_log = REPORT_HANDLER.get_widget() + self.report_bug = ipw.HTML( + f""" +""" + ) + self.report_suggestion = ipw.HTML( + f""" +
+ + +
""" + ) + reports = ipw.HBox( + children=( + ipw.HTML( + '

' + "Help improve the application:

" + ), + self.report_bug, + self.report_suggestion, + ), + ) + + description = ipw.HTML(self.DESCRIPTION) + + super().__init__( + children=(self._debug_log, logo, header, reports, description), + layout=ipw.Layout(width="auto"), + **kwargs, + ) + + def freeze(self): + """Disable widget""" + self.report_suggestion.disabled = True + self._debug_log.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + self.report_suggestion.disabled = False + self._debug_log.unfreeze() + + def reset(self): + """Reset widget""" + self.report_suggestion.disabled = False + self._debug_log.reset() + + @staticmethod + def _get_file(filename: str) -> Union[str, bytes]: + """Read and return file""" + path = Path(filename).resolve() + LOGGER.debug("Trying image file path: %s", str(path)) + if path.exists() and path.is_file(): + with open(path, "rb") as file_handle: + res = file_handle.read() + return res + + LOGGER.debug("File %s either does not exist or is not a file", str(path)) + return "" + + +class OptimadeClientFAQ(ipw.Accordion): + """A "closed" accordion with FAQ about the OPTIMADE Client""" + + FAQ = [ + { + "Q": "Why is a given provider not shown in the client?", + "A": """

The most likely reason is that they have not yet registered with the OPTIMADE consortium's list of providers repository. +Please contact the given provider and let them know they can register themselves there.

""", + }, + { + "Q": "Why is the provider I wish to use greyed out and disabled?", + "A": """

There may be different reasons:

+
    +
  • The provider has not supplied a link to an OPTIMADE index meta-database.
  • +
  • The provider has implemented an unsupported specification version.
  • +
  • The provider has supplied a link that could not be reached.
  • +
  • The provider claims to implement a supported specification version, but certain required features are not fully implemented.
  • +
+

Please go to the OPTIMADE consortium's list of providers repository to update the provider in question's details.

""", + }, + { + "Q": "When I choose a provider, why can I not find any databases?", + "A": """

There may be different reasons:

+
    +
  • The provider does not have a /structures endpoint.
  • +
  • The implementation is of an unsupported version.
  • +
  • The implementation could not be reached.
  • +
+

An implementation may also be removed upon the user choosing it. This is due to OPTIMADE API version incompatibility between the implementation and this client.

""", + }, + { + "Q": "I know a database hosts X number of structures, why can I only find Y?", + "A": f"""

All searches (including the raw input search) will be pre-processed prior to sending the query. +This is done to ensure the best experience when using the client. +Specifically, all structures with "assemblies" and "unknown_positions" (for pre-v1 implementations) in the "structural_features" property are excluded.

+

"assemblies" handling will be implemented at a later time. +See this issue for more information.

+

Finally, a provider may choose to expose only a subset of their database.

""", + }, + { + "Q": "Why are some downloadable formats greyed out and disabled for certain structures?", + "A": """

Currently, only two libraries are used to transform the OPTIMADE structure into other known data types:

+ +

ASE does not support transforming structures with partial occupancies, hence the options using ASE will be disabled when such structures are chosen in the application. +There are plans to also integrate pymatgen, however, the exact integration is still under design.

""", + }, + ] + + def __init__(self, **kwargs): + faq = self._write_faq() + super().__init__(children=(faq,), **kwargs) + self.set_title(0, "FAQ") + self.selected_index = None + + def freeze(self): + """Disable widget""" + + def unfreeze(self): + """Activate widget (in its current state)""" + + def reset(self): + """Reset widget""" + self.selected_index = None + + def _write_faq(self) -> ipw.HTML: + """Generate FAQ HTML""" + value = "" + for faq in self.FAQ: + if value == "": + value += f'

{faq["Q"]}

\n{faq["A"]}' + else: + value += ( + '\n\n

' + f'{faq["Q"]}

\n{faq["A"]}' + ) + return ipw.HTML(value) + + +class OptimadeLog(ipw.Accordion): + """Accordion containing non-editable log output""" + + def __init__(self, **kwargs): + self._debug = bool(os.environ.get("OPTIMADE_CLIENT_DEBUG", False)) + + self.toggle_debug = ipw.Checkbox( + value=self._debug, + description="Show DEBUG messages", + disabled=False, + indent=False, + width="auto", + height="auto", + ) + self.clear_cache = ipw.Button( + description="Clear cache", + disabled=False, + tooltip="Clear cached responses (not logs)", + icon="cube", + layout={ + "visibility": "visible" if self._debug else "hidden", + "width": "auto", + }, + ) + self.clear_logs = ipw.Button( + description="Clear logs", + disabled=False, + tooltip="Clear all log history", + icon="edit", + layout={ + "visibility": "visible" if self._debug else "hidden", + "width": "auto", + }, + ) + self.log_output = WIDGET_HANDLER.get_widget() + super().__init__( + children=( + ipw.VBox( + children=( + ipw.HBox( + children=( + self.toggle_debug, + self.clear_cache, + self.clear_logs, + ), + layout={"height": "auto", "width": "auto"}, + ), + self.log_output, + ) + ), + ), + **kwargs, + ) + self.set_title(0, "Log") + self.selected_index = 0 if self._debug else None + + self.toggle_debug.observe(self._toggle_debug_logging, names="value") + self.clear_cache.on_click(self._clear_cache) + self.clear_logs.on_click(self._clear_logs) + + def freeze(self): + """Disable widget""" + self.toggle_debug.disabled = True + self.log_output.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + self.toggle_debug.disabled = False + self.log_output.unfreeze() + + def reset(self): + """Reset widget""" + self.selected_index = None + self.toggle_debug.value = self._debug + self.toggle_debug.disabled = False + self.log_output.reset() + + def _toggle_debug_logging(self, change: dict): + """Set logging level depending on toggle button""" + if change["new"]: + # Set logging level DEBUG + WIDGET_HANDLER.setLevel(logging.DEBUG) + LOGGER.info("Set log output in widget to level DEBUG") + LOGGER.debug("This should now be shown") + + # Show debug buttons + self.clear_cache.layout.visibility = "visible" + self.clear_logs.layout.visibility = "visible" + else: + # Set logging level to INFO + WIDGET_HANDLER.setLevel(logging.INFO) + LOGGER.info("Set log output in widget to level INFO") + LOGGER.debug("This should now NOT be shown") + + # Hide debug buttons + self.clear_cache.layout.visibility = "hidden" + self.clear_logs.layout.visibility = "hidden" + + @staticmethod + def _clear_cache(_): + """Clear cached responses (not logs)""" + if str(LOG_DIR).startswith(str(CACHE_DIR)): + log_sub_dir = list(Path(str(LOG_DIR)[len(f"{CACHE_DIR}/") :]).parts) + + LOGGER.debug( + "Cache dir: %s - Log dir: %s - Log sub dir parts: %s", + CACHE_DIR, + LOG_DIR, + log_sub_dir, + ) + + for dirpath, dirnames, filenames in os.walk(CACHE_DIR): + log_dir_part = log_sub_dir.pop(0) if log_sub_dir else "" + if not log_sub_dir: + LOGGER.debug( + "No more log sub directory parts. Removing %r from dirnames list.", + log_dir_part, + ) + dirnames.remove(log_dir_part) + + for directory in list(dirnames): + if directory == log_dir_part: + continue + LOGGER.debug( + "Removing folder: %s", Path(dirpath).joinpath(directory).resolve() + ) + shutil.rmtree( + Path(dirpath).joinpath(directory).resolve(), ignore_errors=True + ) + dirnames.remove(directory) + for filename in filenames: + LOGGER.debug( + "Removing file: %s", Path(dirpath).joinpath(filename).resolve() + ) + os.remove(Path(dirpath).joinpath(filename).resolve()) + CACHE_DIR.mkdir(parents=True, exist_ok=True) + + def _clear_logs(self, _): + """Clear all logs""" + shutil.rmtree(LOG_DIR, ignore_errors=True) + LOG_DIR.mkdir(parents=True, exist_ok=True) + self.log_output.reset() diff --git a/build/lib/optimade_client/logger.py b/build/lib/optimade_client/logger.py new file mode 100644 index 00000000..fafdf171 --- /dev/null +++ b/build/lib/optimade_client/logger.py @@ -0,0 +1,317 @@ +"""Logging to both file and widget""" +import logging +from logging.handlers import RotatingFileHandler +import os +from pathlib import Path +from typing import List +import urllib.parse +import warnings + +import appdirs +import ipywidgets as ipw + + +LOG_DIR = Path(appdirs.user_log_dir("optimade-client", "CasperWA")) +LOG_DIR.mkdir(parents=True, exist_ok=True) + +LOG_FILE = LOG_DIR / "optimade_client.log" + + +# This coloring formatter is inspired heavily from: +# https://stackoverflow.com/questions/384076/how-can-i-color-python-logging-output +BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) + +# ANSI escape sequences. +# The color is set with 30 plus the number of the color above. +# The addition is done in the Formatter. +# See https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters for more info. +RESET_SEQ = "\033[0m" +COLOR_SEQ = "\033[%dm" # Can instead be "\033[;m" +BOLD_SEQ = "\033[1m" + +COLORS = { + "CRITICAL": YELLOW, + "ERROR": RED, + "WARNING": MAGENTA, + "INFO": GREEN, + "DEBUG": BLUE, + "NOTSET": BLACK, +} + + +def apply_correct_formatter_sequences(message: str): + """Replace human-readable bash-like variables with correct sequences""" + mapping = { + "$RESET": RESET_SEQ, + "$COLOR": COLOR_SEQ, + "$BOLD": BOLD_SEQ, + } + for variable in mapping: + message = message.replace(variable, mapping[variable]) + return message + + +class ColoredFormatter(logging.Formatter): + """Formatter used for widget outputs""" + + def __init__(self, fmt=None, datefmt=None, style="%"): + if fmt and isinstance(fmt, str): + fmt = apply_correct_formatter_sequences(fmt) + super().__init__(fmt=fmt, datefmt=datefmt, style=style) + + def format(self, record: logging.LogRecord): + """Overrule the same logging.Formatter method + + In order to avoid changing the record, for other logger instances, + the record is restored to its original state before returning. + """ + levelname = record.levelname + if levelname in COLORS: + levelname_color = ( + COLOR_SEQ % (30 + COLORS[levelname]) + levelname + RESET_SEQ + ) + record.levelname = levelname_color + + colored_record = super().format(record=record) + record.levelname = levelname + + return colored_record + + +class OutputLogger(ipw.Output): + """The widget to go with the handler""" + + def __init__(self, **kwargs): + layout = { + "width": "auto", + "min_height": "160px", + "max_height": "240px", + "border": "1px solid black", + "overflow": "hidden auto", # "Internal" scrolling + } + super().__init__(layout=layout) + + def freeze(self): + """Disable widget""" + + def unfreeze(self): + """Activate widget (in its current state)""" + + def reset(self): + """Reset widget""" + self.clear_output() + + +class OutputLoggerHandler(logging.Handler): + """Custom logging handler sending logs to an output widget + Inspired by: + https://ipywidgets.readthedocs.io/en/latest/examples/Output%20Widget.html#Integrating-output-widgets-with-the-logging-module + """ + + def __init__(self): + super().__init__() + self.out = OutputLogger() + + def emit(self, record: logging.LogRecord): + """Overrule the same logging.Handler method""" + formatted_record = self.format(record) + new_output = { + "name": "log", + "output_type": "stream", + "text": f"{formatted_record}\n", + } + self.out.outputs = (new_output,) + self.out.outputs + + def get_widget(self): + """Return the IPyWidget""" + return self.out + + +class ReportLogger(ipw.HTML): + """The widget to go with the handler""" + + WRAPPED_LOGS = """""" + WRAPPED_VALUE = ( # Post-urlencoded + "%3Cdetails%3E%0A++%3Csummary%3ELog+dump%3C%2Fsummary%3E%0A%0A++%60%60%60%0A{logs}++" + "%60%60%60%0A%3C%2Fdetails%3E%0A%0A" + ) + MAX_BYTES = 7400 + + def __init__(self, value: str = None, **kwargs): + self._element_id = "report_log" + self._logs = [] + self._truncated = False + super().__init__(self.clear_logs(), **kwargs) + + @staticmethod + def freeze(): + """Disable widget""" + LOGGER.debug("Freeze 'ReportLogger'.") + + @staticmethod + def unfreeze(): + """Activate widget (in its current state)""" + LOGGER.debug("Unfreeze 'ReportLogger'.") + + @staticmethod + def reset(): + """Reset widget""" + LOGGER.debug("Reset 'ReportLogger'.") + + def clear_logs(self) -> str: + """Clear logs, i.e., input element's value attribute""" + self._logs = [] + return self._update_logs() + + def _update_logs(self) -> str: + """Wrap log messages, i.e., use self.wrapped_log to set self.value""" + return self.WRAPPED_LOGS.format( + value=self.WRAPPED_VALUE.format(logs="".join(self.logs)), + element_id=self.element_id, + ) + + @staticmethod + def _urlencode_string(string: str) -> str: + """URL encode, while adding specific encoding as well + + Specific encoding: + - GitHub wants to turn all spaces into '+'. + This is actually already taken care of, since urlencode uses 'quote_plus'. + """ + res = urllib.parse.urlencode({"value": string}, encoding="utf-8") + return res[len("value=") :] + + def log(self, message: str): + """Log a message, i.e., add it to the input element's value attribute""" + # Remove any surrounding new-line invocations (so we can implement our own) + while message.endswith("\n"): + message = message[:-2] + while message.startswith("\n"): + message = message[2:] + + # Put all messages within the GitHub Markdown accordion + message = self._urlencode_string(f" {message}\n") + + # Truncate logs to not send a too long URI and receive a 414 response from GitHub + note_truncation = self._urlencode_string("...") + message_truncation = self._urlencode_string(f" {note_truncation}\n") + suggested_log = "".join(self.logs) + message + while len(suggested_log) > self.MAX_BYTES: + # NOTE: It is expected that the first log message will never be longer than MAX_BYTES + if len(self.logs) == 1: + # The single latest message is too large, cut it down + new_line = "%0A" + truncation_length = ( + len(message_truncation) + len(note_truncation) + len(new_line) + ) + message = ( + f"{message[:self.MAX_BYTES - truncation_length]}{note_truncation}" + f"{new_line}" + ) + break + + if not self._truncated: + # Add a permanent "log" message to show the list of logs is incomplete + self._truncated = True + self.logs.insert(0, message_truncation) + + self.logs.pop(1) + suggested_log = f"{''.join(self.logs)}{message}" + + self.logs.append(message) + self.value = self._update_logs() + + @property + def logs(self) -> List[str]: + """Return list of currently saved log messages""" + return self._logs + + @logs.setter + def logs(self, _): # pylint: disable=no-self-use + """Do not allow adding logs this way""" + msg = ( + "Will not change 'logs'. Logs should be added through the 'OPTIMADE_Client' logger, " + "using the 'logging' module." + ) + LOGGER.warning("Message: %r", msg) + warnings.warn(msg) + + @property + def element_id(self) -> str: + """Return the input element's id""" + return self._element_id + + @element_id.setter + def element_id(self, _): + """Do not allow changing the input element's id""" + if self._element_id: + msg = ( + "Can not set 'element_id', since it is already set " + ) + LOGGER.warning("Message: %r", msg) + warnings.warn(msg) + + +class ReportLoggerHandler(logging.Handler): + """Custom logging handler sending logs to an output widget + Inspired by: + https://ipywidgets.readthedocs.io/en/latest/examples/Output%20Widget.html#Integrating-output-widgets-with-the-logging-module + """ + + def __init__(self): + super().__init__() + self.out = ReportLogger() + + def emit(self, record: logging.LogRecord): + """Overrule the same logging.Handler method""" + formatted_record = self.format(record) + self.out.log(formatted_record) + + def get_widget(self): + """Return the IPyWidget""" + return self.out + + +# Instantiate LOGGER +LOGGER = logging.getLogger("OPTIMADE_Client") +LOGGER.setLevel(logging.DEBUG) + +# Save a file with all messages (DEBUG level) +FILE_HANDLER = RotatingFileHandler(LOG_FILE, maxBytes=1000000, backupCount=5) +FILE_HANDLER.setLevel(logging.DEBUG) + +# Write to Output widget (INFO level is default, overrideable with environment variable) +WIDGET_HANDLER = OutputLoggerHandler() +if os.environ.get("OPTIMADE_CLIENT_DEBUG", None) is None: + # Default - INFO + WIDGET_HANDLER.setLevel(logging.INFO) +else: + # OPTIMADE_CLIENT_DEBUG set - DEBUG + WIDGET_HANDLER.setLevel(logging.DEBUG) + +# Write to HTML widget (DEBUG level - for bug reporting) +REPORT_HANDLER = ReportLoggerHandler() +REPORT_HANDLER.setLevel(logging.DEBUG) + +# Set formatters +FILE_FORMATTER = logging.Formatter( + "[%(levelname)-8s %(asctime)s %(filename)s:%(lineno)d] %(message)s", + "%d-%m-%Y %H:%M:%S", +) +FILE_HANDLER.setFormatter(FILE_FORMATTER) + +WIDGET_FORMATTER = ColoredFormatter( + "$BOLD[%(asctime)s %(levelname)-5s]$RESET %(message)s", "%H:%M:%S" +) +WIDGET_HANDLER.setFormatter(WIDGET_FORMATTER) + +REPORT_FORMATTER = logging.Formatter( # Minimize and mimic FILE_FORMATTER + "[%(levelname)s %(asctime)s %(filename)s:%(lineno)d] %(message)s", "%H:%M:%S" +) +REPORT_HANDLER.setFormatter(REPORT_FORMATTER) + +# Finalize LOGGER +LOGGER.addHandler(WIDGET_HANDLER) +LOGGER.addHandler(FILE_HANDLER) +LOGGER.addHandler(REPORT_HANDLER) diff --git a/build/lib/optimade_client/query_filter.py b/build/lib/optimade_client/query_filter.py new file mode 100644 index 00000000..c2698eb2 --- /dev/null +++ b/build/lib/optimade_client/query_filter.py @@ -0,0 +1,607 @@ +from enum import Enum, auto +from typing import List, Union +import traceback +import traitlets +import ipywidgets as ipw +import requests +from json import JSONDecodeError + +from optimade.adapters import Structure +from optimade.adapters.structures.utils import species_from_species_at_sites +from optimade.models import LinksResourceAttributes +from optimade.models.utils import CHEMICAL_SYMBOLS, SemanticVersion + +from optimade_client.exceptions import BadResource, QueryError +from optimade_client.logger import LOGGER +from optimade_client.subwidgets import ( + FilterTabs, + ResultsPageChooser, + SortSelector, + StructureDropdown, +) +from optimade_client.utils import ( + ButtonStyle, + check_entry_properties, + handle_errors, + ordered_query_url, + perform_optimade_query, + get_sortable_fields, + SESSION, + TIMEOUT_SECONDS, +) + + +class QueryFilterWidgetOrder(Enum): + """Order of filter query widget parts""" + + filter_header = auto() + filters = auto() + query_button = auto() + structures_header = auto() + structure_drop = auto() + sort_selector = auto() + error_or_status_messages = auto() + structure_page_chooser = auto() + + @classmethod + def default_order( + cls, as_str: bool = True + ) -> List[Union[str, "QueryFilterWidgetOrder"]]: + """Get the default order of filter query widget parts""" + default_order = [ + cls.filter_header, + cls.filters, + cls.query_button, + cls.structures_header, + cls.sort_selector, + cls.structure_page_chooser, + cls.structure_drop, + cls.error_or_status_messages, + ] + return [_.name for _ in default_order] if as_str else default_order + + +class OptimadeQueryFilterWidget( # pylint: disable=too-many-instance-attributes + ipw.VBox +): + """Structure search and import widget for OPTIMADE + + NOTE: Only supports offset- and number-pagination at the moment. + """ + + structure = traitlets.Instance(Structure, allow_none=True) + database = traitlets.Tuple( + traitlets.Unicode(), + traitlets.Instance(LinksResourceAttributes, allow_none=True), + ) + + def __init__( + self, + result_limit: int = None, + button_style: Union[ButtonStyle, str] = None, + embedded: bool = False, + subparts_order: List[str] = None, + **kwargs, + ): + self.page_limit = result_limit if result_limit else 25 + if button_style: + if isinstance(button_style, str): + button_style = ButtonStyle[button_style.upper()] + elif isinstance(button_style, ButtonStyle): + pass + else: + raise TypeError( + "button_style should be either a string or a ButtonStyle Enum. " + f"You passed type {type(button_style)!r}." + ) + else: + button_style = ButtonStyle.PRIMARY + + subparts_order = subparts_order or QueryFilterWidgetOrder.default_order( + as_str=True + ) + + self.offset = 0 + self.number = 1 + self._data_available = None + self.__perform_query = True + self.__cached_ranges = {} + self.__cached_versions = {} + self.database_version = "" + + self.filter_header = ipw.HTML( + '

Apply filters

' + ) + self.filters = FilterTabs(show_large_filters=not embedded) + self.filters.freeze() + self.filters.on_submit(self.retrieve_data) + + self.query_button = ipw.Button( + description="Search", + button_style=button_style.value, + icon="search", + disabled=True, + tooltip="Search - No database chosen", + ) + self.query_button.on_click(self.retrieve_data) + + self.structures_header = ipw.HTML( + '

Results

' + ) + + self.sort_selector = SortSelector(disabled=True) + self.sorting = self.sort_selector.value + self.sort_selector.observe(self._sort, names="value") + + self.structure_drop = StructureDropdown(disabled=True) + self.structure_drop.observe(self._on_structure_select, names="value") + self.error_or_status_messages = ipw.HTML("") + + self.structure_page_chooser = ResultsPageChooser(self.page_limit) + self.structure_page_chooser.observe( + self._get_more_results, names=["page_link", "page_offset", "page_number"] + ) + + for subpart in subparts_order: + if not hasattr(self, subpart): + raise ValueError( + f"Wrongly specified subpart_order: {subpart!r}. Available subparts " + f"(and default order): {QueryFilterWidgetOrder.default_order(as_str=True)}" + ) + + super().__init__( + children=[getattr(self, _) for _ in subparts_order], + layout=ipw.Layout(width="auto", height="auto"), + **kwargs, + ) + + @traitlets.observe("database") + def _on_database_select(self, _): + """Load chosen database""" + self.structure_drop.reset() + + if ( + self.database[1] is None + or getattr(self.database[1], "base_url", None) is None + ): + self.query_button.tooltip = "Search - No database chosen" + self.freeze() + else: + self.offset = 0 + self.number = 1 + self.structure_page_chooser.silent_reset() + try: + self.freeze() + + self.query_button.description = "Updating ..." + self.query_button.icon = "cog" + self.query_button.tooltip = "Updating filters ..." + + self._set_intslider_ranges() + self._set_version() + except Exception as exc: # pylint: disable=broad-except + LOGGER.error( + "Exception raised during setting IntSliderRanges: %s", + exc.with_traceback(), + ) + finally: + self.query_button.description = "Search" + self.query_button.icon = "search" + self.query_button.tooltip = "Search" + self.sort_selector.valid_fields = sorted( + get_sortable_fields(self.database[1].base_url) + ) + self.unfreeze() + + def _on_structure_select(self, change): + """Update structure trait with chosen structure dropdown value""" + chosen_structure = change["new"] + if chosen_structure is None: + self.structure = None + with self.hold_trait_notifications(): + self.structure_drop.index = 0 + else: + self.structure = chosen_structure["structure"] + + def _get_more_results(self, change): + """Query for more results according to pageing""" + if not self.__perform_query: + self.__perform_query = True + LOGGER.debug( + "NOT going to perform query with change: name=%s value=%s", + change["name"], + change["new"], + ) + return + + pageing: Union[int, str] = change["new"] + LOGGER.debug( + "Updating results with pageing change: name=%s value=%s", + change["name"], + pageing, + ) + if change["name"] == "page_offset": + self.offset = pageing + pageing = None + elif change["name"] == "page_number": + self.number = pageing + pageing = None + else: + # It is needed to update page_offset, but we do not wish to query again + with self.hold_trait_notifications(): + self.__perform_query = False + self.structure_page_chooser.update_offset() + + try: + # Freeze and disable list of structures in dropdown widget + # We don't want changes leading to weird things happening prior to the query ending + self.freeze() + + # Update button text and icon + self.query_button.description = "Updating ... " + self.query_button.icon = "cog" + self.query_button.tooltip = "Please wait ..." + + # Query database + response = self._query(pageing) + msg, _ = handle_errors(response) + if msg: + self.error_or_status_messages.value = msg + return + + # Update list of structures in dropdown widget + self._update_structures(response["data"]) + + # Update pageing + self.structure_page_chooser.set_pagination_data( + links_to_page=response.get("links", {}), + ) + + finally: + self.query_button.description = "Search" + self.query_button.icon = "search" + self.query_button.tooltip = "Search" + self.unfreeze() + + def _sort(self, change: dict) -> None: + """Perform new query with new sorting""" + sort = change["new"] + self.sorting = sort + self.retrieve_data({}) + + def freeze(self): + """Disable widget""" + self.query_button.disabled = True + self.filters.freeze() + self.structure_drop.freeze() + self.structure_page_chooser.freeze() + self.sort_selector.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + self.query_button.disabled = False + self.filters.unfreeze() + self.structure_drop.unfreeze() + self.structure_page_chooser.unfreeze() + self.sort_selector.unfreeze() + + def reset(self): + """Reset widget""" + self.offset = 0 + self.number = 1 + with self.hold_trait_notifications(): + self.query_button.disabled = False + self.query_button.tooltip = "Search - No database chosen" + self.filters.reset() + self.structure_drop.reset() + self.structure_page_chooser.reset() + self.sort_selector.reset() + + def _uses_new_structure_features(self) -> bool: + """Check whether self.database_version is >= v1.0.0-rc.2""" + critical_version = SemanticVersion("1.0.0-rc.2") + version = SemanticVersion(self.database_version) + + LOGGER.debug("Semantic version: %r", version) + + if version.base_version > critical_version.base_version: + return True + + if version.base_version == critical_version.base_version: + if version.prerelease: + return version.prerelease >= critical_version.prerelease + + # Version is bigger than critical version and is not a pre-release + return True + + # Major.Minor.Patch is lower than critical version + return False + + def _set_version(self): + """Set self.database_version from an /info query""" + base_url = self.database[1].base_url + if base_url not in self.__cached_versions: + # Retrieve and cache version + response = perform_optimade_query( + base_url=self.database[1].base_url, endpoint="/info" + ) + msg, _ = handle_errors(response) + if msg: + raise QueryError(msg) + + if "meta" not in response: + raise QueryError( + f"'meta' field not found in /info endpoint for base URL: {base_url}" + ) + if "api_version" not in response["meta"]: + raise QueryError( + f"'api_version' field not found in 'meta' for base URL: {base_url}" + ) + + version = response["meta"]["api_version"] + if version.startswith("v"): + version = version[1:] + self.__cached_versions[base_url] = version + LOGGER.debug( + "Cached version %r for base URL: %r", + self.__cached_versions[base_url], + base_url, + ) + + self.database_version = self.__cached_versions[base_url] + + def _set_intslider_ranges(self): + """Update IntRangeSlider ranges according to chosen database + + Query database to retrieve ranges. + Cache ranges in self.__cached_ranges. + """ + defaults = { + "nsites": {"min": 0, "max": 10000}, + "nelements": {"min": 0, "max": len(CHEMICAL_SYMBOLS)}, + } + + db_base_url = self.database[1].base_url + if db_base_url not in self.__cached_ranges: + self.__cached_ranges[db_base_url] = {} + + sortable_fields = check_entry_properties( + base_url=db_base_url, + entry_endpoint="structures", + properties=["nsites", "nelements"], + checks=["sort"], + ) + + for response_field in sortable_fields: + if response_field in self.__cached_ranges[db_base_url]: + # Use cached value(s) + continue + + page_limit = 1 + + new_range = {} + for extremum, sort in [ + ("min", response_field), + ("max", f"-{response_field}"), + ]: + query_params = { + "base_url": db_base_url, + "page_limit": page_limit, + "response_fields": response_field, + "sort": sort, + } + LOGGER.debug( + "Querying %s to get %s of %s.\nParameters: %r", + self.database[0], + extremum, + response_field, + query_params, + ) + + response = perform_optimade_query(**query_params) + msg, _ = handle_errors(response) + if msg: + raise QueryError(msg) + + if not response.get("meta", {}).get("data_available", 0): + new_range[extremum] = defaults[response_field][extremum] + else: + new_range[extremum] = ( + response.get("data", [{}])[0] + .get("attributes", {}) + .get(response_field, None) + ) + + # Cache new values + LOGGER.debug( + "Caching newly found range values for %s\nValue: %r", + db_base_url, + {response_field: new_range}, + ) + self.__cached_ranges[db_base_url].update({response_field: new_range}) + + if not self.__cached_ranges[db_base_url]: + LOGGER.debug("No values found for %s, storing default values.", db_base_url) + self.__cached_ranges[db_base_url].update( + { + "nsites": {"min": 0, "max": 10000}, + "nelements": {"min": 0, "max": len(CHEMICAL_SYMBOLS)}, + } + ) + + # Set widget's new extrema + LOGGER.debug( + "Updating range extrema for %s\nValues: %r", + db_base_url, + self.__cached_ranges[db_base_url], + ) + self.filters.update_range_filters(self.__cached_ranges[db_base_url]) + + def _query(self, link: str = None) -> dict: + """Query helper function""" + # If a complete link is provided, use it straight up + if link is not None: + try: + link = ordered_query_url(link) + response = SESSION.get(link, timeout=TIMEOUT_SECONDS) + if response.from_cache: + LOGGER.debug("Request to %s was taken from cache !", link) + response = response.json() + except ( + requests.exceptions.ConnectTimeout, + requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, + ) as exc: + response = { + "errors": { + "msg": "CLIENT: Connection error or timeout.", + "url": link, + "Exception": repr(exc), + } + } + except JSONDecodeError as exc: + response = { + "errors": { + "msg": "CLIENT: Could not decode response to JSON.", + "url": link, + "Exception": repr(exc), + } + } + return response + + # Avoid structures with null positions and with assemblies. + add_to_filter = 'NOT structure_features HAS ANY "assemblies"' + if not self._uses_new_structure_features(): + add_to_filter += ',"unknown_positions"' + + optimade_filter = self.filters.collect_value() + optimade_filter = ( + "( {} ) AND ( {} )".format(optimade_filter, add_to_filter) + if optimade_filter and add_to_filter + else optimade_filter or add_to_filter or None + ) + LOGGER.debug("Querying with filter: %s", optimade_filter) + + # OPTIMADE queries + queries = { + "base_url": self.database[1].base_url, + "filter": optimade_filter, + "page_limit": self.page_limit, + "page_offset": self.offset, + "page_number": self.number, + "sort": self.sorting, + } + LOGGER.debug( + "Parameters (excluding filter) sent to query util func: %s", + {key: value for key, value in queries.items() if key != "filter"}, + ) + + return perform_optimade_query(**queries) + + @staticmethod + def _check_species_mass(structure: dict) -> dict: + """Ensure species.mass is using OPTIMADE API v1.0.1 type""" + if structure.get("attributes", {}).get("species", False): + for species in structure["attributes"][ + "species" + ] or species_from_species_at_sites( + structure["attributes"]["species_at_sites"] + ): + if not isinstance(species.get("mass", None), (list, type(None))): + species.pop("mass", None) + return structure + + def _update_structures(self, data: list): + """Update structures dropdown from response data""" + structures = [] + + for entry in data: + # XXX: THIS IS TEMPORARY AND SHOULD BE REMOVED ASAP + entry["attributes"]["chemical_formula_anonymous"] = None + + structure = Structure(self._check_species_mass(entry)) + + formula = structure.attributes.chemical_formula_descriptive + if formula is None: + formula = structure.attributes.chemical_formula_reduced + if formula is None: + formula = structure.attributes.chemical_formula_anonymous + if formula is None: + formula = structure.attributes.chemical_formula_hill + if formula is None: + raise BadResource( + resource=structure, + fields=[ + "chemical_formula_descriptive", + "chemical_formula_reduced", + "chemical_formula_anonymous", + "chemical_formula_hill", + ], + msg="At least one of the following chemical formula fields " + "should have a valid value", + ) + + entry_name = f"{formula} (id={structure.id})" + structures.append((entry_name, {"structure": structure})) + + # Update list of structures in dropdown widget + self.structure_drop.set_options(structures) + + def retrieve_data(self, _): + """Perform query and retrieve data""" + self.offset = 0 + self.number = 1 + try: + # Freeze and disable list of structures in dropdown widget + # We don't want changes leading to weird things happening prior to the query ending + self.freeze() + + # Reset the error or status message + if self.error_or_status_messages.value: + self.error_or_status_messages.value = "" + + # Update button text and icon + self.query_button.description = "Querying ... " + self.query_button.icon = "cog" + self.query_button.tooltip = "Please wait ..." + + # Query database + response = self._query() + msg, _ = handle_errors(response) + if msg: + self.error_or_status_messages.value = msg + raise QueryError(msg) + + # Update list of structures in dropdown widget + self._update_structures(response["data"]) + + # Update pageing + if self._data_available is None: + self._data_available = response.get("meta", {}).get( + "data_available", None + ) + data_returned = response.get("meta", {}).get( + "data_returned", len(response.get("data", [])) + ) + self.structure_page_chooser.set_pagination_data( + data_returned=data_returned, + data_available=self._data_available, + links_to_page=response.get("links", {}), + reset_cache=True, + ) + + except QueryError: + self.structure_drop.reset() + self.structure_page_chooser.reset() + raise + + except Exception as exc: + self.structure_drop.reset() + self.structure_page_chooser.reset() + raise QueryError(f"Bad stuff happened: {traceback.format_exc()}") from exc + + finally: + self.query_button.description = "Search" + self.query_button.icon = "search" + self.query_button.tooltip = "Search" + self.unfreeze() diff --git a/build/lib/optimade_client/query_provider.py b/build/lib/optimade_client/query_provider.py new file mode 100644 index 00000000..5fdb0b87 --- /dev/null +++ b/build/lib/optimade_client/query_provider.py @@ -0,0 +1,117 @@ +from typing import Union, Tuple, List, Dict, Optional +import warnings + +import ipywidgets as ipw +import traitlets + +from optimade.models import LinksResourceAttributes + +from optimade_client.subwidgets import ( + ProviderImplementationChooser, + ProviderImplementationSummary, +) +from optimade_client.warnings import OptimadeClientWarning +from optimade_client.default_parameters import ( + PROVIDER_DATABASE_GROUPINGS, + SKIP_DATABASE, + SKIP_PROVIDERS, + DISABLE_PROVIDERS, +) + + +class OptimadeQueryProviderWidget(ipw.GridspecLayout): + """Database/Implementation search and chooser widget for OPTIMADE + + NOTE: Only supports offset-pagination at the moment. + """ + + database = traitlets.Tuple( + traitlets.Unicode(), + traitlets.Instance(LinksResourceAttributes, allow_none=True), + default_value=("", None), + ) + + def __init__( + self, + embedded: bool = False, + database_limit: Optional[int] = None, + width_ratio: Optional[Union[Tuple[int, int], List[int]]] = None, + width_space: Optional[int] = None, + disable_providers: Optional[List[str]] = None, + skip_providers: Optional[List[str]] = None, + skip_databases: Optional[List[str]] = None, + provider_database_groupings: Optional[Dict[str, Dict[str, List[str]]]] = None, + **kwargs, + ): + # At the moment, the pagination does not work properly as each database is not tested for + # validity immediately, only when each "page" is loaded. This can result in the pagination + # failing. Instead the default is set to 100 in an attempt to never actually do paging. + database_limit = ( + database_limit if database_limit and database_limit > 0 else 100 + ) + disable_providers = disable_providers or DISABLE_PROVIDERS + skip_providers = skip_providers or SKIP_PROVIDERS + skip_databases = skip_databases or SKIP_DATABASE + provider_database_groupings = ( + provider_database_groupings or PROVIDER_DATABASE_GROUPINGS + ) + + layout = ipw.Layout(width="100%", height="auto") + + self.chooser = ProviderImplementationChooser( + child_db_limit=database_limit, + disable_providers=disable_providers, + skip_providers=skip_providers, + skip_databases=skip_databases, + provider_database_groupings=provider_database_groupings, + **kwargs, + ) + + self.summary = ProviderImplementationSummary(**kwargs) if not embedded else None + + if embedded: + super().__init__(n_rows=1, n_columns=1, layout=layout, **kwargs) + self[:, :] = self.chooser + else: + if width_ratio is not None and isinstance(width_ratio, (tuple, list)): + if len(width_ratio) != 2 or sum(width_ratio) <= 0: + width_ratio = (10, 21) + warnings.warn( + "width_ratio is not a list or tuple of length 2. " + f"Will use defaults {width_ratio}.", + OptimadeClientWarning, + ) + else: + width_ratio = (10, 21) + + width_space = width_space if width_space is not None else 1 + + super().__init__( + n_rows=1, n_columns=sum(width_ratio), layout=layout, **kwargs + ) + self[:, : width_ratio[0]] = self.chooser + self[:, width_ratio[0] + width_space :] = self.summary + + ipw.dlink((self.chooser, "provider"), (self.summary, "provider")) + ipw.dlink( + (self.chooser, "database"), + (self.summary, "database"), + transform=(lambda db: db[1] if db and db is not None else None), + ) + + ipw.dlink((self.chooser, "database"), (self, "database")) + + def freeze(self): + """Disable widget""" + for widget in self.children: + widget.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + for widget in self.children: + widget.unfreeze() + + def reset(self): + """Reset widget""" + for widget in self.children: + widget.reset() diff --git a/build/lib/optimade_client/subwidgets/__init__.py b/build/lib/optimade_client/subwidgets/__init__.py new file mode 100644 index 00000000..35236922 --- /dev/null +++ b/build/lib/optimade_client/subwidgets/__init__.py @@ -0,0 +1,19 @@ +# pylint: disable=undefined-variable +from .filter_inputs import * # noqa: F403 +from .multi_checkbox import * # noqa: F403 +from .output_summary import * # noqa: F403 +from .periodic_table import * # noqa: F403 +from .provider_database import * # noqa: F403 +from .results import * # noqa: F403 +from .sort_selector import * # noqa: F403 + + +__all__ = ( + filter_inputs.__all__ # noqa: F405 + + multi_checkbox.__all__ # noqa: F405 + + output_summary.__all__ # noqa: F405 + + periodic_table.__all__ # noqa: F405 + + provider_database.__all__ # noqa: F405 + + results.__all__ # noqa: F405 + + sort_selector.__all__ # noqa: F405 +) diff --git a/build/lib/optimade_client/subwidgets/filter_inputs.py b/build/lib/optimade_client/subwidgets/filter_inputs.py new file mode 100644 index 00000000..0094e861 --- /dev/null +++ b/build/lib/optimade_client/subwidgets/filter_inputs.py @@ -0,0 +1,627 @@ +# pylint: disable=too-many-arguments +from typing import Dict, List, Union, Tuple, Callable, Any + +import ipywidgets as ipw +import traitlets + +from optimade.models.utils import CHEMICAL_SYMBOLS + +from optimade_client.exceptions import ParserError +from optimade_client.logger import LOGGER +from optimade_client.subwidgets.intrangeslider import CustomIntRangeSlider +from optimade_client.subwidgets.multi_checkbox import MultiCheckboxes +from optimade_client.subwidgets.periodic_table import PeriodicTable + + +__all__ = ("FilterTabs",) + + +class FilterTabs(ipw.Tab): + """Separate filter inputs into tabs""" + + def __init__(self, show_large_filters: bool = True): + sections: Tuple[Tuple[str, FilterTabSection]] = ( + ("Basic", FilterInputs(show_large_filters=show_large_filters)), + # ("Advanced", ipw.HTML("This input tab has not yet been implemented.")), + ("Raw", FilterRaw(show_large_filters=show_large_filters)), + ) + + super().__init__( + children=tuple(_[1] for _ in sections), + layout={"width": "auto", "height": "auto"}, + ) + for index, title in enumerate([_[0] for _ in sections]): + self.set_title(index, title) + + self.observe(self.on_tab_change, names="selected_index") + + def freeze(self): + """Disable widget""" + for widget in self.children: + if not isinstance(widget, ipw.HTML): + widget.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + for widget in self.children: + if not isinstance(widget, ipw.HTML): + widget.unfreeze() + + def reset(self): + """Reset widget""" + for widget in self.children: + if not isinstance(widget, ipw.HTML): + widget.reset() + + def collect_value(self) -> str: + """Collect inputs to a single OPTIMADE filter query string""" + active_widget = self.children[self.selected_index] + if not isinstance(active_widget, ipw.HTML): + return active_widget.collect_value() + return "" + + def on_submit(self, callback, remove=False): + """(Un)Register a callback to handle text submission""" + for section_widget in self.children: + section_widget.on_submit(callback=callback, remove=remove) + + def update_range_filters(self, data: Dict[str, dict]): + """Update filter widgets with a range (e.g., IntRangeSlider) according to `data`""" + for section_widget in self.children: + section_widget.range_nx = data + + def on_tab_change(self, change): + # If tab is changed from "Basic" to "Raw", + # populate the corresponding raw query from the "Basic" tab + if ( + change["name"] == "selected_index" + and change["old"] == 0 + and change["new"] == 1 + ): + basic_query_string = self.children[0].collect_value() + self.children[1].set_value(basic_query_string) + + +class FilterTabSection(ipw.VBox): + """Base class for a filter tab section""" + + range_nx = traitlets.Dict(allow_none=True) + + def __init__(self, show_large_filters: bool = True, **kwargs): + super().__init__(**kwargs) + self._show_large_filters = show_large_filters + + @traitlets.observe("range_nx") + def update_ranged_inputs(self, change: dict): + """Update ranged inputs' min/max values""" + + def collect_value(self) -> str: + """Collect inputs to a single OPTIMADE filter query string""" + + def on_submit(self, callback, remove=False): + """(Un)Register a callback to handle user input submission""" + + +class FilterRaw(FilterTabSection): + """Filter inputs for raw input""" + + def __init__(self, **kwargs): + self.inputs = [ + FilterInput( + description="Filter", + hint="Raw 'filter' query string ...", + description_width="50px", + ) + ] + + info_text = ipw.HTML( + "Raw string is generated from the active filters in the 'Basic' tab.
" + "Further modifications can be made, but they will be not be reflected in the 'Basic' tab." + ) + + super().__init__( + children=[info_text] + self.inputs, layout={"width": "auto"}, **kwargs + ) + + def reset(self): + """Reset widget""" + for user_input in self.inputs: + user_input.reset() + + def freeze(self): + """Disable widget""" + for user_input in self.inputs: + user_input.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + for user_input in self.inputs: + user_input.unfreeze() + + def collect_value(self) -> str: + """Collect inputs to a single OPTIMADE filter query string""" + filter_ = self.inputs[0] + return filter_.get_user_input.strip() + + def on_submit(self, callback, remove=False): + """(Un)Register a callback to handle user input submission""" + for user_input in self.inputs: + user_input.on_submit(callback=callback, remove=remove) + + def set_value(self, value): + self.inputs[0].set_value(value) + + +class FilterInput(ipw.HBox): + """Combination of HTML and input widget for filter inputs + + :param kwargs: Keyword arguments passed on to `input_widget` + """ + + def __init__( + self, + description: str, + input_widget: Callable = None, + hint: str = None, + description_width: str = None, + layout: dict = None, + **kwargs, + ): + _description_width = ( + description_width if description_width is not None else "170px" + ) + description = ipw.HTML(description, layout={"width": _description_width}) + _layout = layout if layout is not None else {"width": "100%"} + + self.input_widget = ( + input_widget(layout=_layout, **kwargs) + if input_widget is not None + else ipw.Text(layout=_layout) + ) + + if hint and isinstance(self.input_widget, ipw.widgets.widget_string._String): + self.input_widget.placeholder = hint + + super().__init__( + children=(description, self.input_widget), layout={"width": "auto"} + ) + + @property + def get_user_input(self): + """The Widget.value""" + try: + if not isinstance(self.input_widget, CustomIntRangeSlider): + res = self.input_widget.value + else: + res = self.input_widget.get_value() + except AttributeError as exc: + raise ParserError( + msg="Correct attribute can not be found to retrieve widget value", + extras=[("Widget", self.input_widget)], + ) from exc + return res + + def reset(self): + """Reset widget""" + with self.hold_trait_notifications(): + self.input_widget.value = "" + self.input_widget.disabled = False + + def freeze(self): + """Disable widget""" + self.input_widget.disabled = True + + def unfreeze(self): + """Activate widget (in its current state)""" + self.input_widget.disabled = False + + def on_submit(self, callback, remove=False): + """(Un)Register a callback to handle text submission""" + if isinstance(self.input_widget, ipw.Text): + self.input_widget._submission_callbacks.register_callback( # pylint: disable=protected-access + callback, remove=remove + ) + + def set_value(self, value): + if isinstance(self.input_widget, ipw.Text): + self.input_widget.value = value + else: + raise NotImplementedError + + +class FilterInputParser: + """Parse user input for filters""" + + def parse(self, key: str, value: Any) -> Tuple[Any, Union[None, str]]: + """Reroute to self.(value)""" + func = getattr(self, key, None) + if func is None: + return self.__default__(value) + return func(value) + + @staticmethod + def __default__(value: Any) -> Tuple[Any, None]: + """Default parsing fallback function""" + return value, None + + @staticmethod + def default_string_filter(value: str) -> Tuple[str, None]: + """Default handling of string filters + + Remove any superfluous whitespace at the beginning and end of string values. + Remove embedded `"` and wrap the value in `"` (if the value is supplied). + """ + value = value.strip() + value = value.replace('"', "") + res = f'"{value}"' if value else "" + return res, None + + def id(self, value: str) -> Tuple[str, None]: # pylint: disable=invalid-name + """id is a string input""" + return self.default_string_filter(value) + + def chemical_formula_descriptive(self, value: str) -> Tuple[str, None]: + """Chemical formula descriptive is a free form input""" + return self.default_string_filter(value) + + @staticmethod + def nperiodic_dimensions(value: List[bool]) -> Tuple[List[int], None]: + """Return list of nperiodic_dimensions values according to checkbox choices""" + res = [] + for include, periodicity in zip(value, range(4)): + if include: + res.append(periodicity) + return res, None + + @staticmethod + def ranged_int( + field: str, value: Tuple[Union[int, None], Union[int, None]] + ) -> Union[str, List[str]]: + """Turn IntRangeSlider widget value into OPTIMADE filter string""" + LOGGER.debug("ranged_int: Received value %r for field %r", value, field) + + low, high = value + res = "" + if low is None or high is None: + if low is not None: + res = f">={low}" + if high is not None: + res = f"<={high}" + elif low == high: + # Exactly N of property + res = f"={low}" + else: + # Range of property + res = [f">={low}", f"<={high}"] + + LOGGER.debug("ranged_int: Concluded the response is %r", res) + + return res + + def nsites( + self, value: Tuple[Union[int, None], Union[int, None]] + ) -> Tuple[Union[List[str], str], None]: + """Operator with integer values""" + return self.ranged_int("nsites", value), None + + def nelements( + self, value: Tuple[Union[int, None], Union[int, None]] + ) -> Tuple[Union[List[str], str], None]: + """Operator with integer values""" + return self.ranged_int("nelements", value), None + + @staticmethod + def elements( + value: Tuple[bool, Dict[str, int]] + ) -> Tuple[Union[List[str], List[Tuple[str]]], List[str]]: + """Extract included and excluded elements""" + use_all = value[0] + ptable_value = value[1] + + include = [] + exclude = [] + for element, state in ptable_value.items(): + if state == 0: + # Include + include.append(element) + elif state == 1: + # Exclude + exclude.append(element) + + LOGGER.debug( + "elements: With value %r the following are included: %r. And excluded: %r", + value, + include, + exclude, + ) + + values = [] + operators = [] + if exclude: + elements = ",".join([f'"{element}"' for element in exclude]) + values.append(("NOT", elements)) + operators.append(" HAS ANY ") + if include: + include_elements = ",".join([f'"{element}"' for element in include]) + values.append(include_elements) + operators.append(" HAS ALL " if use_all else " HAS ANY ") + + LOGGER.debug( + "elements: Resulting parsed operator(s): %r and value(s): %r", + operators, + values, + ) + + return values, operators + + +class FilterInputs(FilterTabSection): + """Filter inputs in a single widget""" + + provider_section = traitlets.List() + + FILTER_SECTIONS = [ + ( + "Chemistry", + [ + ( + "chemical_formula_descriptive", + { + "description": "Chemical Formula", + "hint": "e.g., (H2O)2 Na", + }, + ), + ( + "elements", + { + "description": "Elements", + "input_widget": PeriodicTable, + "states": 2, # Included/Excluded + "unselected_color": "#5096f1", # Blue + "selected_colors": ["#66BB6A", "#EF5350"], # Green, red + "border_color": "#000000", # Black + "extended": "self._show_large_filters", + }, + ), + ( + "nelements", + { + "description": "Number of Elements", + "input_widget": CustomIntRangeSlider, + "min": 0, + "max": len(CHEMICAL_SYMBOLS), + "value": (0, len(CHEMICAL_SYMBOLS)), + }, + ), + ], + ), + ( + "Cell", + [ + ( + "nperiodic_dimensions", + { + "description": "Dimensionality", + "input_widget": MultiCheckboxes, + "descriptions": ["Molecule", "Wire", "Planar", "Bulk"], + }, + ), + ( + "nsites", + { + "description": "Number of Sites", + "input_widget": CustomIntRangeSlider, + "min": 0, + "max": 10000, + "value": (0, 10000), + }, + ), + ], + ), + ( + "Provider specific", + [ + ( + "id", + { + "description": "Provider ID", + "hint": "NB! Will take precedence", + }, + ) + ], + ), + ] + + OPERATOR_MAP = { + "chemical_formula_descriptive": " CONTAINS ", + "elements": " HAS ANY ", + "nperiodic_dimensions": "=", + "lattice_vectors": " HAS ANY ", + "id": "=", + } + + def __init__(self, **kwargs): + self._show_large_filters = kwargs.get("show_large_filters", True) + self.query_fields: Dict[str, FilterInput] = {} + self._layout = ipw.Layout(width="auto") + + sections = [ + self.new_section(title, inputs) for title, inputs in self.FILTER_SECTIONS + ] + + # Remove initial line-break + sections[0].children[0].value = sections[0].children[0].value[len("
") :] + + super().__init__(children=sections, layout=self._layout, **kwargs) + + def reset(self): + """Reset widget""" + for user_input in self.query_fields.values(): + user_input.reset() + + def freeze(self): + """Disable widget""" + for user_input in self.query_fields.values(): + user_input.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + for user_input in self.query_fields.values(): + user_input.unfreeze() + + @traitlets.observe("range_nx") + def update_ranged_inputs(self, change: dict): + """Update ranged inputs' min/max values""" + ranges = change["new"] + if not ranges or ranges is None: + return + + for field, config in ranges.items(): + if field not in self.query_fields: + raise ParserError( + field=field, + value="N/A", + extras=[ + ("config", config), + ("self.query_fields.keys", self.query_fields.keys()), + ], + msg="Provided field is unknown. Can not update range for unknown field.", + ) + + widget = self.query_fields[field].input_widget + cached_value: Tuple[int, int] = widget.value + for attr in ("min", "max"): + if attr in config and config[attr] is not None: + try: + new_value = int(config[attr]) + except (TypeError, ValueError) as exc: + raise ParserError( + field=field, + value=cached_value, + extras=[("attr", attr), ("config[attr]", config[attr])], + msg=f"Could not cast config[attr] to int. Exception: {exc!s}", + ) from exc + + LOGGER.debug( + "Setting %s for %s to %d.\nWidget immediately before: %r", + attr, + field, + new_value, + widget, + ) + + # Since "min" is always set first, to be able to set "min" to a valid value, + # "max" is first set to the new "min" value + 1 IF the new "min" value is + # larger than the current "max" value, otherwise there is no reason, + # and it may indeed lead to invalid attribute setting, if this is done. + # For "max", coming last, this should then be fine, as the new "min" and "max" + # values should never be an invalid pair. + if attr == "min" and new_value > cached_value[1]: + widget.max = new_value + 1 + + setattr(widget, attr, new_value) + + LOGGER.debug("Updated widget %r:\n%r", attr, widget) + + widget.value = (widget.min, widget.max) + + LOGGER.debug("Final state, updated widget:\n%r", widget) + + def update_provider_section(self): + """Update the provider input section from the chosen provider""" + # schema = get_structures_schema(self.base_url) + + def new_section( + self, title: str, inputs: List[Tuple[str, Union[str, Dict[str, Any]]]] + ) -> ipw.VBox: + """Generate a new filter section""" + header = ipw.HTML(f"
{title}") + user_inputs = [] + for user_input in inputs: + input_config = user_input[1] + if isinstance(input_config, dict): + for key, value in input_config.items(): + if isinstance(value, str) and value.startswith("self."): + input_config[key] = getattr(self, value[len("self.") :]) + new_input = FilterInput(**input_config) + else: + new_input = FilterInput(field=input_config) + user_inputs.append(new_input) + + self.query_fields[user_input[0]] = new_input + + user_inputs.insert(0, header) + return ipw.VBox(children=user_inputs, layout=self._layout) + + def _collect_value(self) -> str: + """Collect inputs to a single OPTIMADE filter query string""" + parser = FilterInputParser() + + result = [] + for field, user_input in self.query_fields.items(): + parsed_value, parsed_operator = parser.parse( + field, user_input.get_user_input + ) + if not parsed_value: + # If the parsed value results in an empty value, skip field + continue + if not parsed_operator: + # Use default operator if none is parsed + parsed_operator = self.OPERATOR_MAP.get(field, "") + + if isinstance(parsed_value, (list, tuple, set)): + for index, value in enumerate(parsed_value): + inverse = "" + if isinstance(value, tuple) and value[0] == "NOT": + inverse = "NOT " + value = value[1] + operator = ( + parsed_operator[index] + if isinstance(parsed_operator, (list, tuple, set)) + else parsed_operator + ) + result.append(f"{inverse}{field}{operator}{value}") + elif isinstance(parsed_value, str): + operator = ( + parsed_operator[0] + if isinstance(parsed_operator, (list, tuple, set)) + else parsed_operator + ) + result.append(f"{field}{operator}{parsed_value}") + else: + raise ParserError( + field=field, + value=user_input.get_user_input, + msg="Parsed value was neither a list, tuple, set nor str and it wasn't empty " + "or None.", + extras=( + "parsed_value", + parsed_value, + "parsed_operator", + parsed_operator, + ), + ) + + result = " AND ".join(result) + return result.replace("'", '"') # OPTIMADE Filter grammar only supports " not ' + + def collect_value(self) -> str: + """Collect inputs, while reporting if an error occurs""" + try: + res = self._collect_value() + except ParserError: + raise + except Exception as exc: + import traceback + + raise ParserError( + msg=f"An exception occurred during collection of filter inputs: {exc!r}", + extras=("traceback", traceback.print_exc(exc)), + ) from exc + else: + return res + + def on_submit(self, callback, remove=False): + """(Un)Register a callback to handle user input submission""" + for user_input in self.query_fields.values(): + user_input.on_submit(callback=callback, remove=remove) diff --git a/build/lib/optimade_client/subwidgets/intrangeslider.py b/build/lib/optimade_client/subwidgets/intrangeslider.py new file mode 100644 index 00000000..8bc929d2 --- /dev/null +++ b/build/lib/optimade_client/subwidgets/intrangeslider.py @@ -0,0 +1,13 @@ +from typing import Tuple, Union + +from ipywidgets import IntRangeSlider + + +class CustomIntRangeSlider(IntRangeSlider): + """An IntRangeSlider that will not return values if equal to min/max""" + + def get_value(self) -> Tuple[Union[int, None], Union[int, None]]: + """Retrieve value, making them `None` if they're equal to the extremas.""" + min_ = None if self.min == self.value[0] else self.value[0] + max_ = None if self.max == self.value[1] else self.value[1] + return min_, max_ diff --git a/build/lib/optimade_client/subwidgets/multi_checkbox.py b/build/lib/optimade_client/subwidgets/multi_checkbox.py new file mode 100644 index 00000000..ad593daa --- /dev/null +++ b/build/lib/optimade_client/subwidgets/multi_checkbox.py @@ -0,0 +1,93 @@ +from typing import List + +import ipywidgets as ipw + + +__all__ = ("MultiCheckboxes",) + + +class MultiCheckboxes(ipw.Box): + """Multiple checkboxes widget""" + + def __init__( + self, + values: List[bool] = None, + descriptions: List[str] = None, + disabled: bool = False, + **kwargs, + ): + if (values and not isinstance(values, (list, tuple, set))) or ( + descriptions and not isinstance(descriptions, (list, tuple, set)) + ): + raise TypeError("values and descriptions must be of type list") + if values is not None: + values = list(values) + if descriptions is not None: + descriptions = list(descriptions) + + if values is None and descriptions is not None: + values = [False] * len(descriptions) + elif values is not None and descriptions is None: + descriptions = [f"Option {i + 1}" for i in range(len(values))] + elif values is not None and descriptions is not None: + if len(values) != len(descriptions): + raise ValueError( + "values and descriptions must be lists of equal length." + f"values: {values}, descriptions: {descriptions}" + ) + else: + values = descriptions = [] + + self._disabled = disabled + + self.checkboxes = [] + for value, description in zip(values, descriptions): + self.checkboxes.append( + ipw.Checkbox( + value=value, + description=description, + indent=False, + disabled=self._disabled, + layout={ + "flex": "0 1 auto", + "width": "auto", + "height": "auto", + }, + ) + ) + + super().__init__( + children=self.checkboxes, + layout=kwargs.get( + "layout", + { + "display": "flex", + "flex-flow": "row wrap", + "justify-content": "center", + "align-items": "center", + "align-content": "flex-start", + "width": "auto", + "height": "auto", + }, + ), + ) + + @property + def value(self) -> List[bool]: + """Get list of values of checkboxes""" + return [_.value for _ in self.checkboxes] + + @property + def disabled(self) -> bool: + """Whether or not the checkboxes are disabled""" + return self._disabled + + @disabled.setter + def disabled(self, value: bool): + """Set disabled value for all checkboxes""" + if not isinstance(value, bool): + raise TypeError("disabled must be a boolean") + + self._disabled = value + for checkbox in self.checkboxes: + checkbox.disabled = self._disabled diff --git a/build/lib/optimade_client/subwidgets/output_summary.py b/build/lib/optimade_client/subwidgets/output_summary.py new file mode 100644 index 00000000..1f37b46d --- /dev/null +++ b/build/lib/optimade_client/subwidgets/output_summary.py @@ -0,0 +1,371 @@ +import re +from typing import Match, List, Dict, Optional +import ipywidgets as ipw +import traitlets + +import pandas as pd + +from optimade.adapters import Structure +from optimade.models import Species +from optimade.models.structures import Vector3D + + +__all__ = ("StructureSummary", "StructureSites") + + +NOT_AVAILABLE_MSG = "Not available in the DB for this structure" + + +def calc_cell_volume(cell: List[Vector3D]) -> float: + """ + Calculates the volume of a cell given the three lattice vectors. + + It is calculated as cell[0] . (cell[1] x cell[2]), where . represents + a dot product and x a cross product. + + NOTE: Taken from `aiida-core` at aiida.orm.nodes.data.structure + + :param cell: the cell vectors; the must be a 3x3 list of lists of floats, + no other checks are done. + + :returns: the cell volume. + """ + if cell: + # returns the volume of the primitive cell: |a_1 . (a_2 x a_3)| + a_1 = cell[0] + a_2 = cell[1] + a_3 = cell[2] + a_mid_0 = a_2[1] * a_3[2] - a_2[2] * a_3[1] + a_mid_1 = a_2[2] * a_3[0] - a_2[0] * a_3[2] + a_mid_2 = a_2[0] * a_3[1] - a_2[1] * a_3[0] + return abs(a_1[0] * a_mid_0 + a_1[1] * a_mid_1 + a_1[2] * a_mid_2) + return 0.0 + + +class StructureSummary(ipw.VBox): + """Structure Summary Output + Show structure data as a set of HTML widgets in a VBox widget. + """ + + structure: Optional[Structure] = traitlets.Instance(Structure, allow_none=True) + + _output_format = "{title}: {value}" + _widget_data = { + "Chemical formula": ipw.HTML(), + "Elements": ipw.HTML(), + "Number of sites": ipw.HTML(), + "Unit cell volume": ipw.HTML(), + "Unit cell": ipw.HTML(), + } + + def __init__(self, structure: Structure = None, **kwargs): + super().__init__(children=tuple(self._widget_data.values()), **kwargs) + self.observe(self._on_change_structure, names="structure") + self.structure = structure + + def _on_change_structure(self, change: dict): + """Update output according to change in `data`""" + if not isinstance(change["new"], Structure): + self.reset() + return + self._update_output() + + def _update_output(self): + """Update widget values in self._widget_data""" + data = self._extract_data_from_structure() + for field, widget in self._widget_data.items(): + widget.value = self._output_format.format( + title=field, value=data.get(field, "") + ) + + def freeze(self): + """Disable widget""" + + def unfreeze(self): + """Activate widget (in its current state)""" + + def reset(self): + """Reset widget""" + for widget in self._widget_data.values(): + widget.value = "" + + def _extract_data_from_structure(self) -> dict: + """Extract and return desired data from Structure""" + return { + "Chemical formula": self._add_style(self._chemical_formula()), + "Elements": ( + self._add_style( + ", ".join(sorted(self.structure.attributes.elements or [])) + ) + if self.structure.attributes.elements + else NOT_AVAILABLE_MSG + ), + "Number of sites": ( + self._add_style(str(self.structure.attributes.nsites or "")) + if self.structure.attributes.nsites + else NOT_AVAILABLE_MSG + ), + "Unit cell": ( + self._unit_cell(self.structure.attributes.lattice_vectors or []) + if self.structure.attributes.lattice_vectors + else NOT_AVAILABLE_MSG + ), + "Unit cell volume": ( + f"{self._add_style('%.2f' % calc_cell_volume(self.structure.attributes.lattice_vectors or []))}" + " Å3" + if self.structure.attributes.lattice_vectors + else NOT_AVAILABLE_MSG + ), + } + + @staticmethod + def _add_style(html_value: str) -> str: + """Wrap 'html_value' with HTML CSS style""" + return ( + f'' + f"{html_value}" + ) + + @property + def _chemical_formula_priority(self) -> List[str]: + """Return the desired priority order for chemical_formulas""" + return [ + "chemical_formula_descriptive", + "chemical_formula_reduced", + "chemical_formula_hill", + ] + + def _chemical_formula(self) -> str: + """Format chemical formula to look pretty with ipywidgets.HTMLMath""" + + for formula_type in self._chemical_formula_priority: + formula: str = getattr(self.structure.attributes, formula_type, None) + if formula: + break + else: + return NOT_AVAILABLE_MSG + + def wrap_number(number: Match) -> str: + return f"{number.group(0)}" + + return re.sub(r"[0-9]+", repl=wrap_number, string=formula) + + @staticmethod + def _unit_cell(unitcell: List[Vector3D]) -> str: + """Format unit cell to HTML table""" + style = """ + +""" + pd.set_option("max_colwidth", 100) + + format_unit_cell = [] + for number, vector in enumerate(unitcell): + format_unit_cell.append( + ( + f"v{number + 1}", + f"{vector[0]:.5f}", + f"{vector[1]:.5f}", + f"{vector[2]:.5f}", + ) + ) + + data_frame = pd.DataFrame( + format_unit_cell, columns=["v", "x (Å)", "y (Å)", "z (Å)"] + ) + return style + data_frame.to_html( + classes="df_uc", + index=False, + table_id="unit_cell", + notebook=False, + escape=False, + ) + + @staticmethod + def _unit_cell_mathjax(unitcell: list) -> str: + """Format unit cell to look pretty with ipywidgets.HTMLMath""" + out = r"$\Bigl(\begin{smallmatrix} " + for i in range(len(unitcell[0]) - 1): + row = [] + for vector in unitcell: + row.append(vector[i]) + out += r" & ".join([f"{_:.5f}" for _ in row]) + out += r" \\ " + row = [] + for vector in unitcell: + row.append(vector[-1]) + out += r" & ".join([str(_) for _ in row]) + out += r" \end{smallmatrix} \Bigr)$" + + return out + + +class StructureSites(ipw.HTML): + """Structure Sites Output + Reimplements the viewer for AiiDA Dicts (from AiiDAlab) + """ + + structure = traitlets.Instance(Structure, allow_none=True) + + def __init__(self, structure: Structure = None, **kwargs): + # For more information on how to control the table appearance please visit: + # https://css-tricks.com/complete-guide-table-element/ + # + # Voila doesn't run the scripts, which should set a color upon choosing a row. + # Furthermore, if it will ever work, one can remove the hover definition in the css styling. + self._script = """ +var row_color; + +function index(el) { + if (!el) return -1; + var i = 0; + do { + i++; + } while (el = el.previousElementSibling); + return i; +} + +function updateRowBackground(row) { + if (row_color != 'rgb(244, 151, 184)') { + row.style.backgroundColor = '#f497b8'; + } else { + if ( index(row) % 2 == 0) { + // even + row.style.backgroundColor = 'white'; + } else { + // odd + row.style.backgroundColor = '#e5e7e9'; + } + } + row_color = getComputedStyle(row)['backgroundColor']; +} + +function doYourStuff() { + [].forEach.call( document.querySelectorAll('tbody > tr'), function(el) { + el.addEventListener('click', function() { + updateRowBackground(el); + }, false); + }); + [].forEach.call( document.querySelectorAll('tbody > tr'), function(el) { + el.addEventListener('mouseover', function() { + row_color = getComputedStyle(el)['backgroundColor']; + this.style.backgroundColor = '#f5b7b1'; + }, false); + }); + [].forEach.call( document.querySelectorAll('tbody > tr'), function(el) { + el.addEventListener('mouseout', function() { + el.style.backgroundColor = row_color; + }, false); + }); +}; + +document.addEventListener('DOMContentLoaded', function() { + doYourStuff(); +}); +// doYourStuff() +""" + self._style = """ + +""" + pd.set_option("max_colwidth", 100) + + super().__init__(layout=ipw.Layout(width="auto", height="auto"), **kwargs) + self.observe(self._on_change_structure, names="structure") + self.structure = structure + + def _on_change_structure(self, change: dict): + """When traitlet 'structure' is updated""" + if change["new"] is None: + self.reset() + else: + self.value = self._style + if all( + getattr(self.structure.attributes, field, None) + for field in [ + "species", + "nsites", + "species_at_sites", + "cartesian_site_positions", + ] + ): + dataf = pd.DataFrame( + self._format_sites(), + columns=["Elements", "Occupancy", "x (Å)", "y (Å)", "z (Å)"], + ) + self.value += dataf.to_html( + classes="df", index=False, table_id="sites", notebook=False + ) + else: + self.value += NOT_AVAILABLE_MSG + + def freeze(self): + """Disable widget""" + + def unfreeze(self): + """Activate widget (in its current state)""" + + def reset(self): + """Reset widget""" + self.value = "" + + def _format_sites(self) -> List[str]: + """Format OPTIMADE Structure into list of formatted HTML strings + Columns: + - Elements + - Occupancy + - Position (x) + - Position (y) + - Position (z) + """ + species: Dict[str, Species] = {_.name: _ for _ in self.structure.species.copy()} + + res = [] + for site_number in range(self.structure.nsites): + symbol_name = self.structure.species_at_sites[site_number] + + for index, symbol in enumerate(species[symbol_name].chemical_symbols): + if symbol == "vacancy": + species[symbol_name].chemical_symbols.pop(index) + species[symbol_name].concentration.pop(index) + break + + res.append( + ( + ", ".join(species[symbol_name].chemical_symbols), + ", ".join( + [ + f"{occupation:.2f}" + for occupation in species[symbol_name].concentration + ] + ), + f"{self.structure.cartesian_site_positions[site_number][0]:.5f}", + f"{self.structure.cartesian_site_positions[site_number][1]:.5f}", + f"{self.structure.cartesian_site_positions[site_number][2]:.5f}", + ) + ) + return res diff --git a/build/lib/optimade_client/subwidgets/periodic_table.py b/build/lib/optimade_client/subwidgets/periodic_table.py new file mode 100644 index 00000000..b3a90d94 --- /dev/null +++ b/build/lib/optimade_client/subwidgets/periodic_table.py @@ -0,0 +1,105 @@ +import ipywidgets as ipw + +from widget_periodictable import PTableWidget + +from optimade_client.logger import LOGGER +from optimade_client.utils import ButtonStyle + + +__all__ = ("PeriodicTable",) + + +class PeriodicTable(ipw.VBox): + """Wrapper-widget for PTableWidget""" + + def __init__(self, extended: bool = True, **kwargs): + self._disabled = kwargs.get("disabled", False) + + self.toggle_button = ipw.ToggleButton( + value=extended, + description="Hide Periodic Table" if extended else "Show Periodic Table", + button_style=ButtonStyle.INFO.value, + icon="flask", + tooltip="Hide Periodic Table" if extended else "Show Periodic Table", + layout={"width": "auto"}, + ) + self.select_any_all = ipw.Checkbox( + value=False, + description="Structures can include any chosen elements (instead of all)", + indent=False, + layout={"width": "auto"}, + disabled=self.disabled, + ) + self.ptable = PTableWidget(**kwargs) + self.ptable_container = ipw.VBox( + children=(self.select_any_all, self.ptable), + layout={ + "width": "auto", + "height": "auto" if extended else "0px", + "visibility": "visible" if extended else "hidden", + }, + ) + + self.toggle_button.observe(self._toggle_widget, names="value") + + super().__init__( + children=(self.toggle_button, self.ptable_container), + layout=kwargs.get("layout", {}), + ) + + @property + def value(self) -> dict: + """Return value for wrapped PTableWidget""" + LOGGER.debug( + "PeriodicTable: PTableWidget.selected_elements = %r", + self.ptable.selected_elements, + ) + LOGGER.debug( + "PeriodicTable: Select ANY (True) or ALL (False) = %r", + self.select_any_all.value, + ) + + return not self.select_any_all.value, self.ptable.selected_elements.copy() + + @property + def disabled(self) -> None: + """Disable widget""" + return self._disabled + + @disabled.setter + def disabled(self, value: bool) -> None: + """Disable widget""" + if not isinstance(value, bool): + raise TypeError("disabled must be a boolean") + + self.select_any_all.disabled = self.ptable.disabled = value + + def reset(self): + """Reset widget""" + self.select_any_all.value = False + self.ptable.selected_elements = {} + + def freeze(self): + """Disable widget""" + self.disabled = True + + def unfreeze(self): + """Activate widget (in its current state)""" + self.disabled = False + + def _toggle_widget(self, change: dict): + """Hide or show the widget according to the toggle button""" + if change["new"]: + # Show widget + LOGGER.debug("Show widget since toggle is %s", change["new"]) + self.ptable_container.layout.visibility = "visible" + self.ptable_container.layout.height = "auto" + self.toggle_button.tooltip = "Hide Periodic Table" + self.toggle_button.description = "Hide Periodic Table" + else: + # Hide widget + LOGGER.debug("Hide widget since toggle is %s", change["new"]) + self.ptable_container.layout.visibility = "hidden" + self.ptable_container.layout.height = "0px" + self.toggle_button.tooltip = "Show Periodic Table" + self.toggle_button.description = "Show Periodic Table" diff --git a/build/lib/optimade_client/subwidgets/provider_database.py b/build/lib/optimade_client/subwidgets/provider_database.py new file mode 100644 index 00000000..b9c7b068 --- /dev/null +++ b/build/lib/optimade_client/subwidgets/provider_database.py @@ -0,0 +1,735 @@ +# pylint: disable=protected-access +from copy import deepcopy +import os +from typing import Dict, List, Tuple, Union +import urllib.parse + +try: + import simplejson as json +except ImportError: + import json + +import ipywidgets as ipw +import requests +import traitlets + +from ipywidgets_extended.dropdown import DropdownExtended + +from optimade.models import LinksResourceAttributes +from optimade.models.links import LinkType + +from optimade_client.exceptions import OptimadeClientError, QueryError +from optimade_client.logger import LOGGER +from optimade_client.subwidgets.results import ResultsPageChooser +from optimade_client.utils import ( + get_list_of_valid_providers, + get_versioned_base_url, + handle_errors, + ordered_query_url, + perform_optimade_query, + SESSION, + TIMEOUT_SECONDS, + update_old_links_resources, + validate_api_version, +) + + +__all__ = ("ProviderImplementationChooser", "ProviderImplementationSummary") + + +class ProviderImplementationChooser( # pylint: disable=too-many-instance-attributes + ipw.VBox +): + """List all OPTIMADE providers and their implementations""" + + provider = traitlets.Instance(LinksResourceAttributes, allow_none=True) + database = traitlets.Tuple( + traitlets.Unicode(), + traitlets.Instance(LinksResourceAttributes, allow_none=True), + default_value=("", None), + ) + + HINT = {"provider": "Select a provider", "child_dbs": "Select a database"} + INITIAL_CHILD_DBS = [("", (("No provider chosen", None),))] + + def __init__( + self, + child_db_limit: int = None, + disable_providers: List[str] = None, + skip_providers: List[str] = None, + skip_databases: Dict[str, List[str]] = None, + provider_database_groupings: Dict[str, Dict[str, List[str]]] = None, + **kwargs, + ): + self.child_db_limit = ( + child_db_limit if child_db_limit and child_db_limit > 0 else 10 + ) + self.skip_child_dbs = skip_databases or {} + self.child_db_groupings = provider_database_groupings or {} + self.offset = 0 + self.number = 1 + self.__perform_query = True + self.__cached_child_dbs = {} + + self.debug = bool(os.environ.get("OPTIMADE_CLIENT_DEBUG", None)) + + providers = [] + providers, invalid_providers = get_list_of_valid_providers( + disable_providers=disable_providers, skip_providers=skip_providers + ) + providers.insert(0, (self.HINT["provider"], {})) + if self.debug: + from optimade_client.utils import VERSION_PARTS + + local_provider = LinksResourceAttributes( + **{ + "name": "Local server", + "description": "Local server, running aiida-optimade", + "base_url": f"http://localhost:5000{VERSION_PARTS[0][0]}", + "homepage": "https://example.org", + "link_type": "external", + } + ) + providers.insert(1, ("Local server", local_provider)) + + self.providers = DropdownExtended( + options=providers, + disabled_options=invalid_providers, + layout=ipw.Layout(width="auto"), + ) + + self.show_child_dbs = ipw.Layout(width="auto", display="none") + self.child_dbs = DropdownExtended( + grouping=self.INITIAL_CHILD_DBS, layout=self.show_child_dbs + ) + self.page_chooser = ResultsPageChooser( + page_limit=self.child_db_limit, layout=self.show_child_dbs + ) + + self.providers.observe(self._observe_providers, names="value") + self.child_dbs.observe(self._observe_child_dbs, names="value") + self.page_chooser.observe( + self._get_more_child_dbs, names=["page_link", "page_offset", "page_number"] + ) + self.error_or_status_messages = ipw.HTML("") + + super().__init__( + children=( + self.providers, + self.child_dbs, + self.page_chooser, + self.error_or_status_messages, + ), + layout=ipw.Layout(width="auto"), + **kwargs, + ) + + def freeze(self): + """Disable widget""" + self.providers.disabled = True + self.show_child_dbs.display = "none" + self.page_chooser.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + self.providers.disabled = False + self.show_child_dbs.display = None + self.page_chooser.unfreeze() + + def reset(self): + """Reset widget""" + self.page_chooser.reset() + self.offset = 0 + self.number = 1 + + self.providers.disabled = False + self.providers.index = 0 + + self.show_child_dbs.display = "none" + self.child_dbs.grouping = self.INITIAL_CHILD_DBS + + def _observe_providers(self, change: dict): + """Update child database dropdown upon changing provider""" + value = change["new"] + self.show_child_dbs.display = "none" + self.provider = value + if value is None or not value: + self.show_child_dbs.display = "none" + self.child_dbs.grouping = self.INITIAL_CHILD_DBS + self.providers.index = 0 + self.child_dbs.index = 0 + else: + self._initialize_child_dbs() + if sum([len(_[1]) for _ in self.child_dbs.grouping]) <= 2: + # The provider either has 0 or 1 implementations + # or we have failed to retrieve any implementations. + # Automatically choose the 1 implementation (if there), + # while otherwise keeping the dropdown disabled. + self.show_child_dbs.display = "none" + try: + self.child_dbs.index = 1 + LOGGER.debug( + "Changed child_dbs index. New child_dbs: %s", self.child_dbs + ) + except IndexError: + pass + else: + self.show_child_dbs.display = None + + def _observe_child_dbs(self, change: dict): + """Update database traitlet with base URL for chosen child database""" + value = change["new"] + if value is None or not value: + self.database = "", None + else: + self.database = self.child_dbs.label.strip(), self.child_dbs.value + + @staticmethod + def _remove_current_dropdown_option(dropdown: ipw.Dropdown) -> tuple: + """Remove the current option from a Dropdown widget and return updated options + + Since Dropdown.options is a tuple there is a need to go through a list. + """ + list_of_options = list(dropdown.options) + list_of_options.pop(dropdown.index) + return tuple(list_of_options) + + def _initialize_child_dbs(self): + """New provider chosen; initialize child DB dropdown""" + self.offset = 0 + self.number = 1 + try: + # Freeze and disable list of structures in dropdown widget + # We don't want changes leading to weird things happening prior to the query ending + self.freeze() + + # Reset the error or status message + if self.error_or_status_messages.value: + self.error_or_status_messages.value = "" + + if self.provider.base_url in self.__cached_child_dbs: + cache = self.__cached_child_dbs[self.provider.base_url] + + LOGGER.debug( + "Initializing child DBs for %s. Using cached info:\n%r", + self.provider.name, + cache, + ) + + self._set_child_dbs(cache["child_dbs"]) + data_returned = cache["data_returned"] + data_available = cache["data_available"] + links = cache["links"] + else: + LOGGER.debug("Initializing child DBs for %s.", self.provider.name) + + # Query database and get child_dbs + child_dbs, links, data_returned, data_available = self._query() + + while True: + # Update list of structures in dropdown widget + exclude_child_dbs, final_child_dbs = self._update_child_dbs( + data=child_dbs, + skip_dbs=self.skip_child_dbs.get(self.provider.name, []), + ) + + LOGGER.debug("Exclude child DBs: %r", exclude_child_dbs) + data_returned -= len(exclude_child_dbs) + data_available -= len(exclude_child_dbs) + if exclude_child_dbs and data_returned: + child_dbs, links, data_returned, _ = self._query( + exclude_ids=exclude_child_dbs + ) + else: + break + self._set_child_dbs(final_child_dbs) + + # Cache initial child_dbs and related information + self.__cached_child_dbs[self.provider.base_url] = { + "child_dbs": final_child_dbs, + "data_returned": data_returned, + "data_available": data_available, + "links": links, + } + + LOGGER.debug( + "Found the following, which has now been cached:\n%r", + self.__cached_child_dbs[self.provider.base_url], + ) + + # Update pageing + self.page_chooser.set_pagination_data( + data_returned=data_returned, + data_available=data_available, + links_to_page=links, + reset_cache=True, + ) + + except QueryError as exc: + LOGGER.debug("Trying to initalize child DBs. QueryError caught: %r", exc) + if exc.remove_target: + LOGGER.debug( + "Remove target: %r. Will remove target at %r: %r", + exc.remove_target, + self.providers.index, + self.providers.value, + ) + self.providers.options = self._remove_current_dropdown_option( + self.providers + ) + self.reset() + else: + LOGGER.debug( + "Remove target: %r. Will NOT remove target at %r: %r", + exc.remove_target, + self.providers.index, + self.providers.value, + ) + self.show_child_dbs.display = "none" + self.child_dbs.grouping = self.INITIAL_CHILD_DBS + + else: + self.unfreeze() + + def _set_child_dbs( + self, + data: List[Tuple[str, List[Tuple[str, LinksResourceAttributes]]]], + ) -> None: + """Update the child_dbs options with `data`""" + first_choice = ( + self.HINT["child_dbs"] if data else "No valid implementations found" + ) + new_data = list(data) + new_data.insert(0, ("", [(first_choice, None)])) + self.child_dbs.grouping = new_data + + def _update_child_dbs( + self, data: List[dict], skip_dbs: List[str] = None + ) -> Tuple[ + List[str], + List[List[Union[str, List[Tuple[str, LinksResourceAttributes]]]]], + ]: + """Update child DB dropdown from response data""" + child_dbs = ( + {"": []} + if self.providers.label not in self.child_db_groupings + else deepcopy(self.child_db_groupings[self.providers.label]) + ) + exclude_dbs = [] + skip_dbs = skip_dbs or [] + + for entry in data: + child_db = update_old_links_resources(entry) + if child_db is None: + continue + + # Skip if desired by user + if child_db.id in skip_dbs: + exclude_dbs.append(child_db.id) + continue + + attributes = child_db.attributes + + # Skip if not a 'child' link_type database + if attributes.link_type != LinkType.CHILD: + LOGGER.debug( + "Skip %s: Links resource not a %r link_type, instead: %r", + attributes.name, + LinkType.CHILD, + attributes.link_type, + ) + continue + + # Skip if there is no base_url + if attributes.base_url is None: + LOGGER.debug( + "Skip %s: Base URL found to be None for child DB: %r", + attributes.name, + child_db, + ) + exclude_dbs.append(child_db.id) + continue + + versioned_base_url = get_versioned_base_url(attributes.base_url) + if versioned_base_url: + attributes.base_url = versioned_base_url + else: + # Not a valid/supported child DB: skip + LOGGER.debug( + "Skip %s: Could not determine versioned base URL for child DB: %r", + attributes.name, + child_db, + ) + exclude_dbs.append(child_db.id) + continue + + if self.providers.label in self.child_db_groupings: + for group, ids in self.child_db_groupings[self.providers.label].items(): + if child_db.id in ids: + index = child_dbs[group].index(child_db.id) + child_dbs[group][index] = (attributes.name, attributes) + break + else: + if "" in child_dbs: + child_dbs[""].append((attributes.name, attributes)) + else: + child_dbs[""] = [(attributes.name, attributes)] + else: + child_dbs[""].append((attributes.name, attributes)) + + if self.providers.label in self.child_db_groupings: + for group, ids in tuple(child_dbs.items()): + child_dbs[group] = [_ for _ in ids if isinstance(_, tuple)] + child_dbs = list(child_dbs.items()) + + LOGGER.debug("Final updated child_dbs: %s", child_dbs) + + return exclude_dbs, child_dbs + + def _get_more_child_dbs(self, change): + """Query for more child DBs according to page_offset""" + if self.providers.value is None: + # This may be called if a provider is suddenly removed (bad provider) + return + + if not self.__perform_query: + self.__perform_query = True + LOGGER.debug( + "Will not perform query with pageing: name=%s value=%s", + change["name"], + change["new"], + ) + return + + pageing: Union[int, str] = change["new"] + LOGGER.debug( + "Detected change in page_chooser: name=%s value=%s", + change["name"], + pageing, + ) + if change["name"] == "page_offset": + LOGGER.debug( + "Got offset %d to retrieve more child DBs from %r", + pageing, + self.providers.value, + ) + self.offset = pageing + pageing = None + elif change["name"] == "page_number": + LOGGER.debug( + "Got number %d to retrieve more child DBs from %r", + pageing, + self.providers.value, + ) + self.number = pageing + pageing = None + else: + LOGGER.debug( + "Got link %r to retrieve more child DBs from %r", + pageing, + self.providers.value, + ) + # It is needed to update page_offset, but we do not wish to query again + with self.hold_trait_notifications(): + self.__perform_query = False + self.page_chooser.update_offset() + + try: + # Freeze and disable both dropdown widgets + # We don't want changes leading to weird things happening prior to the query ending + self.freeze() + + # Query index meta-database + LOGGER.debug("Querying for more child DBs using pageing: %r", pageing) + child_dbs, links, _, _ = self._query(pageing) + + data_returned = self.page_chooser.data_returned + while True: + # Update list of child DBs in dropdown widget + exclude_child_dbs, final_child_dbs = self._update_child_dbs(child_dbs) + + data_returned -= len(exclude_child_dbs) + if exclude_child_dbs and data_returned: + child_dbs, links, data_returned, _ = self._query( + link=pageing, exclude_ids=exclude_child_dbs + ) + else: + break + self._set_child_dbs(final_child_dbs) + + # Update pageing + self.page_chooser.set_pagination_data( + data_returned=data_returned, links_to_page=links + ) + + except QueryError as exc: + LOGGER.debug( + "Trying to retrieve more child DBs (new page). QueryError caught: %r", + exc, + ) + if exc.remove_target: + LOGGER.debug( + "Remove target: %r. Will remove target at %r: %r", + exc.remove_target, + self.providers.index, + self.providers.value, + ) + self.providers.options = self._remove_current_dropdown_option( + self.providers + ) + self.reset() + else: + LOGGER.debug( + "Remove target: %r. Will NOT remove target at %r: %r", + exc.remove_target, + self.providers.index, + self.providers.value, + ) + self.show_child_dbs.display = "none" + self.child_dbs.grouping = self.INITIAL_CHILD_DBS + + else: + self.unfreeze() + + def _query( # pylint: disable=too-many-locals,too-many-branches,too-many-statements + self, link: str = None, exclude_ids: List[str] = None + ) -> Tuple[List[dict], dict, int, int]: + """Query helper function""" + # If a complete link is provided, use it straight up + if link is not None: + try: + if exclude_ids: + filter_value = " AND ".join( + [f'NOT id="{id_}"' for id_ in exclude_ids] + ) + + parsed_url = urllib.parse.urlparse(link) + queries = urllib.parse.parse_qs(parsed_url.query) + # Since parse_qs wraps all values in a list, + # this extracts the values from the list(s). + queries = {key: value[0] for key, value in queries.items()} + + if "filter" in queries: + queries[ + "filter" + ] = f"( {queries['filter']} ) AND ( {filter_value} )" + else: + queries["filter"] = filter_value + + parsed_query = urllib.parse.urlencode(queries) + + link = ( + f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}" + f"?{parsed_query}" + ) + + link = ordered_query_url(link) + response = SESSION.get(link, timeout=TIMEOUT_SECONDS) + if response.from_cache: + LOGGER.debug("Request to %s was taken from cache !", link) + response = response.json() + except ( + requests.exceptions.ConnectTimeout, + requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, + ) as exc: + response = { + "errors": { + "msg": "CLIENT: Connection error or timeout.", + "url": link, + "Exception": repr(exc), + } + } + except json.JSONDecodeError as exc: + response = { + "errors": { + "msg": "CLIENT: Could not decode response to JSON.", + "url": link, + "Exception": repr(exc), + } + } + else: + filter_ = '( link_type="child" OR type="child" )' + if exclude_ids: + filter_ += ( + " AND ( " + + " AND ".join([f'NOT id="{id_}"' for id_ in exclude_ids]) + + " )" + ) + + response = perform_optimade_query( + filter=filter_, + base_url=self.provider.base_url, + endpoint="/links", + page_limit=self.child_db_limit, + page_offset=self.offset, + page_number=self.number, + ) + msg, http_errors = handle_errors(response) + if msg: + if 404 in http_errors: + # If /links not found move on + pass + else: + self.error_or_status_messages.value = msg + raise QueryError(msg=msg, remove_target=True) + + # Check implementation API version + msg = validate_api_version( + response.get("meta", {}).get("api_version", ""), raise_on_fail=False + ) + if msg: + self.error_or_status_messages.value = ( + f"{msg}
The provider has been removed." + ) + raise QueryError(msg=msg, remove_target=True) + + LOGGER.debug("Manually remove `exclude_ids` if filters are not supported") + child_db_data = { + impl.get("id", "N/A"): impl for impl in response.get("data", []) + } + if exclude_ids: + for links_id in exclude_ids: + if links_id in list(child_db_data.keys()): + child_db_data.pop(links_id) + LOGGER.debug("child_db_data after popping: %r", child_db_data) + response["data"] = list(child_db_data.values()) + if "meta" in response: + if "data_available" in response["meta"]: + old_data_available = response["meta"].get("data_available", 0) + if len(response["data"]) > old_data_available: + LOGGER.debug("raising OptimadeClientError") + raise OptimadeClientError( + f"Reported data_available ({old_data_available}) is smaller than " + f"curated list of responses ({len(response['data'])}).", + ) + response["meta"]["data_available"] = len(response["data"]) + else: + raise OptimadeClientError("'meta' not found in response. Bad response") + + LOGGER.debug( + "Attempt for %r (in /links): Found implementations (names+base_url only):\n%s", + self.provider.name, + [ + f"(id: {name}; base_url: {base_url}) " + for name, base_url in [ + ( + impl.get("id", "N/A"), + impl.get("attributes", {}).get("base_url", "N/A"), + ) + for impl in response.get("data", []) + ] + ], + ) + # Return all implementations of link_type "child" + implementations = [ + implementation + for implementation in response.get("data", []) + if ( + implementation.get("attributes", {}).get("link_type", "") == "child" + or implementation.get("type", "") == "child" + ) + ] + LOGGER.debug( + "After curating for implementations which are of 'link_type' = 'child' or 'type' == " + "'child' (old style):\n%s", + [ + f"(id: {name}; base_url: {base_url}) " + for name, base_url in [ + ( + impl.get("id", "N/A"), + impl.get("attributes", {}).get("base_url", "N/A"), + ) + for impl in implementations + ] + ], + ) + + # Get links, data_returned, and data_available + links = response.get("links", {}) + data_returned = response.get("meta", {}).get( + "data_returned", len(implementations) + ) + if data_returned > 0 and not implementations: + # Most probably dealing with pre-v1.0.0-rc.2 implementations + data_returned = 0 + data_available = response.get("meta", {}).get("data_available", 0) + + return implementations, links, data_returned, data_available + + +class ProviderImplementationSummary(ipw.GridspecLayout): + """Summary/description of chosen provider and their database""" + + provider = traitlets.Instance(LinksResourceAttributes, allow_none=True) + database = traitlets.Instance(LinksResourceAttributes, allow_none=True) + + text_style = "margin:0px;padding-top:6px;padding-bottom:4px;padding-left:4px;padding-right:4px;" + + def __init__(self, **kwargs): + self.provider_summary = ipw.HTML() + provider_section = ipw.VBox( + children=[self.provider_summary], + layout=ipw.Layout(width="auto", height="auto"), + ) + + self.database_summary = ipw.HTML() + database_section = ipw.VBox( + children=[self.database_summary], + layout=ipw.Layout(width="auto", height="auto"), + ) + + super().__init__( + n_rows=1, + n_columns=31, + layout={ + "border": "solid 0.5px darkgrey", + "margin": "0px 0px 0px 0px", + "padding": "0px 0px 10px 0px", + }, + **kwargs, + ) + self[:, :15] = provider_section + self[:, 16:] = database_section + + self.observe(self._on_provider_change, names="provider") + self.observe(self._on_database_change, names="database") + + def _on_provider_change(self, change: dict): + """Update provider summary, since self.provider has been changed""" + LOGGER.debug("Provider changed in summary. New value: %r", change["new"]) + self.database_summary.value = "" + if not change["new"] or change["new"] is None: + self.provider_summary.value = "" + else: + self._update_provider() + + def _on_database_change(self, change): + """Update database summary, since self.database has been changed""" + LOGGER.debug("Database changed in summary. New value: %r", change["new"]) + if not change["new"] or change["new"] is None: + self.database_summary.value = "" + else: + self._update_database() + + def _update_provider(self): + """Update provider summary""" + html_text = f"""{getattr(self.provider, 'name', 'Provider')} +

{getattr(self.provider, 'description', '')}

""" + self.provider_summary.value = html_text + + def _update_database(self): + """Update database summary""" + html_text = f"""{getattr(self.database, 'name', 'Database')} +

{getattr(self.database, 'description', '')}

""" + self.database_summary.value = html_text + + def freeze(self): + """Disable widget""" + + def unfreeze(self): + """Activate widget (in its current state)""" + + def reset(self): + """Reset widget""" + self.provider = None diff --git a/build/lib/optimade_client/subwidgets/results.py b/build/lib/optimade_client/subwidgets/results.py new file mode 100644 index 00000000..da048780 --- /dev/null +++ b/build/lib/optimade_client/subwidgets/results.py @@ -0,0 +1,405 @@ +import typing +from urllib.parse import urlparse, parse_qs + +import ipywidgets as ipw +import traitlets + +from optimade_client.exceptions import InputError +from optimade_client.logger import LOGGER + + +__all__ = ("StructureDropdown", "ResultsPageChooser") + + +class StructureDropdown(ipw.Dropdown): + """Dropdown for showing structure results""" + + NO_OPTIONS = "Search for structures ..." + HINT = "Select a structure" + NO_RESULTS = "No structures found!" + + def __init__(self, options=None, **kwargs): + if options is None: + options = [(self.NO_OPTIONS, None)] + else: + options.insert(0, (self.HINT, None)) + + super().__init__(options=options, **kwargs) + + def set_options(self, options: list): + """Set options with hint at top/as first entry""" + if options: + first_option = (self.HINT, None) + index = 1 + else: + first_option = (self.NO_RESULTS, None) + index = 0 + options.insert(0, first_option) + self.options = options + with self.hold_trait_notifications(): + self.index = index + + def reset(self): + """Reset widget""" + with self.hold_trait_notifications(): + self.options = [(self.NO_OPTIONS, None)] + self.index = 0 + self.disabled = True + + def freeze(self): + """Disable widget""" + self.disabled = True + + def unfreeze(self): + """Activate widget (in its current state)""" + self.disabled = False + + +class ResultsPageChooser(ipw.HBox): # pylint: disable=too-many-instance-attributes + """Flip through the OPTIMADE 'pages' + + NOTE: Only supports offset-pagination at the moment. + """ + + page_offset = traitlets.Int(None, allow_none=True) + page_number = traitlets.Int(None, allow_none=True) + page_link = traitlets.Unicode(allow_none=True) + + # {name: default value} + SUPPORTED_PAGEING = {"page_offset": 0, "page_number": 1} + + def __init__(self, page_limit: int, **kwargs): + self._cache = {} + self.__last_page_offset: typing.Union[None, int] = None + self.__last_page_number: typing.Union[None, int] = None + self._layout = kwargs.pop("layout", ipw.Layout(width="auto")) + + self._page_limit = page_limit + self._data_returned = 0 + self._data_available = 0 + self.pages_links = {} + + self._button_layout = { + "style": ipw.ButtonStyle(button_color="white"), + "layout": ipw.Layout(width="auto"), + } + self.button_first = self._create_arrow_button( + "angle-double-left", "First results" + ) + self.button_prev = self._create_arrow_button( + "angle-left", f"Previous {self._page_limit} results" + ) + self.text = ipw.HTML("Showing 0 of 0 results") + self.button_next = self._create_arrow_button( + "angle-right", f"Next {self._page_limit} results" + ) + self.button_last = self._create_arrow_button( + "angle-double-right", "Last results" + ) + + self.button_first.on_click(self._goto_first) + self.button_prev.on_click(self._goto_prev) + self.button_next.on_click(self._goto_next) + self.button_last.on_click(self._goto_last) + + self._update_cache() + + super().__init__( + children=[ + self.button_first, + self.button_prev, + self.text, + self.button_next, + self.button_last, + ], + layout=self._layout, + **kwargs, + ) + + @traitlets.validate("page_offset") + def _set_minimum_page_offset(self, proposal): # pylint: disable=no-self-use + """Must be >=0. Set value to 0 if <0.""" + value = proposal["value"] + if value < 0: + value = 0 + return value + + @traitlets.validate("page_number") + def _set_minimum_page_number(self, proposal): # pylint: disable=no-self-use + """Must be >=1. Set value to 1 if <1.""" + value = proposal["value"] + if value < 1: + value = 1 + return value + + def reset(self): + """Reset widget""" + self.button_first.disabled = True + self.button_prev.disabled = True + self.text.value = "Showing 0 of 0 results" + self.button_next.disabled = True + self.button_last.disabled = True + with self.hold_trait_notifications(): + self.page_offset = self.SUPPORTED_PAGEING["page_offset"] + self.page_number = self.SUPPORTED_PAGEING["page_number"] + self.page_link = None + self._update_cache() + + def freeze(self): + """Disable widget""" + self.button_first.disabled = True + self.button_prev.disabled = True + self.button_next.disabled = True + self.button_last.disabled = True + + def unfreeze(self): + """Activate widget (in its current state)""" + self.button_first.disabled = self._cache["buttons"]["first"] + self.button_prev.disabled = self._cache["buttons"]["prev"] + self.button_next.disabled = self._cache["buttons"]["next"] + self.button_last.disabled = self._cache["buttons"]["last"] + + @property + def data_returned(self) -> int: + """Total number of entities""" + return self._data_returned + + @data_returned.setter + def data_returned(self, value: int): + """Set total number of entities""" + try: + value = int(value) + except (TypeError, ValueError) as exc: + raise InputError("data_returned must be an integer") from exc + else: + self._data_returned = value + + @property + def data_available(self) -> int: + """Total number of entities available""" + return self._data_available + + @data_available.setter + def data_available(self, value: int): + """Set total number of entities available""" + try: + value = int(value) + except (TypeError, ValueError) as exc: + raise InputError("data_available must be an integer") from exc + else: + self._data_available = value + + @property + def _last_page_offset(self) -> int: + """Get the page_offset for the last page""" + if self.__last_page_offset is not None: + return self.__last_page_offset + + if self.data_returned <= self._page_limit: + res = 0 + elif self.data_returned % self._page_limit == 0: + res = self.data_returned - self._page_limit + else: + res = self.data_returned - self.data_returned % self._page_limit + + self.__last_page_offset = res + return self.__last_page_offset + + @property + def _last_page_number(self) -> int: + """Get the page_number for the last page""" + if self.__last_page_number is not None: + return self.__last_page_number + + self.__last_page_number = int(self.data_available / self._page_limit) + self.__last_page_number += 1 if self.data_available % self._page_limit else 0 + + return self.__last_page_number + + def _update_cache(self, page_offset: int = None, page_number: int = None): + """Update cache""" + offset = page_offset if page_offset is not None else self.page_offset + number = page_number if page_number is not None else self.page_number + self._cache = { + "buttons": { + "first": self.button_first.disabled, + "prev": self.button_prev.disabled, + "next": self.button_next.disabled, + "last": self.button_last.disabled, + }, + "page_offset": offset, + "page_number": number, + } + + def _create_arrow_button(self, icon: str, hover_text: str = None) -> ipw.Button: + """Create an arrow button""" + tooltip = hover_text if hover_text is not None else "" + return ipw.Button( + disabled=True, icon=icon, tooltip=tooltip, **self._button_layout + ) + + def _parse_pageing(self, url: str, pageing: str = "page_offset") -> int: + """Retrieve and parse `pageing` value from request URL""" + parsed_url = urlparse(url) + query = parse_qs(parsed_url.query) + return int(query.get(pageing, [str(self.SUPPORTED_PAGEING[pageing])])[0]) + + def _goto_first(self, _): + """Go to first page of results""" + if self.pages_links.get("first", False): + for pageing in self.SUPPORTED_PAGEING: + self._cache[pageing] = self._parse_pageing( + self.pages_links["first"], pageing + ) + + LOGGER.debug( + "Go to first page of results - using link: %s", + self.pages_links["first"], + ) + self.page_link = self.pages_links["first"] + else: + self._cache["page_offset"] = 0 + self._cache["page_number"] = 1 + + LOGGER.debug( + "Go to first page of results - using pageing:\n page_offset=%d\n page_number=%d", + self._cache["page_offset"], + self._cache["page_number"], + ) + self.page_offset = self._cache["page_offset"] + self.page_number = self._cache["page_number"] + + def _goto_prev(self, _): + """Go to previous page of results""" + if self.pages_links.get("prev", False): + for pageing in self.SUPPORTED_PAGEING: + self._cache[pageing] = self._parse_pageing( + self.pages_links["prev"], pageing + ) + + LOGGER.debug( + "Go to previous page of results - using link: %s", + self.pages_links["prev"], + ) + self.page_link = self.pages_links["prev"] + else: + self._cache["page_offset"] -= self._page_limit + self._cache["page_number"] -= 1 + + LOGGER.debug( + "Go to previous page of results - using pageing:\n page_offset=%d\n " + "page_number=%d", + self._cache["page_offset"], + self._cache["page_number"], + ) + self.page_offset = self._cache["page_offset"] + self.page_number = self._cache["page_number"] + + def _goto_next(self, _): + """Go to next page of results""" + if self.pages_links.get("next", False): + for pageing in self.SUPPORTED_PAGEING: + self._cache[pageing] = self._parse_pageing( + self.pages_links["next"], pageing + ) + + LOGGER.debug( + "Go to next page of results - using link: %s", self.pages_links["next"] + ) + self.page_link = self.pages_links["next"] + else: + self._cache["page_offset"] += self._page_limit + self._cache["page_number"] += 1 + + LOGGER.debug( + "Go to next page of results - using pageing:\n page_offset=%d\n page_number=%d", + self._cache["page_offset"], + self._cache["page_number"], + ) + self.page_offset = self._cache["page_offset"] + self.page_number = self._cache["page_number"] + + def _goto_last(self, _): + """Go to last page of results""" + if self.pages_links.get("last", False): + for pageing in self.SUPPORTED_PAGEING: + self._cache[pageing] = self._parse_pageing( + self.pages_links["last"], pageing + ) + + LOGGER.debug( + "Go to last page of results - using link: %s", self.pages_links["last"] + ) + self.page_link = self.pages_links["last"] + else: + self._cache["page_offset"] = self._last_page_offset + self._cache["page_number"] = self._last_page_number + + LOGGER.debug( + "Go to last page of results - using offset: %d", + self._cache["page_offset"], + ) + self.page_offset = self._cache["page_offset"] + + def _update(self): + """Update widget according to chosen results using pageing""" + offset = self._cache["page_offset"] + number = self._cache["page_number"] + + if offset >= self._page_limit or number > self.SUPPORTED_PAGEING["page_number"]: + self.button_first.disabled = False + self.button_prev.disabled = False + else: + self.button_first.disabled = True + self.button_prev.disabled = True + + if self.data_returned > self._page_limit: + if offset == self._last_page_offset or number == self._last_page_number: + result_range = f"{offset + 1}-{self.data_returned}" + else: + result_range = f"{offset + 1}-{offset + self._page_limit}" + elif self.data_returned == 0: + result_range = "0" + elif self.data_returned == 1: + result_range = "1" + else: + result_range = f"{offset + 1}-{self.data_returned}" + self.text.value = f"Showing {result_range} of {self.data_returned} results" + + if offset == self._last_page_offset or number == self._last_page_number: + self.button_next.disabled = True + self.button_last.disabled = True + else: + self.button_next.disabled = False + self.button_last.disabled = False + + self._update_cache(page_offset=offset, page_number=number) + + def set_pagination_data( + self, + data_returned: int = None, + data_available: int = None, + links_to_page: dict = None, + reset_cache: bool = False, + ): + """Set data needed to 'activate' this pagination widget""" + if data_returned is not None: + self.data_returned = data_returned + if data_available is not None: + self.data_available = data_available + if links_to_page is not None: + self.pages_links = links_to_page + if reset_cache: + self._update_cache(**self.SUPPORTED_PAGEING) + self.__last_page_offset = None + + self._update() + + def update_offset(self) -> int: + """Update offset from cache""" + with self.hold_trait_notifications(): + self.page_offset = self._cache["page_offset"] + + def silent_reset(self): + """Reset, but avoid updating page_offset or page_link""" + self.set_pagination_data(data_returned=0, links_to_page=None, reset_cache=True) diff --git a/build/lib/optimade_client/subwidgets/sort_selector.py b/build/lib/optimade_client/subwidgets/sort_selector.py new file mode 100644 index 00000000..8a342bb9 --- /dev/null +++ b/build/lib/optimade_client/subwidgets/sort_selector.py @@ -0,0 +1,202 @@ +# pylint: disable=no-self-use,too-many-instance-attributes +from enum import Enum +from typing import List, Union + +import ipywidgets as ipw +from traitlets import traitlets + + +__all__ = ("SortSelector",) + + +class Order(Enum): + """Sort order""" + + ASCENDING = "" + DESCENDING = "-" + + +class SortSelector(ipw.HBox): + """Select what to sort results by, the order, and a button to sort. + + The "Sort" button will only be enabled if the the sorting field or order is changed. + """ + + NO_AVAILABLE_FIELDS = "Not available" + DEFAULT_FIELD = "nsites" + + field = traitlets.Unicode("", allow_none=False) + order = traitlets.UseEnum(Order, default_value=Order.ASCENDING) + latest_sorting = traitlets.Dict( + default_value={"field": None, "order": order.default_value} + ) + valid_fields = traitlets.List( + traitlets.Unicode(), default_value=[], allow_none=True + ) + + value = traitlets.Unicode(None, allow_none=True) + + def __init__( + self, + valid_fields: List[str] = None, + field: str = None, + order: Union[str, Order] = None, + disabled: bool = False, + **kwargs, + ) -> None: + self._disabled = disabled + + try: + self.order = order + except traitlets.TraitError: + # Use default + pass + + self.order_select = ipw.ToggleButton( + value=self.order == Order.DESCENDING, + description=self.order.name.capitalize(), + disabled=disabled, + button_style="", + tooltip=self.order.name.capitalize(), + icon=self._get_order_icon(), + layout={"width": "auto", "min_width": "105px"}, + ) + self.order_select.observe(self._change_order, names="value") + + self.fields_drop = ipw.Dropdown( + options=self.valid_fields, disabled=disabled, layout={"width": "auto"} + ) + self.fields_drop.observe(self._validate_field, names="value") + + self.sort_button = ipw.Button( + description="Sort", + disabled=True, + button_style="primary", + tooltip="Sort the results", + icon="random", + layout={"width": "auto"}, + ) + self.sort_button.on_click(self._sort_clicked) + + self.valid_fields = valid_fields or [self.NO_AVAILABLE_FIELDS] + + if field is not None: + self.field = field + + super().__init__( + children=(self.order_select, self.fields_drop, self.sort_button), **kwargs + ) + + @property + def disabled(self) -> None: + """Disable widget""" + return self._disabled + + @disabled.setter + def disabled(self, value: bool) -> None: + """Disable widget""" + if not isinstance(value, bool): + raise TypeError("disabled must be a boolean") + + self.order_select.disabled = self.fields_drop.disabled = value + + if value: + self.sort_button.disabled = True + + def reset(self): + """Reset widget""" + self.order_select.value = False + self.fields_drop.options = [self.NO_AVAILABLE_FIELDS] + self.sort_button.disabled = True + + def freeze(self): + """Disable widget""" + self.disabled = True + + def unfreeze(self): + """Activate widget (in its current state)""" + self.disabled = False + + def _update_latest_sorting(self) -> None: + """Update `latest_sorting` with current values for `field` and `order`.""" + self.latest_sorting = {"field": self.field, "order": self.order} + + def _toggle_sort_availability(self) -> None: + """Enable/Disable "Sort" button according to user choices.""" + for key, value in self.latest_sorting.items(): + if getattr(self, key) != value: + self.sort_button.disabled = False + break + else: + self.sort_button.disabled = True + + @traitlets.observe("valid_fields") + def _update_drop_options(self, change: dict) -> None: + """Update list of sort fields dropdown.""" + fields = change["new"] + if not fields: + self.fields_drop.options = [self.NO_AVAILABLE_FIELDS] + self.fields_drop.value = self.NO_AVAILABLE_FIELDS + self.freeze() + return + value = self.fields_drop.value + self.fields_drop.options = fields + if value in fields: + self.fields_drop.value = value + elif self.DEFAULT_FIELD in fields: + self.fields_drop.value = self.DEFAULT_FIELD + self.fields_drop.layout.width = "auto" + + def _validate_field(self, change: dict) -> None: + """The traitlet field should be a valid OPTIMADE field.""" + field = change["new"] + if field and field != self.NO_AVAILABLE_FIELDS: + self.field = field + self._toggle_sort_availability() + else: + self.freeze() + + @traitlets.observe("field") + def _set_value_from_field(self, change: dict) -> None: + """Update `value` from the new `field`.""" + value = change["new"] + if value and value != self.NO_AVAILABLE_FIELDS: + self.value = f"{self.order.value}{value}" + else: + self.value = None + + def _get_order_icon(self) -> str: + """Return button icon according to sort order.""" + if self.order == Order.ASCENDING: + return "sort-up" + if self.order == Order.DESCENDING: + return "sort-down" + raise traitlets.TraitError( + f"Out of Order! Could not determine order from self.order: {self.order!r}" + ) + + def _change_order(self, change: dict) -> None: + """The order button has been toggled. + + When the toggle-button is "pressed down", i.e., the value is `True`, + the order should be `descending`. + """ + descending: bool = change["new"] + self.order = Order.DESCENDING if descending else Order.ASCENDING + self.order_select.description = ( + self.order_select.tooltip + ) = self.order.name.capitalize() + self.order_select.icon = self._get_order_icon() + self._toggle_sort_availability() + + def _sort_clicked(self, _: dict) -> None: + """The Sort button has been clicked. + + Set value to current sorting settings. + Any usage of this widget should "observe" the `value` attribute to toggle sorting. + """ + self._update_latest_sorting() + if self.field: + self.value = f"{self.order.value}{self.field}" + else: + self.value = None diff --git a/build/lib/optimade_client/summary.py b/build/lib/optimade_client/summary.py new file mode 100644 index 00000000..770521a0 --- /dev/null +++ b/build/lib/optimade_client/summary.py @@ -0,0 +1,574 @@ +import base64 +import tempfile +from typing import Union +import warnings + +import ipywidgets as ipw +import traitlets + +from ipywidgets_extended import DropdownExtended +import nglview + +try: + from ase import Atoms as aseAtoms +except ImportError: + aseAtoms = None + +try: + from pymatgen import Molecule as pymatgenMolecule, Structure as pymatgenStructure +except ImportError: + pymatgenMolecule = None + pymatgenStructure = None + +from optimade.adapters import Structure +from optimade.models import StructureFeatures + +from optimade_client import exceptions +from optimade_client.logger import LOGGER +from optimade_client.subwidgets import ( + StructureSummary, + StructureSites, +) +from optimade_client.utils import ButtonStyle +from optimade_client.warnings import OptimadeClientWarning + + +class OptimadeSummaryWidget(ipw.Box): + """Overview of OPTIMADE entity (focusing on structure) + Combined view of structure viewer and tabs for detailed information. + + Set `direction` to "horizontal" or "vertical" to show the two widgets either + horizontally or vertically, respectively. + """ + + entity = traitlets.Instance(Structure, allow_none=True) + + def __init__( + self, + direction: str = None, + button_style: Union[ButtonStyle, str] = None, + **kwargs, + ): + if direction and direction == "horizontal": + direction = "row" + layout_viewer = { + "width": "50%", + "height": "auto", + "margin": "0px 0px 0px 0px", + "padding": "0px 0px 10px 0px", + } + layout_tabs = { + "width": "50%", + "height": "345px", + } + else: + direction = "column" + layout_viewer = { + "width": "auto", + "height": "auto", + "margin": "0px 0px 0px 0px", + "padding": "0px 0px 10px 0px", + } + layout_tabs = { + "width": "auto", + "height": "345px", + } + + if button_style: + if isinstance(button_style, str): + button_style = ButtonStyle[button_style.upper()] + elif isinstance(button_style, ButtonStyle): + pass + else: + raise TypeError( + "button_style should be either a string or a ButtonStyle Enum. " + f"You passed type {type(button_style)!r}." + ) + else: + button_style = ButtonStyle.DEFAULT + + kwargs.pop("layout", None) + + self.viewer = StructureViewer( + layout=layout_viewer, button_style=button_style, **kwargs + ) + self.summary = SummaryTabs(layout=layout_tabs, **kwargs) + + self.children = (self.viewer, self.summary) + + super().__init__( + children=self.children, + layout={ + "display": "flex", + "flex_flow": direction, + "align_items": "stretch", + "width": "100%", + "height": "auto", + }, + **kwargs, + ) + + self.observe(self._on_change_entity, names="entity") + + def _on_change_entity(self, change): + """Handle if entity is None""" + new_entity = change["new"] + if new_entity is None: + self.reset() + else: + self.viewer.structure = new_entity + self.summary.entity = new_entity + + def freeze(self): + """Disable widget""" + for widget in self.children: + widget.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + for widget in self.children: + widget.unfreeze() + + def reset(self): + """Reset widget""" + for widget in self.children: + widget.reset() + + +class DownloadChooser(ipw.HBox): + """Download chooser for structure download + + To be able to have the download button work no matter the widget's final environment, + (as long as it supports JavaScript), the very helpful insight from the following page is used: + https://stackoverflow.com/questions/2906582/how-to-create-an-html-button-that-acts-like-a-link + """ + + chosen_format = traitlets.Tuple(traitlets.Unicode(), traitlets.Dict()) + structure = traitlets.Instance(Structure, allow_none=True) + + _formats = [ + ( + "Crystallographic Information File v1.0 [via ASE] (.cif)", + {"ext": ".cif", "adapter_format": "ase", "final_format": "cif"}, + ), + ( + "XCrySDen Structure File [via ASE] (.xsf)", + {"ext": ".xsf", "adapter_format": "ase", "final_format": "xsf"}, + ), + ( + "WIEN2k Structure File [via ASE] (.struct)", + {"ext": ".struct", "adapter_format": "ase", "final_format": "struct"}, + ), + ( + "VASP POSCAR File [via ASE]", + {"ext": "", "adapter_format": "ase", "final_format": "vasp"}, + ), + ( + "Quantum ESPRESSO File [via ASE] (.in)", + {"ext": ".in", "adapter_format": "ase", "final_format": "espresso-in"}, + ), + ( + "XMol XYZ File [via ASE] (.xyz)", + {"ext": ".xyz", "adapter_format": "ase", "final_format": "xyz"}, + ), + # ( + # "Crystallographic Information File v1.0 (.cif)", + # {"ext": ".cif", "adapter_format": "cif"}, + # ), + ("Protein Data Bank (.pdb)", {"ext": ".pdb", "adapter_format": "pdb"}), + # Not yet implemented: + # ( + # "Protein Data Bank, macromolecular CIF v1.1 (PDBx/mmCIF) (.cif)", + # {"ext": "cif", "adapter_format": "pdbx_mmcif"}, + # ), + ] + _download_button_format = """ + +""" + + def __init__(self, button_style: Union[ButtonStyle, str] = None, **kwargs): + if button_style: + if isinstance(button_style, str): + self._button_style = ButtonStyle[button_style.upper()] + elif isinstance(button_style, ButtonStyle): + self._button_style = button_style + else: + raise TypeError( + "button_style should be either a string or a ButtonStyle Enum. " + f"You passed type {type(button_style)!r}." + ) + else: + self._button_style = ButtonStyle.DEFAULT + + self._initialize_options() + options = self._formats + options.insert(0, ("Select a format", {})) + self.dropdown = DropdownExtended(options=options, layout={"width": "auto"}) + self.download_button = ipw.HTML( + self._download_button_format.format( + button_style=self._button_style.value, + disabled="disabled", + encoding="", + data="", + filename="", + ) + ) + + self.children = (self.dropdown, self.download_button) + super().__init__(children=self.children, layout={"width": "auto"}) + self.reset() + + self.dropdown.observe(self._update_download_button, names="value") + + @traitlets.observe("structure") + def _on_change_structure(self, change: dict): + """Update widget when a new structure is chosen""" + if change["new"] is None: + LOGGER.debug( + "Got no new structure for DownloadChooser (change['new']=%s).", + change["new"], + ) + self.reset() + else: + LOGGER.debug( + "Got new structure for DownloadChooser: id=%s", change["new"].id + ) + self._update_options() + self.unfreeze() + + # Auto-choose the first (available) option in the dropdown + available_formats = { + label: index for index, (label, _) in enumerate(self._formats) + } + available_formats.pop(self._formats[0][0]) + for label in self.dropdown.disabled_options: + available_formats.pop(label) + if available_formats: + new_index = min(available_formats.values()) + self.dropdown.index = new_index + else: + self.dropdown.index = 0 + + def _initialize_options(self) -> None: + """Initialize options according to installed packages""" + for imported_object, adapter_format in [ + (aseAtoms, "ase"), + (pymatgenStructure, "pymatgen"), + ]: + if imported_object is None: + LOGGER.debug("%s not recognized to be installed.", adapter_format) + self._formats = [ + option + for option in self._formats + if option[1].get("adapter_format", "") != adapter_format + ] + + def _update_options(self) -> None: + """Update options according to chosen structure""" + disabled_options = set() + if StructureFeatures.DISORDER in self.structure.structure_features: + # Disordered structures not usable with ASE + LOGGER.debug( + "'disorder' found in the structure's structure_features (%s)", + self.structure.structure_features, + ) + disabled_options |= { + label + for label, value in self._formats + if value.get("adapter_format", "") == "ase" + } + if not self.structure.attributes.lattice_vectors: + LOGGER.debug("'lattice_vectors' not found for structure") + disabled_options |= { + label + for label, value in self._formats + if ( + value.get("adapter_format", "") == "ase" + and value.get("final_format", "") in ("struct", "vasp") + ) + } + if not self.structure.attributes.species: + LOGGER.debug("'species' not found for structure") + disabled_options |= { + label + for label, value in self._formats + if value.get("adapter_format", "") in ("cif", "pdb", "ase") + } + LOGGER.debug( + "Will disable the following dropdown options: %s", disabled_options + ) + self.dropdown.disabled_options = list(disabled_options) + + def _update_download_button(self, change: dict): + """Update Download button with correct onclick value + + The whole parsing process from `Structure` to desired format, is wrapped in a try/except, + which is further wrapped in a `warnings.catch_warnings()`. + This is in order to be able to log any warnings that might be thrown by the adapter in + `optimade-python-tools` and/or any related exceptions. + """ + desired_format = change["new"] + LOGGER.debug( + "Updating the download button with desired format: %s", desired_format + ) + if not desired_format or desired_format is None: + self.download_button.value = self._download_button_format.format( + button_style=self._button_style.value, + disabled="disabled", + encoding="", + data="", + filename="", + ) + return + + output = None + with warnings.catch_warnings(): + warnings.filterwarnings("error") + + try: + output = getattr( + self.structure, f"as_{desired_format['adapter_format']}" + ) + except RuntimeWarning as warn: + if "numpy.ufunc size changed" in str(warn): + # This is an issue that may occur if using pre-built binaries for numpy and + # scipy. It can be resolved by uninstalling scipy and reinstalling it with + # `--no-binary :all:` when using pip. This will recompile all related binaries + # using the currently installed numpy version. + # However, it shouldn't be critical, hence here the warning will be ignored. + warnings.filterwarnings("default") + output = getattr( + self.structure, f"as_{desired_format['adapter_format']}" + ) + else: + self.download_button.value = self._download_button_format.format( + button_style=self._button_style.value, + disabled="disabled", + encoding="", + data="", + filename="", + ) + warnings.warn(OptimadeClientWarning(warn)) + except Warning as warn: + self.download_button.value = self._download_button_format.format( + button_style=self._button_style.value, + disabled="disabled", + encoding="", + data="", + filename="", + ) + warnings.warn(OptimadeClientWarning(warn)) + except Exception as exc: + self.download_button.value = self._download_button_format.format( + button_style=self._button_style.value, + disabled="disabled", + encoding="", + data="", + filename="", + ) + if isinstance(exc, exceptions.OptimadeClientError): + raise exc + # Else wrap the exception to make sure to log it. + raise exceptions.OptimadeClientError(exc) + + if desired_format["adapter_format"] in ( + "ase", + "pymatgen", + "aiida_structuredata", + ): + # output is not a file, but a proxy Python class + func = getattr(self, f"_get_via_{desired_format['adapter_format']}") + output = func(output, desired_format=desired_format["final_format"]) + encoding = "utf-8" + + # Specifically for CIF: v1.x CIF needs to be in "latin-1" formatting + if desired_format["ext"] == ".cif": + encoding = "latin-1" + + filename = f"optimade_structure_{self.structure.id}{desired_format['ext']}" + + if isinstance(output, str): + output = output.encode(encoding) + data = base64.b64encode(output).decode() + + self.download_button.value = self._download_button_format.format( + button_style=self._button_style.value, + disabled="", + encoding=encoding, + data=data, + filename=filename, + ) + + @staticmethod + def _get_via_pymatgen( + structure_molecule: Union[pymatgenStructure, pymatgenMolecule], + desired_format: str, + ) -> str: + """Use pymatgen.[Structure,Molecule].to() method""" + molecule_only_formats = ["xyz", "pdb"] + structure_only_formats = ["xsf", "cif"] + if desired_format in molecule_only_formats and not isinstance( + structure_molecule, pymatgenMolecule + ): + raise exceptions.WrongPymatgenType( + f"Converting to '{desired_format}' format is only possible with a pymatgen." + f"Molecule, instead got {type(structure_molecule)}" + ) + if desired_format in structure_only_formats and not isinstance( + structure_molecule, pymatgenStructure + ): + raise exceptions.WrongPymatgenType( + f"Converting to '{desired_format}' format is only possible with a pymatgen." + f"Structure, instead got {type(structure_molecule)}." + ) + + return structure_molecule.to(fmt=desired_format) + + @staticmethod + def _get_via_ase(atoms: aseAtoms, desired_format: str) -> Union[str, bytes]: + """Use ase.Atoms.write() method""" + with tempfile.NamedTemporaryFile(mode="w+b") as temp_file: + atoms.write(temp_file.name, format=desired_format) + res = temp_file.read() + return res + + def freeze(self): + """Disable widget""" + for widget in self.children: + widget.disabled = True + + def unfreeze(self): + """Activate widget (in its current state)""" + LOGGER.debug("Will unfreeze %s", self.__class__.__name__) + for widget in self.children: + widget.disabled = False + + def reset(self): + """Reset widget""" + self.dropdown.index = 0 + self.freeze() + + +class StructureViewer(ipw.VBox): + """NGL structure viewer including download button""" + + structure = traitlets.Instance(Structure, allow_none=True) + + def __init__(self, **kwargs): + self._current_view = None + + self.viewer = nglview.NGLWidget() + self.viewer.camera = "orthographic" + self.viewer.stage.set_parameters(mouse_preset="pymol") + self.viewer_box = ipw.Box( + children=(self.viewer,), + layout={ + "width": "auto", + "height": "auto", + "border": "solid 0.5px darkgrey", + "margin": "0px", + "padding": "0.5px", + }, + ) + + button_style = kwargs.pop("button_style", None) + self.download = DownloadChooser(button_style=button_style, **kwargs) + + layout = kwargs.pop( + "layout", + { + "width": "auto", + "height": "auto", + "margin": "0px 0px 0px 0px", + "padding": "0px 0px 10px 0px", + }, + ) + + super().__init__( + children=(self.viewer_box, self.download), + layout=layout, + **kwargs, + ) + + self.observe(self._on_change_structure, names="structure") + + traitlets.dlink((self, "structure"), (self.download, "structure")) + + def _on_change_structure(self, change): + """Update viewer for new structure""" + self.reset() + if not change["new"].attributes.species: + return + self._current_view = self.viewer.add_structure( + nglview.ASEStructure(change["new"].convert("ase")) + ) + self.viewer.add_representation("ball+stick", aspectRatio=4) + self.viewer.add_representation("unitcell") + + def freeze(self): + """Disable widget""" + self.download.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + self.download.unfreeze() + + def reset(self): + """Reset widget""" + self.download.reset() + if self._current_view is not None: + self.viewer.clear() + self.viewer.remove_component(self._current_view) + self._current_view = None + + +class SummaryTabs(ipw.Tab): + """Summarize OPTIMADE entity details in tabs""" + + entity = traitlets.Instance(Structure, allow_none=True) + + def __init__(self, **kwargs): + self.sections = ( + ("Structure details", StructureSummary()), + ("Sites", StructureSites()), + ) + + layout = kwargs.pop( + "layout", + { + "width": "auto", + "height": "345px", + }, + ) + + super().__init__( + children=tuple(_[1] for _ in self.sections), + layout=layout, + **kwargs, + ) + for index, title in enumerate([_[0] for _ in self.sections]): + self.set_title(index, title) + + for widget in self.children: + ipw.dlink((self, "entity"), (widget, "structure")) + + def freeze(self): + """Disable widget""" + for widget in self.children: + widget.freeze() + + def unfreeze(self): + """Activate widget (in its current state)""" + for widget in self.children: + widget.unfreeze() + + def reset(self): + """Reset widget""" + for widget in self.children: + widget.reset() diff --git a/build/lib/optimade_client/utils.py b/build/lib/optimade_client/utils.py new file mode 100644 index 00000000..fade4dc8 --- /dev/null +++ b/build/lib/optimade_client/utils.py @@ -0,0 +1,720 @@ +from collections import OrderedDict +from enum import Enum, EnumMeta +import os +from pathlib import Path +import re +from typing import Tuple, List, Union, Iterable +from urllib.parse import urlencode, urlparse, urlunparse, parse_qs + +import json +from json import JSONDecodeError + +import appdirs +from cachecontrol import CacheControlAdapter +from cachecontrol.caches.file_cache import FileCache +from cachecontrol.heuristics import ExpiresAfter +from pydantic import ValidationError, AnyUrl # pylint: disable=no-name-in-module +import requests + +from optimade.models import LinksResource, OptimadeError, Link, LinksResourceAttributes +from optimade.models.links import LinkType + +from optimade_client.exceptions import ( + ApiVersionError, + InputError, +) +from optimade_client.logger import LOGGER + + +# Supported OPTIMADE spec versions +__optimade_version__ = [ + "1.1.0", + "1.0.1", + "1.0.0", +] + +TIMEOUT_SECONDS = 10 # Seconds before URL query timeout is raised + +PROVIDERS_URLS = [ + "https://providers.optimade.org/v1/links", + "https://raw.githubusercontent.com/Materials-Consortia/providers/master/src" + "/links/v1/providers.json", +] + +CACHE_DIR = Path(appdirs.user_cache_dir("optimade-client", "CasperWA")) +CACHE_DIR.mkdir(parents=True, exist_ok=True) +CACHED_PROVIDERS = CACHE_DIR / "cached_providers.json" + +SESSION = requests.Session() +SESSION_ADAPTER = CacheControlAdapter( + cache=FileCache(CACHE_DIR / ".requests_cache"), heuristic=ExpiresAfter(days=1) +) +SESSION_ADAPTER_DEBUG = CacheControlAdapter() +SESSION.mount("http://", SESSION_ADAPTER) +SESSION.mount("https://", SESSION_ADAPTER) +SESSION.mount("http://localhost", SESSION_ADAPTER_DEBUG) +SESSION.mount("http://127.0.0.1", SESSION_ADAPTER_DEBUG) + +# Currently known providers' development OPTIMADE base URLs +DEVELOPMENT_PROVIDERS = {"mcloud": "https://dev-www.materialscloud.org/optimade"} + +try: + DEVELOPMENT_MODE = bool(int(os.getenv("OPTIMADE_CLIENT_DEVELOPMENT_MODE", "0"))) +except ValueError: + LOGGER.debug( + ( + "OPTIMADE_CLIENT_DEVELOPMENT_MODE found, but cannot be parsed as a bool of an int. " + "Setting it to False. Found value: %s" + ), + os.getenv("OPTIMADE_CLIENT_DEVELOPMENT_MODE"), + ) + DEVELOPMENT_MODE = False + + +class DefaultingEnum(EnumMeta): + """Override __getitem__()""" + + def __getitem__(cls, name): + """Log warning and default to "DEFAULT" if name is not valid""" + if name not in cls._member_map_: + LOGGER.warning( + "%r is not a valid button style. Setting button style to 'DEFAULT'. " + "Valid button styles: %s", + name, + list(cls._member_map_.keys()), + ) + name = "DEFAULT" + return cls._member_map_[name] + + +class ButtonStyle(Enum, metaclass=DefaultingEnum): + """Enumeration of button styles""" + + DEFAULT = "default" + PRIMARY = "primary" + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + DANGER = "danger" + + +def perform_optimade_query( # pylint: disable=too-many-arguments,too-many-branches,too-many-locals + base_url: str, + endpoint: str = None, + filter: Union[dict, str] = None, # pylint: disable=redefined-builtin + sort: Union[str, List[str]] = None, + response_format: str = None, + response_fields: str = None, + email_address: str = None, + page_limit: int = None, + page_offset: int = None, + page_number: int = None, +) -> dict: + """Perform query of database""" + queries = OrderedDict() + + if endpoint is None: + endpoint = "/structures" + elif endpoint: + # Make sure we supply the correct slashed format no matter the input + endpoint = f"/{endpoint.strip('/')}" + + url_path = ( + base_url + endpoint[1:] if base_url.endswith("/") else base_url + endpoint + ) + + if filter: + if isinstance(filter, dict): + pass + elif isinstance(filter, str): + queries["filter"] = filter + else: + raise TypeError("'filter' must be either a dict or a str") + + if sort is not None: + if isinstance(sort, str): + queries["sort"] = sort + else: + queries["sort"] = ",".join(sort) + + if response_format is None: + response_format = "json" + queries["response_format"] = response_format + + if response_fields is not None: + queries["response_fields"] = response_fields + elif endpoint == "/structures": + queries["response_fields"] = ",".join( + [ + "structure_features", + "chemical_formula_descriptive", + "chemical_formula_reduced", + "elements", + "nsites", + "lattice_vectors", + "species", + "cartesian_site_positions", + "species_at_sites", + "chemical_formula_hill", + "nelements", + "nperiodic_dimensions", + "last_modified", + "elements_ratios", + "dimension_types", + ] + ) + + if email_address is not None: + queries["email_address"] = email_address + + if page_limit is not None: + queries["page_limit"] = page_limit + + if page_offset is not None: + queries["page_offset"] = page_offset + + if page_number is not None: + queries["page_number"] = page_number + + # Make query - get data + url_query = urlencode(queries) + complete_url = f"{url_path}?{url_query}" + LOGGER.debug("Performing OPTIMADE query:\n%s", complete_url) + try: + response = SESSION.get(complete_url, timeout=TIMEOUT_SECONDS) + if response.from_cache: + LOGGER.debug("Request to %s was taken from cache !", complete_url) + except ( + requests.exceptions.ConnectTimeout, + requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, + ) as exc: + return { + "errors": [ + { + "detail": ( + f"CLIENT: Connection error or timeout.\nURL: {complete_url}\n" + f"Exception: {exc!r}" + ) + } + ] + } + + try: + response = response.json() + except JSONDecodeError as exc: + return { + "errors": [ + { + "detail": ( + f"CLIENT: Cannot decode response to JSON format.\nURL: {complete_url}\n" + f"Exception: {exc!r}" + ) + } + ] + } + + return response + + +def update_local_providers_json(response: dict) -> None: + """Update local `providers.json` if necessary""" + # Remove dynamic fields + _response = response.copy() + for dynamic_field in ( + "time_stamp", + "query", + "last_id", + "response_message", + "warnings", + ): + _response.get("meta", {}).pop(dynamic_field, None) + + if CACHED_PROVIDERS.exists(): + try: + with open(CACHED_PROVIDERS, "r") as handle: + _file_response = json.load(handle) + except JSONDecodeError: + pass + else: + if _file_response == _response: + LOGGER.debug("Local %r is up-to-date", CACHED_PROVIDERS.name) + return + + LOGGER.debug( + "Creating/updating local file of cached providers (%r).", CACHED_PROVIDERS.name + ) + with open(CACHED_PROVIDERS, "w") as handle: + json.dump(_response, handle) + + +def fetch_providers(providers_urls: Union[str, List[str]] = None) -> list: + """Fetch OPTIMADE database providers (from Materials-Consortia) + + :param providers_urls: String or list of strings with versioned base URL(s) + to Materials-Consortia providers database + """ + if providers_urls and not isinstance(providers_urls, (list, str)): + raise TypeError("providers_urls must be a string or list of strings") + + if not providers_urls: + providers_urls = PROVIDERS_URLS + elif not isinstance(providers_urls, list): + providers_urls = [providers_urls] + + for providers_url in providers_urls: + providers = perform_optimade_query(base_url=providers_url, endpoint="") + msg, _ = handle_errors(providers) + if msg: + LOGGER.warning("%r returned error(s).", providers_url) + else: + break + else: + if CACHED_PROVIDERS.exists(): + # Load local cached providers file + LOGGER.warning( + "Loading local, possibly outdated, list of providers (%r).", + CACHED_PROVIDERS.name, + ) + with open(CACHED_PROVIDERS, "r") as handle: + providers = json.load(handle) + else: + LOGGER.error( + "Neither any of the provider URLs: %r returned a valid response, " + "and the local cached file of the latest valid response does not exist.", + providers_urls, + ) + providers = {} + + update_local_providers_json(providers) + return providers.get("data", []) + + +VERSION_PARTS = [] +for ver in __optimade_version__: + VERSION_PARTS.extend( + [ + f"/v{ver.split('-')[0].split('+')[0].split('.')[0]}", # major + f"/v{'.'.join(ver.split('-')[0].split('+')[0].split('.')[:2])}", # major.minor + f"/v{'.'.join(ver.split('-')[0].split('+')[0].split('.')[:3])}", # major.minor.patch + ] + ) +VERSION_PARTS = sorted(sorted(set(VERSION_PARTS), reverse=True), key=len) +LOGGER.debug("All known version editions: %s", VERSION_PARTS) + + +def get_versioned_base_url( # pylint: disable=too-many-branches + base_url: Union[str, dict, Link, AnyUrl] +) -> str: + """Retrieve the versioned base URL + + First, check if the given base URL is already a versioned base URL. + + Then, use `Version Negotiation` as outlined in the specification: + https://github.com/Materials-Consortia/OPTIMADE/blob/v1.0.0/optimade.rst#version-negotiation + + 1. Try unversioned base URL's `/versions` endpoint. + 2. Go through valid versioned base URLs. + + """ + if isinstance(base_url, dict): + base_url = base_url.get("href", "") + elif isinstance(base_url, Link): + base_url = base_url.href + + LOGGER.debug("Retrieving versioned base URL for %r", base_url) + + for version in VERSION_PARTS: + if version in base_url: + if re.match(rf".+{version}$", base_url): + return base_url + if re.match(rf".+{version}/$", base_url): + return base_url[:-1] + LOGGER.debug( + "Found version '%s' in base URL '%s', but not at the end of it. Will continue.", + version, + base_url, + ) + + # 1. Try unversioned base URL's `/versions` endpoint. + versions_endpoint = ( + f"{base_url}versions" if base_url.endswith("/") else f"{base_url}/versions" + ) + try: + response = SESSION.get(versions_endpoint, timeout=TIMEOUT_SECONDS) + if response.from_cache: + LOGGER.debug("Request to %s was taken from cache !", versions_endpoint) + except ( + requests.exceptions.ConnectTimeout, + requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, + ): + pass + else: + if response.status_code == 200: + # This endpoint should be of type "text/csv" + csv_data = response.text.splitlines() + keys = csv_data.pop(0).split(",") + versions = {}.fromkeys(keys, []) + for line in csv_data: + values = line.split(",") + for key, value in zip(keys, values): + versions[key].append(value) + + for version in versions.get("version", []): + version_path = f"/v{version}" + if version_path in VERSION_PARTS: + LOGGER.debug("Found versioned base URL through /versions endpoint.") + return ( + base_url + version_path[1:] + if base_url.endswith("/") + else base_url + version_path + ) + + timeout_seconds = 5 # Use custom timeout seconds due to potentially many requests + + # 2. Go through valid versioned base URLs. + for version in VERSION_PARTS: + versioned_base_url = ( + base_url + version[1:] if base_url.endswith("/") else base_url + version + ) + try: + response = SESSION.get( + f"{versioned_base_url}/info", timeout=timeout_seconds + ) + if response.from_cache: + LOGGER.debug( + "Request to %s/info was taken from cache !", versioned_base_url + ) + except ( + requests.exceptions.ConnectTimeout, + requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, + ): + continue + else: + if response.status_code == 200: + LOGGER.debug( + "Found versioned base URL through adding valid versions to path and requesting " + "the /info endpoint." + ) + return versioned_base_url + + return "" + + +def get_list_of_valid_providers( # pylint: disable=too-many-branches + disable_providers: List[str] = None, skip_providers: List[str] = None +) -> Tuple[List[Tuple[str, LinksResourceAttributes]], List[str]]: + """Get curated list of database providers + + Return formatted list of tuples to use with a dropdown-widget. + """ + providers = fetch_providers() + res = [] + invalid_providers = [] + disable_providers = disable_providers or [] + skip_providers = skip_providers or ["exmpl", "optimade", "aiida"] + + for entry in providers: + provider = LinksResource(**entry) + + if provider.id in skip_providers: + LOGGER.debug("Skipping provider: %s", provider) + continue + + attributes = provider.attributes + + if provider.id in disable_providers: + LOGGER.debug("Temporarily disabling provider: %s", str(provider)) + invalid_providers.append((attributes.name, attributes)) + continue + + # Skip if not an 'external' link_type database + if attributes.link_type != LinkType.EXTERNAL: + LOGGER.debug( + "Skip %s: Links resource not an %r link_type, instead: %r", + attributes.name, + LinkType.EXTERNAL, + attributes.link_type, + ) + continue + + # Disable if there is no base URL + if attributes.base_url is None: + LOGGER.debug("Base URL found to be None for provider: %s", str(provider)) + invalid_providers.append((attributes.name, attributes)) + continue + + # Use development servers for providers if desired and available + if DEVELOPMENT_MODE and provider.id in DEVELOPMENT_PROVIDERS: + development_base_url = DEVELOPMENT_PROVIDERS[provider.id] + + LOGGER.debug( + "Setting base URL for %s to their development server", provider.id + ) + + if isinstance(attributes.base_url, dict): + attributes.base_url["href"] = development_base_url + elif isinstance(attributes.base_url, Link): + attributes.base_url.href = development_base_url + elif isinstance(attributes.base_url, (AnyUrl, str)): + attributes.base_url = development_base_url + else: + raise TypeError( + "base_url not found to be a valid type. Must be either an optimade.models." + f"Link or a dict. Found type: {type(attributes.base_url)}" + ) + + versioned_base_url = get_versioned_base_url(attributes.base_url) + if versioned_base_url: + attributes.base_url = versioned_base_url + else: + # Not a valid/supported provider: skip + LOGGER.debug( + "Could not determine versioned base URL for provider: %s", str(provider) + ) + invalid_providers.append((attributes.name, attributes)) + continue + + res.append((attributes.name, attributes)) + + return res + invalid_providers, [name for name, _ in invalid_providers] + + +def validate_api_version(version: str, raise_on_fail: bool = True) -> str: + """Given an OPTIMADE API version, validate it against current supported API version""" + if not version: + msg = ( + "No version found in response. " + f"Should have been one of {', '.join(['v' + _ for _ in __optimade_version__])}" + ) + if raise_on_fail: + raise ApiVersionError(msg) + return msg + + if version.startswith("v"): + version = version[1:] + + if version not in __optimade_version__: + msg = ( + f"Only OPTIMADE {', '.join(['v' + _ for _ in __optimade_version__])} are supported. " + f"Chosen implementation has v{version}" + ) + if raise_on_fail: + raise ApiVersionError(msg) + return msg + + return "" + + +def get_entry_endpoint_schema(base_url: str, endpoint: str = None) -> dict: + """Retrieve provider's entry endpoint schema (default: /structures).""" + result = {} + + endpoint = endpoint if endpoint is not None else "structures" + endpoint = f"/info/{endpoint.strip('/')}" + + response = perform_optimade_query(endpoint=endpoint, base_url=base_url) + msg, _ = handle_errors(response) + if msg: + LOGGER.error( + "Could not retrieve information about entry-endpoint %r.\n Message: %r\n Response:" + "\n%s", + endpoint[len("/info/") :], + msg, + response, + ) + return result + + return response.get("data", {}).get("properties", {}) + + +def get_sortable_fields(base_url: str, endpoint: str = None) -> List[str]: + """Retrieve sortable fields for entry endpoint (default: /structures).""" + endpoint = endpoint if endpoint is not None else "structures" + + schema = get_entry_endpoint_schema(base_url, endpoint) + + return [field for field in schema if schema[field].get("sortable", False)] + + +def handle_errors(response: dict) -> Tuple[str, set]: + """Handle any errors""" + if "data" not in response and "errors" not in response: + raise InputError(f"No data and no errors reported in response: {response}") + + if "errors" in response: + LOGGER.error("Errored response:\n%s", json.dumps(response, indent=2)) + + if "data" in response: + msg = ( + 'Error(s) during querying, but ' + f"{len(response['data'])} structures found." + ) + elif isinstance(response["errors"], dict) and "detail" in response["errors"]: + msg = ( + 'Error(s) during querying. ' + f"Message from server:
{response['errors']['detail']!r}.
" + ) + elif isinstance(response["errors"], list) and any( + ["detail" in _ for _ in response["errors"]] + ): + details = [_["detail"] for _ in response["errors"] if "detail" in _] + msg = ( + 'Error(s) during querying. Message(s) from server:
- ' + f"{'
- '.join(details)!r}
" + ) + else: + msg = ( + 'Error during querying, ' + "please try again later." + ) + + http_errors = set() + for raw_error in response.get("errors", []): + try: + error = OptimadeError(**raw_error) + status = int(error.status) + except (ValidationError, TypeError, ValueError): + status = 400 + http_errors.add(status) + + return msg, http_errors + + return "", set() + + +def check_entry_properties( + base_url: str, + entry_endpoint: str, + properties: Union[str, Iterable[str]], + checks: Union[str, Iterable[str]], +) -> List[str]: + """Check an entry-endpoint's properties + + :param checks: An iterable, which only recognizes the following str entries: + "sort", "sortable", "present", "queryable" + The first two and latter two represent the same thing, i.e., whether a property is sortable + and whether a property is present in the entry-endpoint's resource's attributes, respectively. + :param properties: Can be either a list or not of properties to check. + :param entry_endpoint: A valid entry-endpoint for the OPTIMADE implementation, + e.g., "structures", "_exmpl_calculations", or "/extensions/structures". + """ + if isinstance(properties, str): + properties = [properties] + properties = list(properties) + + if not checks: + # Don't make any queries if called improperly (with empty iterable for `checks`) + return properties + + if isinstance(checks, str): + checks = [checks] + checks = set(checks) + if "queryable" in checks: + checks.update({"present"}) + checks.remove("queryable") + if "sortable" in checks: + checks.update({"sort"}) + checks.remove("sortable") + + query_params = { + "endpoint": f"/info/{entry_endpoint.strip('/')}", + "base_url": base_url, + } + + response = perform_optimade_query(**query_params) + msg, _ = handle_errors(response) + if msg: + LOGGER.error( + "Could not retrieve information about entry-endpoint %r.\n Message: %r\n Response:" + "\n%s", + entry_endpoint, + msg, + response, + ) + if "present" in checks: + return [] + return properties + + res = list(properties) # Copy of input list of properties + + found_properties = response.get("data", {}).get("properties", {}) + for field in properties: + field_property = found_properties.get(field, None) + if field_property is None: + LOGGER.debug( + "Could not find %r in %r for provider with base URL %r. Found properties:\n%s", + field, + query_params["endpoint"], + base_url, + json.dumps(found_properties), + ) + if "present" in checks: + res.remove(field) + elif "sort" in checks: + sortable = field_property.get("sortable", False) + if not sortable: + res.remove(field) + + LOGGER.debug( + "sortable fields found for %s (looking for %r): %r", base_url, properties, res + ) + return res + + +def update_old_links_resources(resource: dict) -> Union[LinksResource, None]: + """Try to update to resource to newest LinksResource schema""" + try: + res = LinksResource(**resource) + except ValidationError: + LOGGER.debug( + "Links resource could not be cast to newest LinksResource model. Resource: %s", + resource, + ) + + resource["attributes"]["link_type"] = resource["type"] + resource["type"] = "links" + + LOGGER.debug( + "Trying casting to LinksResource again with the updated resource: %s", + resource, + ) + try: + res = LinksResource(**resource) + except ValidationError: + LOGGER.debug( + "After updating 'type' and 'attributes.link_type' in resource, " + "it still fails to cast to LinksResource model. Resource: %s", + resource, + ) + return None + else: + return res + else: + return res + + +def ordered_query_url(url: str) -> str: + """Decode URL, sort queries, re-encode URL""" + LOGGER.debug("Ordering URL: %s", url) + parsed_url = urlparse(url) + queries = parse_qs(parsed_url.query) + LOGGER.debug("Queries to sort and order: %s", queries) + + sorted_keys = sorted(queries.keys()) + + res = OrderedDict() + for key in sorted_keys: + # Since the values are all lists, we also sort these + res[key] = sorted(queries[key]) + + res = urlencode(res, doseq=True) + res = ( + f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path};{parsed_url.params}?{res}" + f"#{parsed_url.fragment}" + ) + LOGGER.debug("Newly ordered URL: %s", res) + LOGGER.debug("Treated URL after unparse(parse): %s", urlunparse(urlparse(res))) + return urlunparse(urlparse(res)) diff --git a/build/lib/optimade_client/warnings.py b/build/lib/optimade_client/warnings.py new file mode 100644 index 00000000..f6bda8f4 --- /dev/null +++ b/build/lib/optimade_client/warnings.py @@ -0,0 +1,20 @@ +from typing import Tuple, Any + +from optimade_client.logger import LOGGER + + +class OptimadeClientWarning(Warning): + """Top-most warning class for OPTIMADE Client""" + + def __init__(self, *args: Tuple[Any]): + LOGGER.warning( + "%s warned.\nWarning message: %s\nAbout this warning: %s", + args[0].__class__.__name__ + if args and isinstance(args[0], Exception) + else self.__class__.__name__, + str(args[0]) if args else "", + args[0].__doc__ + if args and isinstance(args[0], Exception) + else self.__doc__, + ) + super().__init__(*args)