From 7f3f664c51e8d023366dda6f1389bcf9782f72bf Mon Sep 17 00:00:00 2001 From: Ludwig Schneider Date: Mon, 26 Feb 2024 17:16:06 -0600 Subject: [PATCH] Refactor paginator (#425) * capsule some of the request calls * final touches * make mypy happy * add missing file * implement the new paginator design * adjust tests to the new design * trunk catches * remove wrong file * small improvements * make mypy happy * adjust expected result for doctest * further smaller fixes * fix vocab test * automate cancelling outdated workflow runs * fix doctest * fix test expectations * remove tests that don't really make sense. I don't think we should process user supplied URLs too much. * fix trunk check * make API and data schema smarter. Delay init of DBschema to raise API connection error at right place. * full circle and we still don't have the correct search results * reenable tests * stupid mistakes * remove unmerged paths * add missing page increase * add good changes * less error prone * raw is better for debugging * fix import error * remove obsolete test --- .github/workflows/docs_check.yaml | 7 + .github/workflows/doctest.yaml | 8 + .github/workflows/mypy.yaml | 8 + .github/workflows/test_coverage.yaml | 8 + .github/workflows/test_examples.yml | 8 + .github/workflows/tests.yml | 8 + conftest.py | 4 +- src/cript/api/api.py | 221 ++++++++++++------------- src/cript/api/api_config.py | 2 +- src/cript/api/data_schema.py | 109 +++++------- src/cript/api/paginator.py | 208 ++++++++--------------- src/cript/nodes/util/json.py | 34 ++-- src/cript/nodes/uuid_base.py | 2 +- tests/api/test_api.py | 146 +--------------- tests/api/test_db_schema.py | 13 -- tests/api/test_search.py | 104 ++++++++++++ tests/fixtures/api_fixtures.py | 3 +- tests/utils/integration_test_helper.py | 9 +- 18 files changed, 392 insertions(+), 510 deletions(-) create mode 100644 tests/api/test_search.py diff --git a/.github/workflows/docs_check.yaml b/.github/workflows/docs_check.yaml index 649437cb0..a7eaae983 100644 --- a/.github/workflows/docs_check.yaml +++ b/.github/workflows/docs_check.yaml @@ -15,6 +15,13 @@ on: - main - develop - "*" +concurrency: + # github.workflow: name of the workflow + # github.event.pull_request.number || github.ref: pull request number or branch name if not a pull request + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + + # Cancel in-progress runs when a new workflow with the same group name is triggered + cancel-in-progress: true jobs: build: diff --git a/.github/workflows/doctest.yaml b/.github/workflows/doctest.yaml index ef5d93dde..41091a3df 100644 --- a/.github/workflows/doctest.yaml +++ b/.github/workflows/doctest.yaml @@ -14,6 +14,14 @@ on: - main - develop +concurrency: + # github.workflow: name of the workflow + # github.event.pull_request.number || github.ref: pull request number or branch name if not a pull request + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + + # Cancel in-progress runs when a new workflow with the same group name is triggered + cancel-in-progress: true + jobs: doctest: strategy: diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml index 2df11ab3b..c5dbadeb4 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/mypy.yaml @@ -14,6 +14,14 @@ on: - main - develop +concurrency: + # github.workflow: name of the workflow + # github.event.pull_request.number || github.ref: pull request number or branch name if not a pull request + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + + # Cancel in-progress runs when a new workflow with the same group name is triggered + cancel-in-progress: true + jobs: mypy-test: strategy: diff --git a/.github/workflows/test_coverage.yaml b/.github/workflows/test_coverage.yaml index 27b7d1af2..95654e7ba 100644 --- a/.github/workflows/test_coverage.yaml +++ b/.github/workflows/test_coverage.yaml @@ -15,6 +15,14 @@ on: - main - develop +concurrency: + # github.workflow: name of the workflow + # github.event.pull_request.number || github.ref: pull request number or branch name if not a pull request + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + + # Cancel in-progress runs when a new workflow with the same group name is triggered + cancel-in-progress: true + jobs: test-coverage: runs-on: ubuntu-latest diff --git a/.github/workflows/test_examples.yml b/.github/workflows/test_examples.yml index de9db832a..24ef37fc2 100644 --- a/.github/workflows/test_examples.yml +++ b/.github/workflows/test_examples.yml @@ -12,6 +12,14 @@ on: - main - develop +concurrency: + # github.workflow: name of the workflow + # github.event.pull_request.number || github.ref: pull request number or branch name if not a pull request + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + + # Cancel in-progress runs when a new workflow with the same group name is triggered + cancel-in-progress: true + jobs: test-examples: runs-on: ${{ matrix.os }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d7f433397..ba088676b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,6 +18,14 @@ on: - develop - "*" +concurrency: + # github.workflow: name of the workflow + # github.event.pull_request.number || github.ref: pull request number or branch name if not a pull request + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + + # Cancel in-progress runs when a new workflow with the same group name is triggered + cancel-in-progress: true + jobs: install: runs-on: ${{ matrix.os }} diff --git a/conftest.py b/conftest.py index 51081d5fb..9e160353b 100644 --- a/conftest.py +++ b/conftest.py @@ -8,6 +8,7 @@ The fixtures are all functional fixtures that stay consistent between all tests. """ +import logging import os import pytest @@ -49,7 +50,7 @@ def cript_api(): """ storage_token = os.getenv("CRIPT_STORAGE_TOKEN") - with cript.API(host=None, api_token=None, storage_token=storage_token) as api: + with cript.API(host=None, api_token=None, storage_token=storage_token, default_log_level=logging.DEBUG) as api: # overriding AWS S3 cognito variables to be sure we do not upload test data to production storage # staging AWS S3 cognito storage variables api._IDENTITY_POOL_ID = "us-east-1:25043452-a922-43af-b8a6-7e938a9e55c1" @@ -57,6 +58,7 @@ def cript_api(): api._BUCKET_NAME = "cript-stage-user-data" # using the tests folder name within our cloud storage api._BUCKET_DIRECTORY_NAME = "tests" + api.extra_api_log_debug_info = True yield api diff --git a/src/cript/api/api.py b/src/cript/api/api.py index 869007c33..980c08876 100644 --- a/src/cript/api/api.py +++ b/src/cript/api/api.py @@ -2,8 +2,8 @@ import json import logging import os +import traceback import uuid -import warnings from pathlib import Path from typing import Any, Dict, Optional, Union @@ -19,7 +19,6 @@ CRIPTAPISaveError, CRIPTConnectionError, CRIPTDuplicateNameError, - InvalidHostError, ) from cript.api.paginator import Paginator from cript.api.utils.aws_s3_utils import get_s3_client @@ -75,6 +74,8 @@ class API: _internal_s3_client: Any = None # type: ignore # trunk-ignore-end(cspell) + extra_api_log_debug_info: bool = False + @beartype def __init__(self, host: Union[str, None] = None, api_token: Union[str, None] = None, storage_token: Union[str, None] = None, config_file_path: Union[str, Path] = "", default_log_level=logging.INFO): """ @@ -208,19 +209,15 @@ def __init__(self, host: Union[str, None] = None, api_token: Union[str, None] = api_token = authentication_dict["api_token"] storage_token = authentication_dict["storage_token"] - self._host = self._prepare_host(host=host) # type: ignore + self._host: str = host.rstrip("/") self._api_token = api_token # type: ignore self._storage_token = storage_token # type: ignore # add Bearer to token for HTTP requests self._http_headers = {"Authorization": f"Bearer {self._api_token}", "Content-Type": "application/json"} - # check that api can connect to CRIPT with host and token - self._check_initial_host_connection() - # set a logger instance to use for the class logs self._init_logger(default_log_level) - self._db_schema = DataSchema(self.host, self.logger) def __str__(self) -> str: """ @@ -235,7 +232,7 @@ def __str__(self) -> str: ... storage_token=os.getenv("CRIPT_STORAGE_TOKEN") ... ) as api: ... print(api) - CRIPT API Client - Host URL: 'https://api.criptapp.org/api/v1' + CRIPT API Client - Host URL: 'https://api.criptapp.org' Returns ------- @@ -283,46 +280,6 @@ def _init_logger(self, log_level=logging.INFO) -> None: def logger(self): return self._logger - @beartype - def _prepare_host(self, host: str) -> str: - """ - Takes the host URL provided by the user during API object construction (e.g., `https://api.criptapp.org`) - and standardizes it for internal use. Performs any required string manipulation to ensure uniformity. - - Parameters - ---------- - host: str - The host URL specified during API initialization, typically in the form `https://api.criptapp.org`. - - Warnings - -------- - If the specified host uses the unsafe "http://" protocol, a warning will be raised to consider using HTTPS. - - Raises - ------ - InvalidHostError - If the host string does not start with either "http" or "https", an InvalidHostError will be raised. - Only HTTP protocol is acceptable at this time. - - Returns - ------- - str - A standardized host string formatted for internal use. - - """ - # strip ending slash to make host always uniform - host = host.rstrip("/") - host = f"{host}/{self._api_prefix}/{self._api_version}" - - # if host is using unsafe "http://" then give a warning - if host.startswith("http://"): - warnings.warn("HTTP is an unsafe protocol please consider using HTTPS.") - - if not host.startswith("http"): - raise InvalidHostError() - - return host - # Use a property to ensure delayed init of s3_client @property def _s3_client(self) -> boto3.client: # type: ignore @@ -359,7 +316,18 @@ def connect(self): If this function is called manually, the `API.disconnect` function has to be called later. For manual connection: nested API object are discouraged. + + Raises + ------- + CRIPTConnectionError + raised when the host does not give the expected response """ + # As a form to check our connection, we pull and establish the data schema + try: + self._db_schema = DataSchema(self) + except APIError as exc: + raise CRIPTConnectionError(self.host, self._api_token) from exc + # Store the last active global API (might be None) global _global_cached_api self._previous_global_cached_api = copy.copy(_global_cached_api) @@ -410,27 +378,17 @@ def host(self): ... storage_token=os.getenv("CRIPT_STORAGE_TOKEN") ... ) as api: ... print(api.host) - https://api.criptapp.org/api/v1 + https://api.criptapp.org """ return self._host - def _check_initial_host_connection(self) -> None: - """ - tries to create a connection with host and if the host does not respond or is invalid it raises an error - - Raises - ------- - CRIPTConnectionError - raised when the host does not give the expected response + @property + def api_prefix(self): + return self._api_prefix - Returns - ------- - None - """ - try: - pass - except Exception as exc: - raise CRIPTConnectionError(self.host, self._api_token) from exc + @property + def api_version(self): + return self._api_version def save(self, project: Project) -> None: """ @@ -497,7 +455,7 @@ def _internal_save(self, node, save_values: Optional[_InternalSaveValues] = None # This checks if the current node exists on the back end. # if it does exist we use `patch` if it doesn't `post`. - test_get_response: Dict = requests.get(url=f"{self._host}/{node.node_type_snake_case}/{str(node.uuid)}/", headers=self._http_headers, timeout=_API_TIMEOUT).json() + test_get_response: Dict = self._capsule_request(url_path=f"/{node.node_type_snake_case}/{str(node.uuid)}/", method="GET").json() patch_request = test_get_response["code"] == 200 # TODO remove once get works properly @@ -512,13 +470,17 @@ def _internal_save(self, node, save_values: Optional[_InternalSaveValues] = None response = {"code": 200} break + method = "POST" + url_path = f"/{node.node_type_snake_case}/" if patch_request: - response: Dict = requests.patch(url=f"{self._host}/{node.node_type_snake_case}/{str(node.uuid)}/", headers=self._http_headers, data=json_data, timeout=_API_TIMEOUT).json() # type: ignore - else: - response: Dict = requests.post(url=f"{self._host}/{node.node_type_snake_case}/", headers=self._http_headers, data=json_data, timeout=_API_TIMEOUT).json() # type: ignore - # if node.node_type != "Project": - # test_success: Dict = requests.get(url=f"{self._host}/{node.node_type_snake_case}/{str(node.uuid)}/", headers=self._http_headers, timeout=_API_TIMEOUT).json() - # print("XYZ", json_data, save_values, response, test_success) + method = "PATCH" + url_path += f"{str(node.uuid)}/" + + response: Dict = self._capsule_request(url_path=url_path, method=method, data=json_data).json() # type: ignore + + # if node.node_type != "Project": + # test_success: Dict = requests.get(url=f"{self._host}/{node.node_type_snake_case}/{str(node.uuid)}/", headers=self._http_headers, timeout=_API_TIMEOUT).json() + # print("XYZ", json_data, save_values, response, test_success) # print(json_data, patch_request, response, save_values) # If we get an error we may be able to fix, we to handle this extra and save the bad node first. @@ -733,7 +695,7 @@ def search( self, node_type: Any, search_mode: SearchModes, - value_to_search: Optional[str], + value_to_search: str = "", ) -> Paginator: """ This method is used to perform search on the CRIPT platform. @@ -745,16 +707,15 @@ def search( -------- ???+ Example "Search by Node Type" ```python - materials_paginator = cript_api.search( + materials_iterator = cript_api.search( node_type=cript.Material, search_mode=cript.SearchModes.NODE_TYPE, - value_to_search=None ) ``` ??? Example "Search by Contains name" ```python - contains_name_paginator = cript_api.search( + contains_name_iterator = cript_api.search( node_type=cript.Process, search_mode=cript.SearchModes.CONTAINS_NAME, value_to_search="poly" @@ -763,7 +724,7 @@ def search( ??? Example "Search by Exact Name" ```python - exact_name_paginator = cript_api.search( + exact_name_iterator = cript_api.search( node_type=cript.Project, search_mode=cript.SearchModes.EXACT_NAME, value_to_search="Sodium polystyrene sulfonate" @@ -772,7 +733,7 @@ def search( ??? Example "Search by UUID" ```python - uuid_paginator = cript_api.search( + uuid_iterator = cript_api.search( node_type=cript.Collection, search_mode=cript.SearchModes.UUID, value_to_search="75fd3ee5-48c2-4fc7-8d0b-842f4fc812b7" @@ -781,7 +742,7 @@ def search( ??? Example "Search by BigSmiles" ```python - paginator = cript_api.search( + iterator = cript_api.search( node_type=cript.Material, search_mode=cript.SearchModes.BIGSMILES, value_to_search="{[][$]CC(C)(C(=O)OCCCC)[$][]}" @@ -795,74 +756,54 @@ def search( search_mode : SearchModes Type of search you want to do. You can search by name, `UUID`, `EXACT_NAME`, etc. Refer to [valid search modes](../search_modes) - value_to_search : Optional[str] + value_to_search : str What you are searching for can be either a value, and if you are only searching for a `NODE_TYPE`, then this value can be empty or `None` Returns ------- Paginator - paginator object for the user to use to flip through pages of search results + An iterator that will present and fetch the results to the user seamlessly Notes ----- To learn more about working with pagination, please refer to our [paginator object documentation](../paginator). - - Additionally, you can utilize the utility function - [`load_nodes_from_json(node_json)`](../../utility_functions/#cript.nodes.util.load_nodes_from_json) - to convert API JSON responses into Python SDK nodes. - - ???+ Example "Convert API JSON Response to Python SDK Nodes" - ```python - # Get updated project from API - my_paginator = api.search( - node_type=cript.Project, - search_mode=cript.SearchModes.EXACT_NAME, - value_to_search="my project name", - ) - - # Take specific Project you want from paginator - my_project_from_api_dict: dict = my_paginator.current_page_results[0] - - # Deserialize your Project dict into a Project node - my_project_node_from_api = cript.load_nodes_from_json( - nodes_json=json.dumps(my_project_from_api_dict) - ) - ``` """ # get node typ from class node_type = node_type.node_type_snake_case - # always putting a page parameter of 0 for all search URLs - page_number = 0 - api_endpoint: str = "" + page_number: Union[int, None] = None - # requesting a page of some primary node if search_mode == SearchModes.NODE_TYPE: - api_endpoint = f"{self._host}/{node_type}" + api_endpoint = f"/search/{node_type}" + page_number = 0 elif search_mode == SearchModes.CONTAINS_NAME: - api_endpoint = f"{self._host}/search/{node_type}" + api_endpoint = f"/search/{node_type}" + page_number = 0 elif search_mode == SearchModes.EXACT_NAME: - api_endpoint = f"{self._host}/search/exact/{node_type}" + api_endpoint = f"/search/exact/{node_type}" + page_number = None elif search_mode == SearchModes.UUID: - api_endpoint = f"{self._host}/{node_type}/{value_to_search}" + api_endpoint = f"/{node_type}/{value_to_search}" # putting the value_to_search in the URL instead of a query - value_to_search = None + value_to_search = "" + page_number = None elif search_mode == SearchModes.BIGSMILES: - api_endpoint = f"{self._host}/search/bigsmiles/" + api_endpoint = "/search/bigsmiles/" + page_number = 0 # error handling if none of the API endpoints got hit else: raise RuntimeError("Internal Error: Failed to recognize any search modes. Please report this bug on https://github.com/C-Accel-CRIPT/Python-SDK/issues.") - return Paginator(http_headers=self._http_headers, api_endpoint=api_endpoint, query=value_to_search, current_page_number=page_number) + return Paginator(api=self, url_path=api_endpoint, page_number=page_number, query=value_to_search) def delete(self, node) -> None: """ @@ -997,11 +938,57 @@ def delete_node_by_uuid(self, node_type: str, node_uuid: str) -> None: ------- None """ - delete_node_api_url: str = f"{self._host}/{node_type.lower()}/{node_uuid}/" - response: Dict = requests.delete(headers=self._http_headers, url=delete_node_api_url, timeout=_API_TIMEOUT).json() + response: Dict = self._capsule_request(url_path=f"/{node_type.lower()}/{node_uuid}/", method="DELETE").json() if response["code"] != 200: - raise APIError(api_error=str(response), http_method="DELETE", api_url=delete_node_api_url) + raise APIError(api_error=str(response), http_method="DELETE", api_url=f"/{node_type.lower()}/{node_uuid}/") self.logger.info(f"Deleted '{node_type.title()}' with UUID of '{node_uuid}' from CRIPT API.") + + def _capsule_request(self, url_path: str, method: str, api_request: bool = True, headers: Optional[Dict] = None, timeout: int = _API_TIMEOUT, **kwargs) -> requests.Response: + """Helper function that capsules every request call we make against the backend. + + Please *always* use this methods instead of `requests` directly. + We can log all request calls this way, which can help debugging immensely. + + Parameters + ---------- + url_path:str + URL path that we want to request from. So every thing that follows api.host. You can omit the api prefix and api version if you use api_request=True they are automatically added. + + method: str + One of `GET`, `OPTIONS`, `HEAD`, `POST`, `PUT, `PATCH`, or `DELETE` as this will directly passed to `requests.request(...)`. See https://docs.python-requests.org/en/latest/api/ for details. + + headers: Dict + HTTPS headers to use for the request. + If None (default) use the once associated with this API object for authentication. + + timeout:int + Time out to be used for the request call. + + kwargs + additional keyword arguments that are passed to `request.request` + """ + + if headers is None: + headers = self._http_headers + + url: str = self.host + if api_request: + url += f"/{self.api_prefix}/{self.api_version}" + url += url_path + + pre_log_message: str = f"Requesting {method} from {url}" + if self.extra_api_log_debug_info: + pre_log_message += f" from {traceback.format_stack(limit=4)} kwargs {kwargs}" + pre_log_message += "..." + self.logger.debug(pre_log_message) + + response: requests.Response = requests.request(url=url, method=method, headers=headers, timeout=timeout, **kwargs) + post_log_message: str = f"Request return with {response.status_code}" + if self.extra_api_log_debug_info: + post_log_message += f" {response.raw}" + self.logger.debug(post_log_message) + + return response diff --git a/src/cript/api/api_config.py b/src/cript/api/api_config.py index bd7de1b6e..b2222e212 100644 --- a/src/cript/api/api_config.py +++ b/src/cript/api/api_config.py @@ -6,4 +6,4 @@ """ # Default maximum time in seconds for all API requests to wait for a response from the backend -_API_TIMEOUT: int = 120 +_API_TIMEOUT: int = 6 * 150 diff --git a/src/cript/api/data_schema.py b/src/cript/api/data_schema.py index 925209f21..87c51c12d 100644 --- a/src/cript/api/data_schema.py +++ b/src/cript/api/data_schema.py @@ -2,10 +2,8 @@ from typing import Union import jsonschema -import requests from beartype import beartype -from cript.api.api_config import _API_TIMEOUT from cript.api.exceptions import APIError, InvalidVocabulary from cript.api.utils.helper_functions import _get_node_type_from_json from cript.api.vocabulary_categories import VocabCategories @@ -20,14 +18,28 @@ class DataSchema: _vocabulary: dict = {} _db_schema: dict = {} - _logger = None # Advanced User Tip: Disabling Node Validation # For experienced users, deactivating node validation during creation can be a time-saver. # Note that the complete node graph will still undergo validation before being saved to the back end. # Caution: It's advisable to keep validation active while debugging scripts, as disabling it can delay error notifications and complicate the debugging process. skip_validation: bool = False - def _get_db_schema(self, host: str) -> dict: + def __init__(self, api): + """ + Initialize DataSchema class with a full hostname to fetch the node validation schema. + + Examples + -------- + ### Create a stand alone DataSchema instance. + >>> import cript + >>> with cript.API(host="https://api.criptapp.org/") as api: + ... data_schema = cript.api.DataSchema(api) + """ + self._api = api + self._vocabulary = {} + self._db_schema = self._get_db_schema() + + def _get_db_schema(self) -> dict: """ Sends a GET request to CRIPT to get the database schema and returns it. The database schema can be used for validating the JSON request @@ -45,80 +57,34 @@ def _get_db_schema(self, host: str) -> dict: return self._db_schema # fetch db_schema from API - if self._logger: - self._logger.info(f"Loading node validation schema from {host}/schema/") + self._api.logger.info(f"Loading node validation schema from {self._api.host}/schema/") # fetch db schema from API - response: requests.Response = requests.get(url=f"{host}/schema/", timeout=_API_TIMEOUT) + response: dict = self._api._capsule_request(url_path="/schema/", method="GET").json() # raise error if not HTTP 200 - response.raise_for_status() - if self._logger: - self._logger.info(f"Loading node validation schema from {host}/schema/ was successful.") + if response["code"] != 200: + raise APIError(api_error=str(response), http_method="GET", api_url="/schema") - # if no error, take the JSON from the API response - response_dict: dict = response.json() + self._api.logger.info(f"Loading node validation schema from {self._api.host}/schema/ was successful.") # get the data from the API JSON response - db_schema = response_dict["data"] + db_schema = response["data"] return db_schema - def __init__(self, host: str, logger=None): + def _fetch_vocab_entry(self, category: VocabCategories): """ - Initialize DataSchema class with a full hostname to fetch the node validation schema. - - Examples - -------- - ### Create a stand alone DataSchema instance. - >>> import cript - >>> with cript.API(host="https://api.criptapp.org/") as api: - ... data_schema = cript.api.DataSchema(api.host) - """ - - self._db_schema = self._get_db_schema(host) - self._vocabulary = self._get_vocab(host) - self._logger = logger - - def _get_vocab(self, host: str) -> dict: - """ - gets the entire CRIPT controlled vocabulary and stores it in _vocabulary - - 1. loops through all controlled vocabulary categories - 1. if the category already exists in the controlled vocabulary then skip that category and continue - 1. if the category does not exist in the `_vocabulary` dict, - then request it from the API and append it to the `_vocabulary` dict - 1. at the end the `_vocabulary` should have all the controlled vocabulary and that will be returned - - Examples - -------- - The vocabulary looks like this - ```json - {'algorithm_key': - [ - { - 'description': "Velocity-Verlet integration algorithm. Parameters: 'integration_timestep'.", - 'name': 'velocity_verlet' - }, - } - ``` + Fetches one the CRIPT controlled vocabulary and stores it in self._vocabulary """ - vocabulary: dict = {} - # loop through all vocabulary categories and make a request to each vocabulary category - # and put them all inside of self._vocab with the keys being the vocab category name - for category in VocabCategories: - vocabulary_category_url: str = f"{host}/cv/{category.value}/" - - # if vocabulary category is not in cache, then get it from API and cache it - response: dict = requests.get(url=vocabulary_category_url, timeout=_API_TIMEOUT).json() + vocabulary_category_url: str = f"/cv/{category.value}/" - if response["code"] != 200: - raise APIError(api_error=str(response), http_method="GET", api_url=vocabulary_category_url) - - # add to cache - vocabulary[category.value] = response["data"] - - return vocabulary + # if vocabulary category is not in cache, then get it from API and cache it + response: dict = self._api._capsule_request(url_path=vocabulary_category_url, method="GET").json() + if response["code"] != 200: + raise APIError(api_error=str(response), http_method="GET", api_url=vocabulary_category_url) + # add to cache + self._vocabulary[category.value] = response["data"] @beartype def get_vocab_by_category(self, category: VocabCategories) -> list: @@ -134,7 +100,7 @@ def get_vocab_by_category(self, category: VocabCategories) -> list: ... api_token=os.getenv("CRIPT_TOKEN"), ... storage_token=os.getenv("CRIPT_STORAGE_TOKEN") ... ) as api: - ... api.validation_schema.get_vocab_by_category(cript.VocabCategories.MATERIAL_IDENTIFIER_KEY) # doctest: +SKIP + ... api.schema.get_vocab_by_category(cript.VocabCategories.MATERIAL_IDENTIFIER_KEY) # doctest: +SKIP Parameters ---------- @@ -146,7 +112,11 @@ def get_vocab_by_category(self, category: VocabCategories) -> list: List[dict] list of JSON containing the controlled vocabulary """ - return self._vocabulary[category.value] + try: + return self._vocabulary[category.value] + except KeyError: + self._fetch_vocab_entry(category) + return self._vocabulary[category.value] @beartype def _is_vocab_valid(self, vocab_category: VocabCategories, vocab_word: str) -> bool: @@ -182,7 +152,7 @@ def _is_vocab_valid(self, vocab_category: VocabCategories, vocab_word: str) -> b # return True # get just the category needed - controlled_vocabulary = self._vocabulary[vocab_category.value] + controlled_vocabulary = self.get_vocab_by_category(vocab_category) # TODO this can be faster with a dict of dicts that can do o(1) look up # looping through an unsorted list is an O(n) look up which is slow @@ -247,8 +217,7 @@ def is_node_schema_valid(self, node_json: str, is_patch: bool = False, force_val else: log_message += " (Can be disabled by setting `cript.API.skip_validation = True`.)" - if self._logger: - self._logger.info(log_message) + self._api.logger.info(log_message) # set the schema to test against http POST or PATCH of DB Schema schema_http_method: str diff --git a/src/cript/api/paginator.py b/src/cript/api/paginator.py index f829a0d27..929954727 100644 --- a/src/cript/api/paginator.py +++ b/src/cript/api/paginator.py @@ -1,12 +1,12 @@ from json import JSONDecodeError -from typing import Dict, List, Optional, Union +from typing import Dict, Union from urllib.parse import quote import requests from beartype import beartype -from cript.api.api_config import _API_TIMEOUT from cript.api.exceptions import APIError +from cript.nodes.util import load_nodes_from_json class Paginator: @@ -14,45 +14,33 @@ class Paginator: Paginator is used to flip through different pages of data that the API returns when searching. > Instead of the user manipulating the URL and parameters, this object handles all of that for them. - When conducting any kind of search the API returns pages of data and each page contains 10 results. - This is equivalent to conducting a Google search when Google returns a limited number of links on the first page - and all other results are on the next pages. + Using the Paginator object, the user can simply and easily flip through the results of the search. + The details, that results are listed as pages are hidden from the user. + The pages are automatically requested from the API as needed. - Using the Paginator object, the user can simply and easily flip through the pages of data the API provides. + This object implements a python iterator, so `for node in Paginator` works as expected. + It will loop through all results of the search, returning the nodes one by one. !!! Warning "Do not create paginator objects" Please note that you are not required or advised to create a paginator object, and instead the Python SDK API object will create a paginator for you, return it, and let you simply use it - - Attributes - ---------- - current_page_results: List[dict] - List of JSON dictionary results returned from the API - ```python - [{result 1}, {result 2}, {result 3}, ...] - ``` """ - _http_headers: dict - - api_endpoint: str - - # if query or page number are None, then it means that api_endpoint does not allow for whatever that is None - # and that is not added to the URL - # by default the page_number and query are `None` and they can get filled in - query: Union[str, None] - _current_page_number: int - - current_page_results: List[dict] + _url_path: str + _query: str + _initial_page_number: Union[int, None] + _current_position: int + _fetched_nodes: list + _number_fetched_pages: int = 0 @beartype def __init__( self, - http_headers: dict, - api_endpoint: str, - query: Optional[str] = None, - current_page_number: int = 0, + api, + url_path: str, + page_number: Union[int, None], + query: str, ): """ create a paginator @@ -77,130 +65,51 @@ def __init__( None instantiate a paginator """ - self._http_headers = http_headers - self.api_endpoint = api_endpoint - self.query = query - self._current_page_number = current_page_number - - # check if it is a string and not None to avoid AttributeError - if api_endpoint is not None: - # strip the ending slash "/" to make URL uniform and any trailing spaces from either side - self.api_endpoint = api_endpoint.rstrip("/").strip() + self._api = api + self._initial_page_number = page_number + self._number_fetched_pages = 0 + self._fetched_nodes = [] + self._current_position = 0 # check if it is a string and not None to avoid AttributeError - if query is not None: - # URL encode query - self.query = quote(query) - - self.fetch_page_from_api() - - def next_page(self): - """ - flip to the next page of data. - - Examples - -------- - ```python - my_paginator.next_page() - ``` - """ - self.current_page_number += 1 - - def previous_page(self): - """ - flip to the next page of data. - - Examples - -------- - ```python - my_paginator.previous_page() - ``` - """ - self.current_page_number -= 1 - - @property - @beartype - def current_page_number(self) -> int: - """ - get the current page number that you are on. - - Setting the page will take you to that specific page of results - - Examples - -------- - ```python - my_paginator.current_page = 10 - ``` - - Returns - ------- - current page number: int - the current page number of the data - """ - return self._current_page_number - - @current_page_number.setter - @beartype - def current_page_number(self, new_page_number: int) -> None: - """ - flips to a specific page of data that has been requested - - sets the current_page_number and then sends the request to the API and gets the results of this page number - - Parameters - ---------- - new_page_number (int): specific page of data that the user wants to go to - - Examples - -------- - requests.get("https://api.criptapp.org//api?page=2) - requests.get(f"{self.query}?page={self.current_page_number - 1}") - - Raises - -------- - InvalidPageRequest, in case the user tries to get a negative page or a page that doesn't exist - """ - if new_page_number < 0: - error_message: str = f"Paginator current page number is invalid because it is negative: " f"{self.current_page_number} please set paginator.current_page_number " f"to a positive page number" - - raise RuntimeError(error_message) + try: + self._url_path = quote(url_path.rstrip("/").strip()) + except Exception as exc: + raise RuntimeError(f"Invalid type for api_endpoint {self._url_path} for a paginator.") from exc - else: - self._current_page_number = new_page_number - # when new page number is set, it is then fetched from the API - self.fetch_page_from_api() + self._query = quote(query) @beartype - def fetch_page_from_api(self) -> List[dict]: + def _fetch_next_page(self) -> None: """ 1. builds the URL from the query and page number 1. makes the request to the API 1. API responds with a JSON that has data or JSON that has data and result - 1. parses it and correctly sets the current_page_results property + 1. parses the response + 2. creates cript.Nodes from the response + 3. Add the nodes to the fetched_data so the iterator can return them Raises ------ InvalidSearchRequest In case the API responds with an error + StopIteration + In case there are no further results to fetch + Returns ------- - current page results: List[dict] - makes a request to the API and gets a page of data + None """ - # temporary variable to not overwrite api_endpoint - temp_api_endpoint: str = self.api_endpoint - - if self.query is not None: - temp_api_endpoint = f"{temp_api_endpoint}/?q={self.query}" + # Composition of the query URL + temp_url_path: str = self._url_path + temp_url_path += f"/?q={self._query}" + if self._initial_page_number is not None: + temp_url_path += f"&page={self._initial_page_number + self._number_fetched_pages}" + self._number_fetched_pages += 1 - elif self.query is None: - temp_api_endpoint = f"{temp_api_endpoint}/?q=" - - temp_api_endpoint = f"{temp_api_endpoint}&page={self.current_page_number}" - - response: requests.Response = requests.get(url=temp_api_endpoint, headers=self._http_headers, timeout=_API_TIMEOUT) + response: requests.Response = self._api._capsule_request(url_path=temp_url_path, method="GET") # it is expected that the response will be JSON # try to convert response to JSON @@ -215,18 +124,37 @@ def fetch_page_from_api(self) -> List[dict]: # handling both cases in case there is result inside of data or just data try: - self.current_page_results = api_response["data"]["result"] + current_page_results = api_response["data"]["result"] except KeyError: - self.current_page_results = api_response["data"] + current_page_results = api_response["data"] except TypeError: - self.current_page_results = api_response["data"] + current_page_results = api_response["data"] if api_response["code"] == 404 and api_response["error"] == "The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.": - self.current_page_results = [] - return self.current_page_results + current_page_results = [] # if API response is not 200 raise error for the user to debug if api_response["code"] != 200: - raise APIError(api_error=str(response.json()), http_method="GET", api_url=temp_api_endpoint) + raise APIError(api_error=str(response.json()), http_method="GET", api_url=temp_url_path) + + node_list = load_nodes_from_json(current_page_results) + self._fetched_nodes += node_list - return self.current_page_results + def __next__(self): + if self._current_position >= len(self._fetched_nodes): + # Without a page number argument, we can only fetch once. + if self._initial_page_number is None and self._number_fetched_pages > 0: + raise StopIteration + self._fetch_next_page() + + self._current_position += 1 + try: + return self._fetched_nodes[self._current_position - 1] + except IndexError: # This is not a random access iteration. + # So if fetching a next page wasn't enough to get the index inbound, + # The iteration stops + raise StopIteration + + def __iter__(self): + self._current_position = 0 + return self diff --git a/src/cript/nodes/util/json.py b/src/cript/nodes/util/json.py index e03435afe..1106f5e0d 100644 --- a/src/cript/nodes/util/json.py +++ b/src/cript/nodes/util/json.py @@ -26,14 +26,13 @@ class NodeEncoder(json.JSONEncoder): Attributes ---------- - handled_ids : Set[str] + handled_ids : set[str] A set to store the UIDs of nodes that have been processed during serialization. - known_uuid : Set[str] + known_uuid : set[str] A set to store the UUIDs of nodes that have been previously encountered in the JSON. - condense_to_uuid : Dict[str, Set[str]] + condense_to_uuid : dict[str, set[str]] A set to store the node types that should be condensed to UUID edges in the JSON. - suppress_attributes : Optional[Dict[str, Set[str]]] - A dictionary that allows suppressing specific attributes for nodes with the corresponding UUIDs. + suppress_attributes : Optional[dict[str, set[str]]] Methods ------- @@ -43,7 +42,7 @@ class NodeEncoder(json.JSONEncoder): ``` ```python - _apply_modifications(self, serialize_dict: Dict) -> Tuple[Dict, List[str]]: + _apply_modifications(self, serialize_dict: dict) -> Tuple[dict, list[str]]: # Apply modifications to the serialized dictionary based on node types # and attributes to be condensed. This internal function handles node # condensation and attribute suppression during serialization. @@ -136,20 +135,21 @@ def default(self, obj): def _apply_modifications(self, serialize_dict: Dict): """ - Checks the serialize_dict to see if any other operations are required before it - can be considered done. If other operations are required, then it passes it to the other operations - and at the end returns the fully finished dict. + Checks the serialize_dict to see if any other operations are required before it + can be considered done. If other operations are required, then it passes it to the other operations + and at the end returns the fully finished dict. - This function is essentially a big switch case that checks the node type - and determines what other operations are required for it. + This function is essentially a big switch case that checks the node type + and determines what other operations are required for it. - Parameters - ---------- - serialize_dict: Dict + Parameters + ---------- + <<<<<<< HEAD + serialize_dict: Dict - Returns - ------- - serialize_dict: Dict + Returns + ------- + serialize_dict: Dict """ def process_attribute(attribute): diff --git a/src/cript/nodes/uuid_base.py b/src/cript/nodes/uuid_base.py index 899b278ed..1a3aa6e33 100644 --- a/src/cript/nodes/uuid_base.py +++ b/src/cript/nodes/uuid_base.py @@ -52,7 +52,7 @@ def url(self): from cript.api.api import _get_global_cached_api api = _get_global_cached_api() - return f"{api.host}/{self.uuid}" + return f"{api.host}/{api.api_prefix}/{api.api_version}/{self.uuid}" def __deepcopy__(self, memo): node = super().__deepcopy__(memo) diff --git a/tests/api/test_api.py b/tests/api/test_api.py index e4323b7ee..0a1b6f954 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -11,20 +11,6 @@ import cript from conftest import HAS_INTEGRATION_TESTS_ENABLED -from cript.api.paginator import Paginator - - -def test_api_with_invalid_host() -> None: - """ - this mostly tests the _prepare_host() function to be sure it is working as expected - * attempting to create an api client with invalid host appropriately throws a `CRIPTConnectionError` - * giving a host that does not start with http such as "criptapp.org" should throw an InvalidHostError - """ - with pytest.raises((requests.ConnectionError, cript.api.exceptions.CRIPTConnectionError)): - cript.API(host="https://some_invalid_host", api_token="123456789", storage_token="123456") - - with pytest.raises(cript.api.exceptions.InvalidHostError): - cript.API(host="no_http_host.org", api_token="123456789", storage_token="987654321") @pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="skipping because API client needs API token") @@ -56,7 +42,7 @@ def test_create_api_with_none() -> None: # assert SDK correctly got env vars to create cript.API with # host/api/v1 - assert api._host == f"{env_var_host}/api/v1" + assert api._host == f"{env_var_host}" assert api._api_token == os.environ["CRIPT_TOKEN"] assert api._storage_token == os.environ["CRIPT_STORAGE_TOKEN"] @@ -80,7 +66,7 @@ def test_config_file() -> None: api = cript.API(config_file_path=config_file_path) - assert api._host == config_file_texts["host"] + "/api/v1" + assert api._host == config_file_texts["host"] assert api._api_token == config_file_texts["api_token"] @@ -148,131 +134,3 @@ def test_upload_and_download_local_file(cript_api, tmp_path_factory) -> None: # assert download file contents are the same as uploaded file contents assert downloaded_file_contents == file_text - - -@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") -def test_api_search_node_type(cript_api: cript.API) -> None: - """ - tests the api.search() method with just a node type material search - - just testing that something comes back from the server - - Notes - ----- - * also tests that it can go to the next page and previous page - * later this test should be expanded to test things that it should expect an error for as well. - * test checks if there are at least 5 things in the paginator - * each page should have a max of 10 results and there should be close to 5k materials in db, - * more than enough to at least have 5 in the paginator - """ - materials_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.NODE_TYPE, value_to_search=None) - - # test search results - assert isinstance(materials_paginator, Paginator) - assert len(materials_paginator.current_page_results) > 5 - first_page_first_result = materials_paginator.current_page_results[0]["name"] - - # just checking that the word has a few characters in it - assert len(first_page_first_result) > 3 - - # tests that it can correctly go to the next page - materials_paginator.next_page() - assert len(materials_paginator.current_page_results) > 5 - second_page_first_result = materials_paginator.current_page_results[0]["name"] - - assert len(second_page_first_result) > 3 - - # tests that it can correctly go to the previous page - materials_paginator.previous_page() - assert len(materials_paginator.current_page_results) > 5 - - assert len(first_page_first_result) > 3 - - -@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") -def test_api_search_contains_name(cript_api: cript.API) -> None: - """ - tests that it can correctly search with contains name mode - searches for a material that contains the name "poly" - """ - contains_name_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.CONTAINS_NAME, value_to_search="poly") - - assert isinstance(contains_name_paginator, Paginator) - assert len(contains_name_paginator.current_page_results) > 5 - - contains_name_first_result = contains_name_paginator.current_page_results[0]["name"] - - # just checking that the result has a few characters in it - assert len(contains_name_first_result) > 3 - - -@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") -def test_api_search_exact_name(cript_api: cript.API) -> None: - """ - tests search method with exact name search - searches for material "Sodium polystyrene sulfonate" - """ - exact_name_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.EXACT_NAME, value_to_search="Sodium polystyrene sulfonate") - - assert isinstance(exact_name_paginator, Paginator) - assert len(exact_name_paginator.current_page_results) == 1 - assert exact_name_paginator.current_page_results[0]["name"] == "Sodium polystyrene sulfonate" - - -@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") -def test_api_search_uuid(cript_api: cript.API, dynamic_material_data) -> None: - """ - tests search with UUID - searches for `Sodium polystyrene sulfonate` material via UUID - - The test is made dynamic to work with any server environment - 1. gets the material via `exact name search` and gets the full node - 2. takes the UUID from the full node and puts it into the `UUID search` - 3. asserts everything is as expected - """ - uuid_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.UUID, value_to_search=dynamic_material_data["uuid"]) - - assert isinstance(uuid_paginator, Paginator) - assert len(uuid_paginator.current_page_results) == 1 - assert uuid_paginator.current_page_results[0]["name"] == dynamic_material_data["name"] - assert uuid_paginator.current_page_results[0]["uuid"] == dynamic_material_data["uuid"] - - -@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") -def test_api_search_bigsmiles(cript_api: cript.API) -> None: - """ - tests search method with bigsmiles SearchMode to see if we just get at least one match - searches for material - "{[][<]C(C)C(=O)O[>][<]}{[$][$]CCC(C)C[$],[$]CC(C(C)C)[$],[$]CC(C)(CC)[$][]}" - - another good example can be "{[][$]CC(C)(C(=O)OCCCC)[$][]}" - """ - bigsmiles_search_value = "{[][<]C(C)C(=O)O[>][<]}{[$][$]CCC(C)C[$],[$]CC(C(C)C)[$],[$]CC(C)(CC)[$][]}" - - bigsmiles_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.BIGSMILES, value_to_search=bigsmiles_search_value) - - assert isinstance(bigsmiles_paginator, Paginator) - assert len(bigsmiles_paginator.current_page_results) >= 1 - # not sure if this will always be in this position in every server environment, so commenting it out for now - # assert bigsmiles_paginator.current_page_results[1]["name"] == "BCDB_Material_285" - - -def test_get_my_user_node_from_api(cript_api: cript.API) -> None: - """ - tests that the Python SDK can successfully get the user node associated with the API Token - """ - pass - - -def test_get_my_group_node_from_api(cript_api: cript.API) -> None: - """ - tests that group node that is associated with their API Token can be gotten correctly - """ - pass - - -def test_get_my_projects_from_api(cript_api: cript.API) -> None: - """ - get a page of project nodes that is associated with the API token - """ - pass diff --git a/tests/api/test_db_schema.py b/tests/api/test_db_schema.py index 9703bb6cd..fd80d4407 100644 --- a/tests/api/test_db_schema.py +++ b/tests/api/test_db_schema.py @@ -122,19 +122,6 @@ def test_get_vocabulary_by_category(cript_api: cript.API) -> None: assert "pubchem_cid" in material_identifiers -def test_get_controlled_vocabulary_from_api(cript_api: cript.API) -> None: - """ - checks if it can successfully get the controlled vocabulary list from CRIPT API - """ - number_of_vocab_categories = 26 - vocab = cript_api.schema._get_vocab(cript_api.host) - - # assertions - # check vocabulary list is not empty - assert bool(vocab) is True - assert len(vocab) == number_of_vocab_categories - - def test_is_vocab_valid(cript_api: cript.API) -> None: """ tests if the method for vocabulary is validating and invalidating correctly diff --git a/tests/api/test_search.py b/tests/api/test_search.py new file mode 100644 index 000000000..860ab43ea --- /dev/null +++ b/tests/api/test_search.py @@ -0,0 +1,104 @@ +import pytest + +import cript +from conftest import HAS_INTEGRATION_TESTS_ENABLED +from cript.api.paginator import Paginator + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") +def test_api_search_node_type(cript_api: cript.API) -> None: + """ + tests the api.search() method with just a node type material search + + just testing that something comes back from the server + + Notes + ----- + * also tests that it can go to the next page and previous page + * later this test should be expanded to test things that it should expect an error for as well. + * test checks if there are at least 5 things in the paginator + * each page should have a max of 10 results and there should be close to 5k materials in db, + * more than enough to at least have 5 in the paginator + """ + materials_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.NODE_TYPE) + + # test search results + assert isinstance(materials_paginator, Paginator) + materials_list = list(materials_paginator) + # Assure that we paginated more then one page + assert materials_paginator._current_page_number > 0 + assert len(materials_list) > 5 + first_page_first_result = materials_list[0]["name"] + # just checking that the word has a few characters in it + assert len(first_page_first_result) > 3 + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") +def test_api_search_contains_name(cript_api: cript.API) -> None: + """ + tests that it can correctly search with contains name mode + searches for a material that contains the name "polystyrene" + """ + contains_name_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.CONTAINS_NAME, value_to_search="polystyrene") + + assert isinstance(contains_name_paginator, Paginator) + contains_name_list = list(contains_name_paginator) + # Assure that we paginated more then one page + assert len(contains_name_list) > 2 + + contains_name_first_result = contains_name_list[0].name + + # just checking that the result has a few characters in it + assert len(contains_name_first_result) > 3 + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") +def test_api_search_exact_name(cript_api: cript.API) -> None: + """ + tests search method with exact name search + searches for material "Sodium polystyrene sulfonate" + """ + exact_name_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.EXACT_NAME, value_to_search="Sodium polystyrene sulfonate") + + assert isinstance(exact_name_paginator, Paginator) + exact_name_list = list(exact_name_paginator) + assert len(exact_name_list) == 1 + assert exact_name_list[0].name == "Sodium polystyrene sulfonate" + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") +def test_api_search_uuid(cript_api: cript.API, dynamic_material_data) -> None: + """ + tests search with UUID + searches for `Sodium polystyrene sulfonate` material via UUID + + The test is made dynamic to work with any server environment + 1. gets the material via `exact name search` and gets the full node + 2. takes the UUID from the full node and puts it into the `UUID search` + 3. asserts everything is as expected + """ + uuid_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.UUID, value_to_search=dynamic_material_data["uuid"]) + + assert isinstance(uuid_paginator, Paginator) + uuid_list = list(uuid_paginator) + assert len(uuid_list) == 1 + assert uuid_list[0].name == dynamic_material_data["name"] + assert str(uuid_list[0].uuid) == dynamic_material_data["uuid"] + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") +def test_api_search_bigsmiles(cript_api: cript.API) -> None: + """ + tests search method with bigsmiles SearchMode to see if we just get at least one match + searches for material + "{[][<]C(C)C(=O)O[>][<]}{[$][$]CCC(C)C[$],[$]CC(C(C)C)[$],[$]CC(C)(CC)[$][]}" + + another good example can be "{[][$]CC(C)(C(=O)OCCCC)[$][]}" + """ + bigsmiles_search_value = "{[][<]C(C)C(=O)O[>][<]}{[$][$]CCC(C)C[$],[$]CC(C(C)C)[$],[$]CC(C)(CC)[$][]}" + + bigsmiles_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.BIGSMILES, value_to_search=bigsmiles_search_value) + + assert isinstance(bigsmiles_paginator, Paginator) + bigsmiles_list = list(bigsmiles_paginator) + assert len(bigsmiles_list) >= 1 diff --git a/tests/fixtures/api_fixtures.py b/tests/fixtures/api_fixtures.py index 82391b5ee..062c19f75 100644 --- a/tests/fixtures/api_fixtures.py +++ b/tests/fixtures/api_fixtures.py @@ -26,6 +26,7 @@ def dynamic_material_data(cript_api: cript.API) -> Dict[str, str]: exact_name_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.EXACT_NAME, value_to_search=material_name) - material_uuid: str = exact_name_paginator.current_page_results[0]["uuid"] + material = next(exact_name_paginator) + material_uuid: str = str(material.uuid) return {"name": material_name, "uuid": material_uuid} diff --git a/tests/utils/integration_test_helper.py b/tests/utils/integration_test_helper.py index 0639a9028..6c319fdbe 100644 --- a/tests/utils/integration_test_helper.py +++ b/tests/utils/integration_test_helper.py @@ -58,10 +58,10 @@ def save_integration_node_helper(cript_api: cript.API, project_node: cript.Proje my_paginator = cript_api.search(node_type=cript.Project, search_mode=cript.SearchModes.EXACT_NAME, value_to_search=project_node.name) # get the project from paginator - my_project_from_api_dict = my_paginator.current_page_results[0] + my_project_from_api_node = my_paginator.next() print("\n\n================= API Response Node ============================") - print(json.dumps(my_project_from_api_dict, sort_keys=False, indent=2)) + print(json.dumps(my_project_from_api_node.json, sort_keys=False, indent=2)) print("==============================================================") # Configure keys and blocks to be ignored by deepdiff using exclude_regex_path @@ -82,7 +82,7 @@ def save_integration_node_helper(cript_api: cript.API, project_node: cript.Proje r"root(\[.*\])?\['model_version'\]", ] # Compare the JSONs - diff = DeepDiff(json.loads(project_node.json), my_project_from_api_dict, exclude_regex_paths=exclude_regex_paths) + diff = DeepDiff(json.loads(project_node.json), json.loads(my_project_from_api_node.json), exclude_regex_paths=exclude_regex_paths) # with open("la", "a") as file_handle: # file_handle.write(str(diff) + "\n") @@ -92,9 +92,8 @@ def save_integration_node_helper(cript_api: cript.API, project_node: cript.Proje # assert not list(diff.get("dictionary_item_added", [])) # try to convert api JSON project to node - my_project_from_api = cript.load_nodes_from_json(json.dumps(my_project_from_api_dict)) print("\n\n=================== Project Node Deserialized =========================") - print(my_project_from_api.get_json(sort_keys=False, condense_to_uuid={}, indent=2).json) + print(my_project_from_api_node.get_json(sort_keys=False, condense_to_uuid={}, indent=2).json) print("==============================================================") print("\n\n\n######################################## TEST Passed ########################################\n\n\n")