Source code for atomscale.core.client

"""Base client implementation and HTTP utilities."""

from __future__ import annotations

import itertools
import os
import platform
import sys
from collections.abc import Callable  # type: ignore[ruleName]
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from importlib.metadata import PackageNotFoundError, version
from typing import Any, Literal
from urllib.parse import urljoin

from requests import Session
from requests.adapters import HTTPAdapter
from rich.progress import Progress
from urllib3.util.retry import Retry

try:
    __version__ = version("atomscale")
except PackageNotFoundError:
    __version__ = "0.0.0"


# The RHEED workflow names a backend older than the unified-types release
# understands. Both mean "rheed"; callers may still pass either, and whether the
# stage turned is read from the stored per-view rpm rather than the name.
LEGACY_RHEED_WORKFLOWS = ("rheed_stationary", "rheed_rotating")


[docs] def canonical_workflow(workflow: str) -> str: """The unified name for a workflow that may still be addressed by a legacy one.""" return "rheed" if workflow in LEGACY_RHEED_WORKFLOWS else workflow
[docs] class BaseClient: """Base API client implementation"""
[docs] def __init__( self, api_key: str, endpoint: str, ): """ Args: api_key (str | None): API key. endpoint (str): Root API endpoint. """ self.api_key = api_key self.endpoint = endpoint self._session = None
@property def session(self) -> Session: """Session under which HTTP requests are issued""" if not self._session: self._session = self._create_session(self.api_key) return self._session # type: ignore[return-value] def _get( self, sub_url: str, params: dict[str, Any] | None = None, deserialize: bool = True, base_override: str | None = None, ) -> list[dict[Any, Any]] | dict[Any, Any] | bytes | None: """Method for issuing a GET request Args: sub_url (str): API sub-url to use. params (dict[str, Any] | None): Params to pass in the GET request. Defaults to None. deserialize (bool): Whether to JSON deserialize the response data or return raw bytes. Defaults to True. base_overrise (str): Base URL to use instead of the default ADS API root URL. Raises: ClientError: If the response code returned is not within the range of 200-400. Returns: (list[dict] | dict | bytes | None): Deserialized JSON data or raw bytes. Returns None if response is a 404. """ base_url = base_override or self.endpoint response = self.session.get( url=urljoin(base_url, sub_url), verify=True, params=params ) if not response.ok: if response.status_code == 404: return None raise ClientError( f"Problem retrieving data from {sub_url} with parameters {params}. HTTP Error {response.status_code}: {response.text}", status_code=response.status_code, response_text=response.text, ) if len(response.content) == 0: return None return response.json() if deserialize else response.content def _get_by_workflow( self, url_template: str, workflow: str, params: dict[str, Any] | None = None, ) -> list[dict[Any, Any]] | dict[Any, Any] | bytes | None: """``_get`` a URL whose path names a workflow, against either type enum. ``rheed_stationary`` and ``rheed_rotating`` were unified into a single ``rheed``. The request goes out under the canonical name; a backend still on the old enum rejects it in the path (422), so the legacy name is tried instead — both, in order, when the caller asked for the unified name, since only that backend knows which one it stored the data under. Only that backend pays the extra requests. """ canonical = canonical_workflow(workflow) try: return self._get( sub_url=url_template.format(workflow=canonical), params=params ) except ClientError as error: if canonical != "rheed" or error.status_code != 422: raise last_error = error # A caller naming a legacy workflow has already said which one it is; # asking for the other would answer about a different recording. candidates = ( [workflow] if workflow in LEGACY_RHEED_WORKFLOWS else list(LEGACY_RHEED_WORKFLOWS) ) answered = False for legacy in candidates: try: payload = self._get( sub_url=url_template.format(workflow=legacy), params=params ) except ClientError as error: if error.status_code != 422: raise last_error = error continue # The workflow is part of the path here, so the name this recording # was *not* stored under 404s — which ``_get`` reports as None, and # which says nothing about the other name. Returning it would report # a rotating recording as having no matches at all. Only a payload # ends the search; None is the answer once every candidate has been # asked and understood. answered = True if payload is not None: return payload if not answered: raise last_error return None def _post_or_put( self, method: Literal["POST", "PUT"], sub_url: str, params: dict[str, Any] | None = None, body: dict[str, Any] | bytes | None = None, headers: dict[str, str] | None = None, deserialize: bool = True, base_override: str | None = None, return_headers: bool = False, ) -> list[dict[Any, Any]] | dict[Any, Any] | bytes | None: """Method for issuing a POST or PUT request Args: method (Literal["POST", "PUT"]): Method to use sub_url (str): API sub-url to use. params (dict[str, Any] | None): Params to pass in the GET request. Defaults to None. body (dict[str, Any] | bytes): Body data to send in the POST request. headers (dict[str, str] | None): Optional headers to include in the request. deserialize (bool): Whether to JSON deserialize the response data or return raw bytes. Defaults to True. base_overrise (str): Base URL to use instead of the default ADS API root URL. return_headers (bool): Whether to return the headers from the response instead of the content. Defaults to False. Raises: ClientError: If the response code returned is not within the range of 200-400. Returns: (list[dict] | dict | bytes | None): Deserialized JSON data or raw bytes. Returns None if response is a 404. """ base_url = base_override or self.endpoint method_func = self.session.put if method == "PUT" else self.session.post # decide whether to use data= (bytes/streams) or json= if body is None: data_params: dict[str, Any] = {} elif isinstance(body, bytes | bytearray): data_params = {"data": body} elif hasattr(body, "read"): # any file-like / RawIOBase data_params = {"data": body} else: # everything else (dict, list, etc.) goes through JSON data_params = {"json": body} response = method_func( url=urljoin(base_url, sub_url), verify=True, params=params, headers=headers or {}, **data_params, # type: ignore # noqa: PGH003 ) if not response.ok: if response.status_code == 404: return None raise ClientError( f"Problem sending data to {sub_url}. HTTP Error {response.status_code}: {response.text}", status_code=response.status_code, response_text=response.text, ) if return_headers: return_data: dict[Any, Any] = response.headers # type: ignore # noqa: PGH003 else: return_data = response.json() if deserialize else response.content # type: ignore #noqa: PGH003 return return_data def _delete( self, sub_url: str, params: dict[str, Any] | None = None, deserialize: bool = True, base_override: str | None = None, ) -> list[dict[Any, Any]] | dict[Any, Any] | bytes | None: """Method for issuing a DELETE request Args: sub_url (str): API sub-url to use. params (dict[str, Any] | None): Params to pass in the DELETE request. Defaults to None. deserialize (bool): Whether to JSON deserialize the response data or return raw bytes. Defaults to True. base_override (str): Base URL to use instead of the default ADS API root URL. Raises: ClientError: If the response code returned is not within the range of 200-400. Returns: (list[dict] | dict | bytes | None): Deserialized JSON data or raw bytes. Returns None if response is a 404. """ base_url = base_override or self.endpoint response = self.session.delete( url=urljoin(base_url, sub_url), verify=True, params=params ) if not response.ok: if response.status_code == 404: return None raise ClientError( f"Problem deleting data at {sub_url} with parameters {params}. HTTP Error {response.status_code}: {response.text}", status_code=response.status_code, response_text=response.text, ) if len(response.content) == 0: return None return response.json() if deserialize else response.content def _multi_thread( self, func: Callable[..., Any], kwargs_list: list[dict[str, Any]], progress_bar: Progress | None = None, progress_description: str | None = None, progress_kwargs: dict | None = None, transient: bool = False, ) -> list[Any]: """Handles running a function concurrently with a ThreadPoolExecutor Arguments: func (Callable): Function to run concurrently kwargs_list (list): List of keyword argument inputs for the function progress_bar (Progress | None): Progress bar to show. Defaults to None. progress_description (str | None): Progress bar description. progress_kwargs (dict | None): Additional kwargs to pass to the progress task. transient (bool): Whether the progress bar is transient. Defaults to False, Returns: (list[Any]): List of results from passed function in the order of parameters passed """ return_dict = {} total_count = len(kwargs_list) kwargs_gen = iter(kwargs_list) if progress_bar is not None: progress_kwargs = progress_kwargs or {"pad": ""} task = progress_bar.add_task( progress_description or "", total=total_count, **progress_kwargs ) ind = 0 num_parallel = min(os.cpu_count() or 8, 8) with ThreadPoolExecutor(max_workers=num_parallel) as executor: # Get list of initial futures defined by max number of parallel requests futures = set() for kwargs in itertools.islice(kwargs_gen, num_parallel): future = executor.submit( func, **kwargs, ) future.ind = ind # type: ignore # noqa: PGH003 futures.add(future) ind += 1 while futures: # Wait for at least one future to complete and process finished finished, futures = wait(futures, return_when=FIRST_COMPLETED) for future in finished: data = future.result() if progress_bar is not None: progress_bar.update(task, advance=1, refresh=True) # type: ignore # noqa: PGH003 return_dict[future.ind] = data # type: ignore # noqa: PGH003 # Populate more futures to replace finished for kwargs in itertools.islice(kwargs_gen, len(finished)): new_future = executor.submit( func, **kwargs, ) new_future.ind = ind # type: ignore # noqa: PGH003 futures.add(new_future) ind += 1 if progress_bar is not None and transient: progress_bar.remove_task(task) # type: ignore # noqa: PGH003 return [t[1] for t in sorted(return_dict.items())] @staticmethod def _create_session(api_key: str): """Create a requests session Args: api_key (str): API key to include in the header. Returns: (Session): Requests Session object """ session = Session() session.headers = {"X-API-KEY": api_key} # User agent information atomscale_info = "atomscale/" + __version__ python_info = f"Python/{sys.version.split()[0]}" platform_info = f"{platform.system()}/{platform.release()}" session.headers["user-agent"] = ( f"{atomscale_info} ({python_info} {platform_info})" ) # urllib3 retries are the safety net for **all** HTTP calls (including # those not wrapped by `_retry_client_call`, e.g. plain `_get`). # Status-code retries are kept small here because the application # layer (`_retry_client_call` in `client.py`) does its own retries # for retryable statuses; previously both were set to 3 retries each, # giving 4 x 4 = 16 worst-case HTTP requests against a persistently # failing endpoint. Capping urllib3's status retries at 1 keeps a # single-transient absorber for direct callers while bounding the # stacked worst case at 2 x 4 = 8. # TODO: Add retry setting to configuration somewhere max_retry_num = 3 retry = Retry( total=max_retry_num, read=max_retry_num, connect=max_retry_num, status=1, backoff_factor=0.5, respect_retry_after_header=True, status_forcelist=[429, 500, 502, 503, 504], raise_on_status=False, ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter) return session
[docs] class ClientError(Exception): """Generic error thrown by the Atomic Data Sciences API client""" def __init__( self, message: str, status_code: int | None = None, response_text: str | None = None, ): super().__init__(message) self.status_code = status_code self.response_text = response_text