diff --git a/.github/workflows/pytest-windows.yml b/.github/workflows/pytest-windows.yml index 6884749..34a557e 100644 --- a/.github/workflows/pytest-windows.yml +++ b/.github/workflows/pytest-windows.yml @@ -11,7 +11,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.10"] + python-version: ["3.11"] os: [windows-latest] steps: diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 3a32dfc..334600e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -11,7 +11,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.8", "3.11"] + python-version: ["3.8", "3.12"] os: [ubuntu-20.04] steps: diff --git a/MANIFEST.in b/MANIFEST.in index 4797e8e..a948aa7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include requirements/* include README.md -include pephubclient/pephub_oauth/* \ No newline at end of file +include pephubclient/pephub_oauth/* +include pephubclient/modules/* \ No newline at end of file diff --git a/Makefile b/Makefile index 5033ebb..40d1dfe 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ run-coverage: coverage run -m pytest html-report: - coverage html + coverage html --omit="*/test*" open-coverage: cd htmlcov && google-chrome index.html diff --git a/docs/changelog.md b/docs/changelog.md index e2bb29a..c0209da 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,8 +2,26 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. +## [0.4.0] - 2024-02-12 +### Added +- a parameter that points to where peps should be saved ([#32](https://github.com/pepkit/pephubclient/issues/32)) +- pep zipping option to `save_pep` function ([#34](https://github.com/pepkit/pephubclient/issues/34)) +- API for samples ([#29](https://github.com/pepkit/pephubclient/issues/29)) +- API for projects ([#28](https://github.com/pepkit/pephubclient/issues/28)) + +### Updated +- Transferred `save_pep` function to helpers + ## [0.3.0] - 2024-01-17 ### Added +- customization of the base PEPhub URL ([#22](https://github.com/pepkit/pephubclient/issues/22)) + +### Updated +- Updated PEPhub API URL +- Increased the required pydantic version to >2.5.0 + +## [0.2.2] - 2024-01-17 +### Added - customization of the base pephub URL. [#22](https://github.com/pepkit/pephubclient/issues/22) ### Updated diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 8e2e1a5..4cdf008 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,8 +1,26 @@ from pephubclient.pephubclient import PEPHubClient +from pephubclient.helpers import is_registry_path, save_pep +import logging +import coloredlogs __app_name__ = "pephubclient" -__version__ = "0.2.2" +__version__ = "0.4.0" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" -__all__ = ["PEPHubClient", __app_name__, __author__, __version__] +__all__ = [ + "PEPHubClient", + __app_name__, + __author__, + __version__, + "is_registry_path", + "save_pep", +] + + +_LOGGER = logging.getLogger(__app_name__) +coloredlogs.install( + logger=_LOGGER, + datefmt="%H:%M:%S", + fmt="[%(levelname)s] [%(asctime)s] %(message)s", +) diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 7553062..7be8cfa 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -29,6 +29,8 @@ def logout(): def pull( project_registry_path: str, force: bool = typer.Option(False, help="Overwrite project if it exists."), + zip: bool = typer.Option(False, help="Save project as zip file."), + output: str = typer.Option(None, help="Output directory."), ): """ Download and save project locally. @@ -37,6 +39,8 @@ def pull( _client.pull, project_registry_path=project_registry_path, force=force, + output=output, + zip=zip, ) diff --git a/pephubclient/constants.py b/pephubclient/constants.py index e36e66a..26e8ed7 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -12,12 +12,18 @@ PEPHUB_PEP_SEARCH_URL = f"{PEPHUB_BASE_URL}api/v1/namespaces/{{namespace}}/projects" PEPHUB_PUSH_URL = f"{PEPHUB_BASE_URL}api/v1/namespaces/{{namespace}}/projects/json" +PEPHUB_SAMPLE_URL = f"{PEPHUB_BASE_URL}api/v1/projects/{{namespace}}/{{project}}/samples/{{sample_name}}" +PEPHUB_VIEW_URL = ( + f"{PEPHUB_BASE_URL}api/v1/projects/{{namespace}}/{{project}}/views/{{view_name}}" +) +PEPHUB_VIEW_SAMPLE_URL = f"{PEPHUB_BASE_URL}api/v1/projects/{{namespace}}/{{project}}/views/{{view_name}}/{{sample_name}}" + class RegistryPath(BaseModel): - protocol: Optional[str] + protocol: Optional[str] = None namespace: str item: str - subitem: Optional[str] + subitem: Optional[str] = None tag: Optional[str] = "default" @field_validator("tag") @@ -33,3 +39,10 @@ class ResponseStatusCodes(int, Enum): NOT_EXIST = 404 CONFLICT = 409 INTERNAL_ERROR = 500 + + +USER_DATA_FILE_NAME = "jwt.txt" +HOME_PATH = os.getenv("HOME") +if not HOME_PATH: + HOME_PATH = os.path.expanduser("~") +PATH_TO_FILE_WITH_JWT = os.path.join(HOME_PATH, ".pephubclient/") + USER_DATA_FILE_NAME diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 3da0d19..a3d9b56 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -4,8 +4,8 @@ import pandas import yaml +import zipfile -from pephubclient.constants import RegistryPath from pephubclient.exceptions import PEPExistsError @@ -29,17 +29,23 @@ def load_jwt_data_from_file(path: str) -> str: return f.read() @staticmethod - def create_project_folder(registry_path: RegistryPath) -> str: + def create_project_folder( + parent_path: str, + folder_name: str, + ) -> str: """ Create new project folder - :param registry_path: project registry path + :param parent_path: parent path to create folder in + :param folder_name: folder name :return: folder_path """ - folder_name = FilesManager._create_filename_to_save_downloaded_project( - registry_path - ) - folder_path = os.path.join(os.getcwd(), folder_name) + if parent_path: + if not Path(parent_path).exists(): + raise OSError( + f"Parent path does not exist. Provided path: {parent_path}" + ) + folder_path = os.path.join(parent_path or os.getcwd(), folder_name) Path(folder_path).mkdir(parents=True, exist_ok=True) return folder_path @@ -62,21 +68,28 @@ def file_exists(full_path: str) -> bool: def delete_file_if_exists(filename: str) -> None: with suppress(FileNotFoundError): os.remove(filename) - - @staticmethod - def _create_filename_to_save_downloaded_project(registry_path: RegistryPath) -> str: - """ - Takes query string and creates output filename to save the project to. - - :param registry_path: Query string that was used to find the project. - :return: Filename uniquely identifying the project. - """ - filename = "_".join(filter(bool, [registry_path.namespace, registry_path.item])) - if registry_path.tag: - filename += f":{registry_path.tag}" - return filename + print( + f"\033[38;5;11m{f'File was deleted successfully -> {filename}'}\033[0m" + ) @staticmethod def check_writable(path: str, force: bool = True): if not force and os.path.isfile(path): raise PEPExistsError(f"File already exists and won't be updated: {path}") + + @staticmethod + def save_zip_file(files_dict: dict, file_path: str, force: bool = False) -> None: + """ + Save zip file with provided files as dict. + + :param files_dict: dict with files to save. e.g. {"file1.txt": "file1 content"} + :param file_path: filename to save zip file to + :param force: overwrite file if exists + :return: None + """ + FilesManager.check_writable(path=file_path, force=force) + with zipfile.ZipFile( + file_path, mode="w", compression=zipfile.ZIP_DEFLATED + ) as zf: + for name, res in files_dict.items(): + zf.writestr(name, str.encode(res)) diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 41f06e9..85979bf 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,14 +1,30 @@ import json -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, Union +import peppy +import yaml +import os +import pandas as pd +from peppy.const import ( + NAME_KEY, + DESC_KEY, + CONFIG_KEY, + SUBSAMPLE_RAW_LIST_KEY, + SAMPLE_RAW_DICT_KEY, + CFG_SAMPLE_TABLE_KEY, + CFG_SUBSAMPLE_TABLE_KEY, +) import requests from requests.exceptions import ConnectionError +from urllib.parse import urlencode from ubiquerg import parse_registry_path from pydantic import ValidationError from pephubclient.exceptions import PEPExistsError, ResponseError from pephubclient.constants import RegistryPath +from pephubclient.files_manager import FilesManager +from pephubclient.models import ProjectDict class RequestManager: @@ -32,20 +48,50 @@ def send_request( ) @staticmethod - def decode_response(response: requests.Response, encoding: str = "utf-8") -> str: + def decode_response( + response: requests.Response, encoding: str = "utf-8", output_json: bool = False + ) -> Union[str, dict]: """ Decode the response from GitHub and pack the returned data into appropriate model. :param response: Response from GitHub. :param encoding: Response encoding [Default: utf-8] + :param output_json: If True, return response in json format :return: Response data as an instance of correct model. """ try: - return response.content.decode(encoding) + if output_json: + return response.json() + else: + return response.content.decode(encoding) except json.JSONDecodeError as err: raise ResponseError(f"Error in response encoding format: {err}") + @staticmethod + def parse_query_param(pep_variables: dict) -> str: + """ + Grab all the variables passed by user (if any) and parse them to match the format specified + by PEPhub API for query parameters. + + :param pep_variables: dict of query parameters + :return: PEPHubClient variables transformed into string in correct format. + """ + return "?" + urlencode(pep_variables) + + @staticmethod + def parse_header(jwt_data: Optional[str] = None) -> dict: + """ + Create Authorization header + + :param jwt_data: jwt string + :return: Authorization dict + """ + if jwt_data: + return {"Authorization": jwt_data} + else: + return {} + class MessageHandler: """ @@ -103,3 +149,157 @@ def is_registry_path(input_string: str) -> bool: except (ValidationError, TypeError): return False return True + + +def _build_filename(registry_path: RegistryPath) -> str: + """ + Takes query string and creates output filename to save the project to. + + :param registry_path: Query string that was used to find the project. + :return: Filename uniquely identifying the project. + """ + filename = "_".join(filter(bool, [registry_path.namespace, registry_path.item])) + if registry_path.tag: + filename += f"_{registry_path.tag}" + return filename + + +def _save_zip_pep(project: dict, zip_filepath: str, force: bool = False) -> None: + """ + Zip and save a project + + :param project: peppy project to zip + :param zip_filepath: path to save zip file + :param force: overwrite project if exists + """ + + content_to_zip = {} + config = project[CONFIG_KEY] + project_name = config[NAME_KEY] + + if project[SAMPLE_RAW_DICT_KEY] is not None: + config[CFG_SAMPLE_TABLE_KEY] = ["sample_table.csv"] + content_to_zip["sample_table.csv"] = pd.DataFrame( + project[SAMPLE_RAW_DICT_KEY] + ).to_csv(index=False) + + if project[SUBSAMPLE_RAW_LIST_KEY] is not None: + if not isinstance(project[SUBSAMPLE_RAW_LIST_KEY], list): + config[CFG_SUBSAMPLE_TABLE_KEY] = ["subsample_table1.csv"] + content_to_zip["subsample_table1.csv"] = pd.DataFrame( + project[SUBSAMPLE_RAW_LIST_KEY] + ).to_csv(index=False) + else: + config[CFG_SUBSAMPLE_TABLE_KEY] = [] + for number, file in enumerate(project[SUBSAMPLE_RAW_LIST_KEY]): + file_name = f"subsample_table{number + 1}.csv" + config[CFG_SUBSAMPLE_TABLE_KEY].append(file_name) + content_to_zip[file_name] = pd.DataFrame(file).to_csv(index=False) + + content_to_zip[f"{project_name}_config.yaml"] = yaml.dump(config, indent=4) + FilesManager.save_zip_file(content_to_zip, file_path=zip_filepath, force=force) + + MessageHandler.print_success(f"Project was saved successfully -> {zip_filepath}") + return None + + +def _save_unzipped_pep( + project_dict: dict, folder_path: str, force: bool = False +) -> None: + """ + Save unzipped project to specified folder + + :param project_dict: raw pep project + :param folder_path: path to save project + :param force: overwrite project if exists + :return: None + """ + + def full_path(fn: str) -> str: + return os.path.join(folder_path, fn) + + project_name = project_dict[CONFIG_KEY][NAME_KEY] + sample_table_filename = "sample_table.csv" + yaml_full_path = full_path(f"{project_name}_config.yaml") + sample_full_path = full_path(sample_table_filename) + if not force: + extant = [p for p in [yaml_full_path, sample_full_path] if os.path.isfile(p)] + if extant: + raise PEPExistsError(f"{len(extant)} file(s) exist(s): {', '.join(extant)}") + + config_dict = project_dict.get(CONFIG_KEY) + config_dict[NAME_KEY] = project_name + config_dict[DESC_KEY] = project_dict[CONFIG_KEY][DESC_KEY] + config_dict["sample_table"] = sample_table_filename + + sample_pandas = pd.DataFrame(project_dict.get(SAMPLE_RAW_DICT_KEY, {})) + + subsample_list = [ + pd.DataFrame(sub_a) for sub_a in project_dict.get(SUBSAMPLE_RAW_LIST_KEY) or [] + ] + + filenames = [] + for idx, subsample in enumerate(subsample_list): + fn = f"subsample_table{idx + 1}.csv" + filenames.append(fn) + FilesManager.save_pandas(subsample, full_path(fn), not_force=False) + config_dict["subsample_table"] = filenames + + FilesManager.save_yaml(config_dict, yaml_full_path, not_force=False) + FilesManager.save_pandas(sample_pandas, sample_full_path, not_force=False) + + if config_dict.get("subsample_table"): + for number, subsample in enumerate(subsample_list): + FilesManager.save_pandas( + subsample, + os.path.join(folder_path, config_dict["subsample_table"][number]), + not_force=False, + ) + + MessageHandler.print_success(f"Project was saved successfully -> {folder_path}") + return None + + +def save_pep( + project: Union[dict, peppy.Project], + reg_path: str = None, + force: bool = False, + project_path: Optional[str] = None, + zip: bool = False, +) -> None: + """ + Save project locally. + + :param dict project: PEP dictionary (raw project) + :param str reg_path: Project registry path in PEPhub (e.g. databio/base:default). If not provided, + folder will be created with just project name. + :param bool force: overwrite project if exists + :param str project_path: Path where project will be saved. By default, it will be saved in current directory. + :param bool zip: If True, save project as zip file + :return: None + """ + if isinstance(project, peppy.Project): + project = project.to_dict(extended=True, orient="records") + + project = ProjectDict(**project).model_dump(by_alias=True) + + if not project_path: + project_path = os.getcwd() + + if reg_path: + file_name = _build_filename(RegistryPath(**parse_registry_path(reg_path))) + else: + file_name = project[CONFIG_KEY][NAME_KEY] + + if zip: + _save_zip_pep( + project, + zip_filepath=f"{os.path.join(project_path, file_name)}.zip", + force=force, + ) + return None + + folder_path = FilesManager.create_project_folder( + parent_path=project_path, folder_name=file_name + ) + _save_unzipped_pep(project, folder_path, force=force) diff --git a/pephubclient/models.py b/pephubclient/models.py index b4a0172..2df7681 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -1,5 +1,5 @@ import datetime -from typing import Optional, List +from typing import Optional, List, Union from pydantic import BaseModel, Field, field_validator, ConfigDict from peppy.const import CONFIG_KEY, SUBSAMPLE_RAW_LIST_KEY, SAMPLE_RAW_DICT_KEY @@ -43,6 +43,9 @@ class ProjectAnnotationModel(BaseModel): submission_date: datetime.datetime digest: str pep_schema: str + pop: bool = False + stars_number: Optional[int] = 0 + forked_from: Optional[Union[str, None]] = None class SearchReturnModel(BaseModel): diff --git a/requirements/requirements-dev.txt b/pephubclient/modules/__init__.py similarity index 100% rename from requirements/requirements-dev.txt rename to pephubclient/modules/__init__.py diff --git a/pephubclient/modules/sample.py b/pephubclient/modules/sample.py new file mode 100644 index 0000000..c8208d1 --- /dev/null +++ b/pephubclient/modules/sample.py @@ -0,0 +1,208 @@ +import logging + +from pephubclient.helpers import RequestManager +from pephubclient.constants import PEPHUB_SAMPLE_URL, ResponseStatusCodes +from pephubclient.exceptions import ResponseError + +_LOGGER = logging.getLogger("pephubclient") + + +class PEPHubSample(RequestManager): + """ + Class for managing samples in PEPhub and provides methods for + getting, creating, updating and removing samples. + This class is not related to peppy.Sample class. + """ + + def __init__(self, jwt_data: str = None): + """ + :param jwt_data: jwt token for authorization + """ + + self.__jwt_data = jwt_data + + def get( + self, + namespace: str, + name: str, + tag: str, + sample_name: str = None, + ) -> dict: + """ + Get sample from project in PEPhub. + + :param namespace: namespace of project + :param name: name of project + :param tag: tag of project + :param sample_name: sample name + :return: Sample object + """ + url = self._build_sample_request_url( + namespace=namespace, name=name, sample_name=sample_name + ) + + url = url + self.parse_query_param(pep_variables={"tag": tag}) + + response = self.send_request( + method="GET", url=url, headers=self.parse_header(self.__jwt_data) + ) + if response.status_code == ResponseStatusCodes.OK: + return self.decode_response(response, output_json=True) + if response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError( + f"Sample does not exist. Project: '{namespace}/{name}:{tag}'. Sample_name: '{sample_name}'" + ) + elif response.status_code == ResponseStatusCodes.INTERNAL_ERROR: + raise ResponseError("Internal server error. Unexpected return value.") + else: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) + + def create( + self, + namespace: str, + name: str, + tag: str, + sample_name: str, + sample_dict: dict, + overwrite: bool = False, + ) -> None: + """ + Create sample in project in PEPhub. + + :param namespace: namespace of project + :param name: name of project + :param tag: tag of project + :param sample_dict: sample dict + :param sample_name: sample name + :param overwrite: overwrite sample if it exists + :return: None + """ + url = self._build_sample_request_url( + namespace=namespace, + name=name, + sample_name=sample_name, + ) + + url = url + self.parse_query_param( + pep_variables={"tag": tag, "overwrite": overwrite} + ) + + # add sample name to sample_dict if it is not there + if sample_name not in sample_dict.values(): + sample_dict["sample_name"] = sample_name + + response = self.send_request( + method="POST", + url=url, + headers=self.parse_header(self.__jwt_data), + json=sample_dict, + ) + if response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Sample '{sample_name}' added to project '{namespace}/{name}:{tag}' successfully." + ) + return None + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError(f"Project '{namespace}/{name}:{tag}' does not exist.") + elif response.status_code == ResponseStatusCodes.CONFLICT: + raise ResponseError( + f"Sample '{sample_name}' already exists. Set overwrite to True to overwrite sample." + ) + else: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) + + def update( + self, + namespace: str, + name: str, + tag: str, + sample_name: str, + sample_dict: dict, + ): + """ + Update sample in project in PEPhub. + + :param namespace: namespace of project + :param name: name of project + :param tag: tag of project + :param sample_name: sample name + :param sample_dict: sample dict, that contain elements to update, or + :return: None + """ + + url = self._build_sample_request_url( + namespace=namespace, name=name, sample_name=sample_name + ) + + url = url + self.parse_query_param(pep_variables={"tag": tag}) + + response = self.send_request( + method="PATCH", + url=url, + headers=self.parse_header(self.__jwt_data), + json=sample_dict, + ) + if response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Sample '{sample_name}' updated in project '{namespace}/{name}:{tag}' successfully." + ) + return None + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError( + f"Sample '{sample_name}' or project {namespace}/{name}:{tag} does not exist. Error: {response.status_code}" + ) + else: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) + + def remove(self, namespace: str, name: str, tag: str, sample_name: str): + """ + Remove sample from project in PEPhub. + + :param namespace: namespace of project + :param name: name of project + :param tag: tag of project + :param sample_name: sample name + :return: None + """ + url = self._build_sample_request_url( + namespace=namespace, name=name, sample_name=sample_name + ) + + url = url + self.parse_query_param(pep_variables={"tag": tag}) + + response = self.send_request( + method="DELETE", + url=url, + headers=self.parse_header(self.__jwt_data), + ) + if response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Sample '{sample_name}' removed from project '{namespace}/{name}:{tag}' successfully." + ) + return None + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError( + f"Sample '{sample_name}' or project {namespace}/{name}:{tag} does not exist. Error: {response.status_code}" + ) + else: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) + + @staticmethod + def _build_sample_request_url(namespace: str, name: str, sample_name: str) -> str: + """ + Build url for sample request. + + :param namespace: namespace where project will be uploaded + :return: url string + """ + return PEPHUB_SAMPLE_URL.format( + namespace=namespace, project=name, sample_name=sample_name + ) diff --git a/pephubclient/modules/view.py b/pephubclient/modules/view.py new file mode 100644 index 0000000..f68d36c --- /dev/null +++ b/pephubclient/modules/view.py @@ -0,0 +1,263 @@ +from typing import Union +import peppy +import logging + +from pephubclient.helpers import RequestManager +from pephubclient.constants import ( + PEPHUB_VIEW_URL, + PEPHUB_VIEW_SAMPLE_URL, + ResponseStatusCodes, +) +from pephubclient.exceptions import ResponseError +from pephubclient.models import ProjectDict + +_LOGGER = logging.getLogger("pephubclient") + + +class PEPHubView(RequestManager): + """ + Class for managing views in PEPhub and provides methods for + getting, creating, updating and removing views. + + This class aims to warp the Views API for easier maintenance and + better user experience. + """ + + def __init__(self, jwt_data: str = None): + """ + :param jwt_data: jwt token for authorization + """ + + self.__jwt_data = jwt_data + + def get( + self, namespace: str, name: str, tag: str, view_name: str, raw: bool = False + ) -> Union[peppy.Project, dict]: + """ + Get view from project in PEPhub. + + :param namespace: namespace of project + :param name: name of project + :param tag: tag of project + :param view_name: name of the view + :param raw: if True, return raw response + :return: peppy.Project object or dictionary of the project (view) + """ + url = self._build_view_request_url( + namespace=namespace, name=name, view_name=view_name + ) + + url = url + self.parse_query_param(pep_variables={"tag": tag}) + + response = self.send_request( + method="GET", url=url, headers=self.parse_header(self.__jwt_data) + ) + if response.status_code == ResponseStatusCodes.OK: + output = self.decode_response(response, output_json=True) + if raw: + return output + output = ProjectDict(**output).model_dump(by_alias=True) + return peppy.Project.from_dict(output) + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("View does not exist, or you are unauthorized.") + else: + raise ResponseError( + f"Internal server error. Unexpected return value. Error: {response.status_code}" + ) + + def create( + self, + namespace: str, + name: str, + tag: str, + view_name: str, + sample_list: list = None, + ): + """ + Create view in project in PEPhub. + + :param namespace: namespace of project + :param name: name of project + :param tag: tag of project + :param view_name: name of the view + :param sample_list: list of sample names + """ + url = self._build_view_request_url( + namespace=namespace, name=name, view_name=view_name + ) + + url = url + self.parse_query_param(pep_variables={"tag": tag}) + + response = self.send_request( + method="POST", + url=url, + headers=self.parse_header(self.__jwt_data), + json=sample_list, + ) + if response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"View '{view_name}' created in project '{namespace}/{name}:{tag}' successfully." + ) + return None + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError( + f"Project '{namespace}/{name}:{tag}' or one of the samples does not exist." + ) + elif response.status_code == ResponseStatusCodes.CONFLICT: + raise ResponseError(f"View '{view_name}' already exists in the project.") + else: + raise ResponseError(f"Unexpected return value.{response.status_code}") + + def delete(self, namespace: str, name: str, tag: str, view_name: str) -> None: + """ + Delete view from project in PEPhub. + + :param namespace: namespace of project + :param name: name of project + :param tag: tag of project + :param view_name: name of the view + :return: None + """ + url = self._build_view_request_url( + namespace=namespace, name=name, view_name=view_name + ) + + url = url + self.parse_query_param(pep_variables={"tag": tag}) + + response = self.send_request( + method="DELETE", url=url, headers=self.parse_header(self.__jwt_data) + ) + + if response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"View '{view_name}' deleted from project '{namespace}/{name}:{tag}' successfully." + ) + return None + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("View does not exists, or you are unauthorized.") + elif response.status_code == ResponseStatusCodes.UNAUTHORIZED: + raise ResponseError("You are unauthorized to delete this view.") + else: + raise ResponseError("Unexpected return value. ") + + def add_sample( + self, + namespace: str, + name: str, + tag: str, + view_name: str, + sample_name: str, + ): + """ + Add sample to view in project in PEPhub. + + :param namespace: namespace of project + :param name: name of project + :param tag: tag of project + :param view_name: name of the view + :param sample_name: name of the sample + """ + url = self._build_view_request_url( + namespace=namespace, + name=name, + view_name=view_name, + sample_name=sample_name, + ) + + url = url + self.parse_query_param(pep_variables={"tag": tag}) + + response = self.send_request( + method="POST", + url=url, + headers=self.parse_header(self.__jwt_data), + ) + if response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Sample '{sample_name}' added to view '{view_name}' in project '{namespace}/{name}:{tag}' successfully." + ) + return None + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError( + f"Sample '{sample_name}' or project {namespace}/{name}:{tag} does not exist." + ) + elif response.status_code == ResponseStatusCodes.CONFLICT: + raise ResponseError(f"Sample '{sample_name}' already exists in the view.") + else: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) + + def remove_sample( + self, + namespace: str, + name: str, + tag: str, + view_name: str, + sample_name: str, + ): + """ + Remove sample from view in project in PEPhub. + + :param namespace: namespace of project + :param name: name of project + :param tag: tag of project + :param view_name: name of the view + :param sample_name: name of the sample + :return: None + """ + url = self._build_view_request_url( + namespace=namespace, + name=name, + view_name=view_name, + sample_name=sample_name, + ) + + url = url + self.parse_query_param(pep_variables={"tag": tag}) + + response = self.send_request( + method="DELETE", + url=url, + headers=self.parse_header(self.__jwt_data), + ) + if response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Sample '{sample_name}' removed from view '{view_name}' in project '{namespace}/{name}:{tag}' successfully." + ) + return None + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError( + f"Sample '{sample_name}' or project {namespace}/{name}:{tag} does not exist. " + ) + elif response.status_code == ResponseStatusCodes.UNAUTHORIZED: + raise ResponseError( + "You are unauthorized to remove this sample from the view." + ) + else: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) + + @staticmethod + def _build_view_request_url( + namespace: str, name: str, view_name: str, sample_name: str = None + ): + """ + Build URL for view request. + + :param namespace: namespace of project + :param name: name of project + :param view_name: name of view + :return: URL + """ + if sample_name: + return PEPHUB_VIEW_SAMPLE_URL.format( + namespace=namespace, + project=name, + view_name=view_name, + sample_name=sample_name, + ) + return PEPHUB_VIEW_URL.format( + namespace=namespace, + project=name, + view_name=view_name, + ) diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 118bdd2..6aa54ed 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,17 +1,8 @@ -import json -import os from typing import NoReturn, Optional, Literal +from typing_extensions import deprecated -import pandas as pd import peppy -from peppy.const import ( - NAME_KEY, - DESC_KEY, - CONFIG_KEY, - SUBSAMPLE_RAW_LIST_KEY, - SAMPLE_RAW_DICT_KEY, -) -import requests +from peppy.const import NAME_KEY import urllib3 from pydantic import ValidationError from ubiquerg import parse_registry_path @@ -22,14 +13,14 @@ RegistryPath, ResponseStatusCodes, PEPHUB_PEP_SEARCH_URL, + PATH_TO_FILE_WITH_JWT, ) from pephubclient.exceptions import ( IncorrectQueryStringError, - PEPExistsError, ResponseError, ) from pephubclient.files_manager import FilesManager -from pephubclient.helpers import MessageHandler, RequestManager +from pephubclient.helpers import MessageHandler, RequestManager, save_pep from pephubclient.models import ( ProjectDict, ProjectUploadData, @@ -37,21 +28,26 @@ ProjectAnnotationModel, ) from pephubclient.pephub_oauth.pephub_oauth import PEPHubAuth +from pephubclient.modules.view import PEPHubView +from pephubclient.modules.sample import PEPHubSample urllib3.disable_warnings() class PEPHubClient(RequestManager): - USER_DATA_FILE_NAME = "jwt.txt" - home_path = os.getenv("HOME") - if not home_path: - home_path = os.path.expanduser("~") - PATH_TO_FILE_WITH_JWT = ( - os.path.join(home_path, ".pephubclient/") + USER_DATA_FILE_NAME - ) - def __init__(self): - self.registry_path = None + self.__jwt_data = FilesManager.load_jwt_data_from_file(PATH_TO_FILE_WITH_JWT) + + self.__view = PEPHubView(self.__jwt_data) + self.__sample = PEPHubSample(self.__jwt_data) + + @property + def view(self) -> PEPHubView: + return self.__view + + @property + def sample(self) -> PEPHubSample: + return self.__sample def login(self) -> NoReturn: """ @@ -59,29 +55,42 @@ def login(self) -> NoReturn: """ user_token = PEPHubAuth().login_to_pephub() - FilesManager.save_jwt_data_to_file(self.PATH_TO_FILE_WITH_JWT, user_token) + FilesManager.save_jwt_data_to_file(PATH_TO_FILE_WITH_JWT, user_token) + self.__jwt_data = FilesManager.load_jwt_data_from_file(PATH_TO_FILE_WITH_JWT) def logout(self) -> NoReturn: """ Log out from PEPhub """ - FilesManager.delete_file_if_exists(self.PATH_TO_FILE_WITH_JWT) + FilesManager.delete_file_if_exists(PATH_TO_FILE_WITH_JWT) + self.__jwt_data = None - def pull(self, project_registry_path: str, force: Optional[bool] = False) -> None: + def pull( + self, + project_registry_path: str, + force: Optional[bool] = False, + zip: Optional[bool] = False, + output: Optional[str] = None, + ) -> None: """ Download project locally :param str project_registry_path: Project registry path in PEPhub (e.g. databio/base:default) :param bool force: if project exists, overwrite it. + :param bool zip: if True, save project as zip file + :param str output: path where project will be saved :return: None """ - jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) - project_dict = self._load_raw_pep( - registry_path=project_registry_path, jwt_data=jwt_data + project_dict = self.load_raw_pep( + registry_path=project_registry_path, ) - self._save_raw_pep( - reg_path=project_registry_path, project_dict=project_dict, force=force + save_pep( + project=project_dict, + reg_path=project_registry_path, + force=force, + project_path=output, + zip=zip, ) def load_project( @@ -96,8 +105,7 @@ def load_project( :param query_param: query parameters used in get request :return Project: peppy project. """ - jwt = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) - raw_pep = self._load_raw_pep(project_registry_path, jwt, query_param) + raw_pep = self.load_raw_pep(project_registry_path, query_param) peppy_project = peppy.Project().from_dict(raw_pep) return peppy_project @@ -153,7 +161,6 @@ def upload( :param force: overwrite project if it exists :return: None """ - jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) if name: project[NAME_KEY] = name @@ -169,7 +176,7 @@ def upload( pephub_response = self.send_request( method="POST", url=self._build_push_request_url(namespace=namespace), - headers=self._get_header(jwt_data), + headers=self.parse_header(self.__jwt_data), json=upload_data.model_dump(), cookies=None, ) @@ -215,7 +222,6 @@ def find_project( :param end_date: filter end date (if none today's date is used) :return: """ - jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) query_param = { "q": query_string, @@ -236,100 +242,47 @@ def find_project( pephub_response = self.send_request( method="GET", url=url, - headers=self._get_header(jwt_data), + headers=self.parse_header(self.__jwt_data), json=None, cookies=None, ) if pephub_response.status_code == ResponseStatusCodes.OK: - decoded_response = self._handle_pephub_response(pephub_response) + decoded_response = self.decode_response(pephub_response, output_json=True) project_list = [] - for project_found in json.loads(decoded_response)["items"]: + for project_found in decoded_response["items"]: project_list.append(ProjectAnnotationModel(**project_found)) - return SearchReturnModel(**json.loads(decoded_response)) + return SearchReturnModel(**decoded_response) - @staticmethod - def _save_raw_pep( - reg_path: str, - project_dict: dict, - force: bool = False, - ) -> None: + @deprecated("This method is deprecated. Use load_raw_pep instead.") + def _load_raw_pep( + self, + registry_path: str, + jwt_data: Optional[str] = None, + query_param: Optional[dict] = None, + ) -> dict: """ - Save project locally. + !!! This method is deprecated. Use load_raw_pep instead. !!! - :param dict project_dict: PEP dictionary (raw project) - :param bool force: overwrite project if exists - :return: None + Request PEPhub and return the requested project as peppy.Project object. + + :param registry_path: Project namespace, eg. "geo/GSE124224:tag" + :param query_param: Optional variables to be passed to PEPhub + :return: Raw project in dict. """ - reg_path_model = RegistryPath(**parse_registry_path(reg_path)) - folder_path = FilesManager.create_project_folder(registry_path=reg_path_model) - - def full_path(fn: str) -> str: - return os.path.join(folder_path, fn) - - project_name = project_dict[CONFIG_KEY][NAME_KEY] - sample_table_filename = "sample_table.csv" - yaml_full_path = full_path(f"{project_name}_config.yaml") - sample_full_path = full_path(sample_table_filename) - if not force: - extant = [ - p for p in [yaml_full_path, sample_full_path] if os.path.isfile(p) - ] - if extant: - raise PEPExistsError( - f"{len(extant)} file(s) exist(s): {', '.join(extant)}" - ) - - config_dict = project_dict.get(CONFIG_KEY) - config_dict[NAME_KEY] = project_name - config_dict[DESC_KEY] = project_dict[CONFIG_KEY][DESC_KEY] - config_dict["sample_table"] = sample_table_filename - - sample_pandas = pd.DataFrame(project_dict.get(SAMPLE_RAW_DICT_KEY, {})) - - subsample_list = [ - pd.DataFrame(sub_a) - for sub_a in project_dict.get(SUBSAMPLE_RAW_LIST_KEY) or [] - ] - - filenames = [] - for idx, subsample in enumerate(subsample_list): - fn = f"subsample_table{idx + 1}.csv" - filenames.append(fn) - FilesManager.save_pandas(subsample, full_path(fn), not_force=False) - config_dict["subsample_table"] = filenames - - FilesManager.save_yaml(config_dict, yaml_full_path, not_force=False) - FilesManager.save_pandas(sample_pandas, sample_full_path, not_force=False) - - if config_dict.get("subsample_table"): - for number, subsample in enumerate(subsample_list): - FilesManager.save_pandas( - subsample, - os.path.join(folder_path, config_dict["subsample_table"][number]), - not_force=False, - ) - - MessageHandler.print_success( - f"Project was downloaded successfully -> {folder_path}" - ) - return None + return self.load_raw_pep(registry_path, query_param) - def _load_raw_pep( + def load_raw_pep( self, registry_path: str, - jwt_data: Optional[str] = None, query_param: Optional[dict] = None, ) -> dict: - """project_name + """ Request PEPhub and return the requested project as peppy.Project object. :param registry_path: Project namespace, eg. "geo/GSE124224:tag" :param query_param: Optional variables to be passed to PEPhub - :param jwt_data: JWT token. :return: Raw project in dict. """ - if not jwt_data: - jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) query_param = query_param or {} query_param["raw"] = "true" @@ -337,12 +290,12 @@ def _load_raw_pep( pephub_response = self.send_request( method="GET", url=self._build_pull_request_url(query_param=query_param), - headers=self._get_header(jwt_data), + headers=self.parse_header(self.__jwt_data), cookies=None, ) if pephub_response.status_code == ResponseStatusCodes.OK: - decoded_response = self._handle_pephub_response(pephub_response) - correct_proj_dict = ProjectDict(**json.loads(decoded_response)) + decoded_response = self.decode_response(pephub_response, output_json=True) + correct_proj_dict = ProjectDict(**decoded_response) # This step is necessary because of this issue: https://github.com/pepkit/pephub/issues/124 return correct_proj_dict.model_dump(by_alias=True) @@ -366,19 +319,6 @@ def _set_registry_data(self, query_string: str) -> None: except (ValidationError, TypeError): raise IncorrectQueryStringError(query_string=query_string) - @staticmethod - def _get_header(jwt_data: Optional[str] = None) -> dict: - """ - Create Authorization header - - :param jwt_data: jwt string - :return: Authorization dict - """ - if jwt_data: - return {"Authorization": jwt_data} - else: - return {} - def _build_pull_request_url(self, query_param: dict = None) -> str: """ Build request for getting projects form pephub @@ -391,14 +331,13 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: endpoint = self.registry_path.namespace + "/" + self.registry_path.item - variables_string = PEPHubClient._parse_query_param(query_param) + variables_string = self.parse_query_param(query_param) endpoint += variables_string return PEPHUB_PEP_API_BASE_URL + endpoint - def _build_project_search_url( - self, namespace: str, query_param: dict = None - ) -> str: + @staticmethod + def _build_project_search_url(namespace: str, query_param: dict = None) -> str: """ Build request for searching projects form pephub @@ -406,7 +345,7 @@ def _build_project_search_url( :return: url string """ - variables_string = PEPHubClient._parse_query_param(query_param) + variables_string = RequestManager.parse_query_param(query_param) endpoint = variables_string return PEPHUB_PEP_SEARCH_URL.format(namespace=namespace) + endpoint @@ -420,30 +359,3 @@ def _build_push_request_url(namespace: str) -> str: :return: url string """ return PEPHUB_PUSH_URL.format(namespace=namespace) - - @staticmethod - def _parse_query_param(pep_variables: dict) -> str: - """ - Grab all the variables passed by user (if any) and parse them to match the format specified - by PEPhub API for query parameters. - - :param pep_variables: dict of query parameters - :return: PEPHubClient variables transformed into string in correct format. - """ - parsed_variables = [] - - for variable_name, variable_value in pep_variables.items(): - parsed_variables.append(f"{variable_name}={variable_value}") - return "?" + "&".join(parsed_variables) - - @staticmethod - def _handle_pephub_response(pephub_response: requests.Response): - """ - Check pephub response - """ - decoded_response = PEPHubClient.decode_response(pephub_response) - - if pephub_response.status_code != ResponseStatusCodes.OK: - raise ResponseError(message=json.loads(decoded_response).get("detail")) - - return decoded_response diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index e091589..3dd6043 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -1,6 +1,7 @@ typer>=0.7.0 -peppy>=0.40.0 +peppy>=0.40.1 requests>=2.28.2 pydantic>2.5.0 pandas>=2.0.0 -ubiquerg>=0.6.3 \ No newline at end of file +ubiquerg>=0.6.3 +coloredlogs>=15.0.1 \ No newline at end of file diff --git a/setup.py b/setup.py index aaf8893..cdf8165 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ def read_reqs(reqs_name): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Bio-Informatics", ], keywords="project, bioinformatics, metadata", @@ -58,7 +59,7 @@ def read_reqs(reqs_name): scripts=None, include_package_data=True, test_suite="tests", - tests_require=read_reqs("dev"), + tests_require=read_reqs("test"), setup_requires=( ["pytest-runner"] if {"test", "pytest", "ptr"} & set(sys.argv) else [] ), diff --git a/tests/conftest.py b/tests/conftest.py index 48b4483..e0a5469 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -import json - import pytest from pephubclient.pephub_oauth.models import InitializeDeviceCodeResponse @@ -29,7 +27,7 @@ def test_raw_pep_return(): {"time": "0", "file_path": "source1", "sample_name": "frog_0h"}, ], } - return json.dumps(sample_prj) + return sample_prj @pytest.fixture diff --git a/tests/test_manual.py b/tests/test_manual.py new file mode 100644 index 0000000..1848210 --- /dev/null +++ b/tests/test_manual.py @@ -0,0 +1,101 @@ +from pephubclient.pephubclient import PEPHubClient +import pytest + + +@pytest.mark.skip(reason="Manual test") +class TestViewsManual: + + def test_get(self): + ff = PEPHubClient().view.get( + "databio", + "bedset1", + "default", + "test_view", + ) + print(ff) + + def test_create(self): + PEPHubClient().view.create( + "databio", + "bedset1", + "default", + "test_view", + sample_list=["orange", "grape1", "apple1"], + ) + + def test_delete(self): + PEPHubClient().view.delete( + "databio", + "bedset1", + "default", + "test_view", + ) + + def test_add_sample(self): + PEPHubClient().view.add_sample( + "databio", + "bedset1", + "default", + "test_view", + "name", + ) + + def test_delete_sample(self): + PEPHubClient().view.remove_sample( + "databio", + "bedset1", + "default", + "test_view", + "name", + ) + + +@pytest.mark.skip(reason="Manual test") +class TestSamplesManual: + def test_manual(self): + ff = PEPHubClient().sample.get( + "databio", + "bedset1", + "default", + "grape1", + ) + ff + + def test_update(self): + ff = PEPHubClient().sample.get( + "databio", + "bedset1", + "default", + "newf", + ) + ff.update({"shefflab": "test1"}) + ff["sample_type"] = "new_type" + PEPHubClient().sample.update( + "databio", + "bedset1", + "default", + "newf", + sample_dict=ff, + ) + + def test_add(self): + ff = { + "genome": "phc_test1", + "sample_type": "phc_test", + } + PEPHubClient().sample.create( + "databio", + "bedset1", + "default", + "new_2222", + overwrite=False, + sample_dict=ff, + ) + + def test_delete(self): + PEPHubClient().sample.remove( + "databio", + "bedset1", + "default", + "new_2222", + ) diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index c35c8ac..6a9aec9 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -61,7 +61,7 @@ def test_pull(self, mocker, test_jwt, test_raw_pep_return): return_value=Mock(content="some return", status_code=200), ) mocker.patch( - "pephubclient.pephubclient.PEPHubClient._handle_pephub_response", + "pephubclient.helpers.RequestManager.decode_response", return_value=test_raw_pep_return, ) save_yaml_mock = mocker.patch( @@ -150,11 +150,38 @@ def test_push_with_pephub_error_response( ) def test_search_prj(self, mocker): - return_value = b'{"count":1,"limit":100,"offset":0,"items":[{"namespace":"namespace1","name":"basic","tag":"default","is_private":false,"number_of_samples":2,"description":"None","last_update_date":"2023-08-27 19:07:31.552861+00:00","submission_date":"2023-08-27 19:07:31.552858+00:00","digest":"08cbcdbf4974fc84bee824c562b324b5","pep_schema":"random_schema_name"}],"session_info":null,"can_edit":false}' + return_value = { + "count": 1, + "limit": 100, + "offset": 0, + "items": [ + { + "namespace": "namespace1", + "name": "basic", + "tag": "default", + "is_private": False, + "number_of_samples": 2, + "description": "None", + "last_update_date": "2023-08-27 19:07:31.552861+00:00", + "submission_date": "2023-08-27 19:07:31.552858+00:00", + "digest": "08cbcdbf4974fc84bee824c562b324b5", + "pep_schema": "random_schema_name", + "pop": False, + "stars_number": 0, + "forked_from": None, + } + ], + "session_info": None, + "can_edit": False, + } mocker.patch( "requests.request", return_value=Mock(content=return_value, status_code=200), ) + mocker.patch( + "pephubclient.helpers.RequestManager.decode_response", + return_value=return_value, + ) return_value = PEPHubClient().find_project(namespace="namespace1") assert return_value.count == 1 @@ -201,3 +228,385 @@ class TestHelpers: ) def test_is_registry_path(self, input_str, expected_output): assert is_registry_path(input_str) is expected_output + + +class TestSamples: + + def test_get(self, mocker): + return_value = { + "genome": "phc_test1", + "sample_type": "phc_test", + "sample_name": "gg1", + } + mocker.patch( + "requests.request", + return_value=Mock(content=return_value, status_code=200), + ) + mocker.patch( + "pephubclient.helpers.RequestManager.decode_response", + return_value=return_value, + ) + return_value = PEPHubClient().sample.get( + "test_namespace", + "taest_name", + "default", + "gg1", + ) + assert return_value == return_value + + @pytest.mark.parametrize( + "status_code, expected_error_message", + [ + ( + 404, + "Sample does not exist.", + ), + ( + 500, + "Internal server error. Unexpected return value.", + ), + ( + 403, + "Unexpected return value. Error: 403", + ), + ], + ) + def test_sample_get_with_pephub_error_response( + self, mocker, status_code, expected_error_message + ): + mocker.patch("requests.request", return_value=Mock(status_code=status_code)) + with pytest.raises(ResponseError, match=expected_error_message): + PEPHubClient().sample.get( + "test_namespace", + "taest_name", + "default", + "gg1", + ) + + @pytest.mark.parametrize( + "prj_dict", + [ + {"genome": "phc_test1", "sample_type": "phc_test", "sample_name": "gg1"}, + {"genome": "phc_test1", "sample_type": "phc_test"}, + ], + ) + def test_create(self, mocker, prj_dict): + return_value = prj_dict + mocker_obj = mocker.patch( + "requests.request", + return_value=Mock(content=return_value, status_code=202), + ) + + PEPHubClient().sample.create( + "test_namespace", + "taest_name", + "default", + "gg1", + sample_dict=return_value, + ) + assert mocker_obj.called + + @pytest.mark.parametrize( + "status_code, expected_error_message", + [ + ( + 404, + "does not exist.", + ), + ( + 409, + "already exists. Set overwrite to True to overwrite sample.", + ), + ( + 500, + "Unexpected return value.", + ), + ], + ) + def test_sample_create_with_pephub_error_response( + self, mocker, status_code, expected_error_message + ): + mocker.patch("requests.request", return_value=Mock(status_code=status_code)) + with pytest.raises(ResponseError, match=expected_error_message): + PEPHubClient().sample.create( + "test_namespace", + "taest_name", + "default", + "gg1", + sample_dict={ + "genome": "phc_test1", + "sample_type": "phc_test", + "sample_name": "gg1", + }, + ) + + def test_delete(self, mocker): + mocker_obj = mocker.patch( + "requests.request", + return_value=Mock(status_code=202), + ) + + PEPHubClient().sample.remove( + "test_namespace", + "taest_name", + "default", + "gg1", + ) + assert mocker_obj.called + + @pytest.mark.parametrize( + "status_code, expected_error_message", + [ + ( + 404, + "does not exist.", + ), + ( + 500, + "Unexpected return value.", + ), + ], + ) + def test_sample_delete_with_pephub_error_response( + self, mocker, status_code, expected_error_message + ): + mocker.patch("requests.request", return_value=Mock(status_code=status_code)) + with pytest.raises(ResponseError, match=expected_error_message): + PEPHubClient().sample.remove( + "test_namespace", + "taest_name", + "default", + "gg1", + ) + + def test_update(self, mocker): + mocker_obj = mocker.patch( + "requests.request", + return_value=Mock(status_code=202), + ) + + PEPHubClient().sample.update( + "test_namespace", + "taest_name", + "default", + "gg1", + sample_dict={ + "genome": "phc_test1", + "sample_type": "phc_test", + "new_col": "column", + }, + ) + assert mocker_obj.called + + @pytest.mark.parametrize( + "status_code, expected_error_message", + [ + ( + 404, + "does not exist.", + ), + ( + 500, + "Unexpected return value.", + ), + ], + ) + def test_sample_update_with_pephub_error_response( + self, mocker, status_code, expected_error_message + ): + mocker.patch("requests.request", return_value=Mock(status_code=status_code)) + with pytest.raises(ResponseError, match=expected_error_message): + PEPHubClient().sample.update( + "test_namespace", + "taest_name", + "default", + "gg1", + sample_dict={ + "genome": "phc_test1", + "sample_type": "phc_test", + "new_col": "column", + }, + ) + + +class TestViews: + def test_get(self, mocker, test_raw_pep_return): + return_value = test_raw_pep_return + mocker.patch( + "requests.request", + return_value=Mock(content=return_value, status_code=200), + ) + mocker.patch( + "pephubclient.helpers.RequestManager.decode_response", + return_value=return_value, + ) + + return_value = PEPHubClient().view.get( + "test_namespace", + "taest_name", + "default", + "gg1", + ) + assert return_value == return_value + + @pytest.mark.parametrize( + "status_code, expected_error_message", + [ + ( + 404, + "does not exist.", + ), + ( + 500, + "Internal server error.", + ), + ], + ) + def test_view_get_with_pephub_error_response( + self, mocker, status_code, expected_error_message + ): + mocker.patch("requests.request", return_value=Mock(status_code=status_code)) + with pytest.raises(ResponseError, match=expected_error_message): + PEPHubClient().view.get( + "test_namespace", + "taest_name", + "default", + "gg1", + ) + + def test_create(self, mocker): + mocker_obj = mocker.patch( + "requests.request", + return_value=Mock(status_code=202), + ) + + PEPHubClient().view.create( + "test_namespace", + "taest_name", + "default", + "gg1", + sample_list=["sample1", "sample2"], + ) + assert mocker_obj.called + + @pytest.mark.parametrize( + "status_code, expected_error_message", + [ + ( + 404, + "does not exist.", + ), + ( + 409, + "already exists in the project.", + ), + ], + ) + def test_view_create_with_pephub_error_response( + self, mocker, status_code, expected_error_message + ): + mocker.patch("requests.request", return_value=Mock(status_code=status_code)) + with pytest.raises(ResponseError, match=expected_error_message): + PEPHubClient().view.create( + "test_namespace", + "taest_name", + "default", + "gg1", + sample_list=["sample1", "sample2"], + ) + + def test_delete(self, mocker): + mocker_obj = mocker.patch( + "requests.request", + return_value=Mock(status_code=202), + ) + + PEPHubClient().view.delete( + "test_namespace", + "taest_name", + "default", + "gg1", + ) + assert mocker_obj.called + + @pytest.mark.parametrize( + "status_code, expected_error_message", + [ + ( + 404, + "does not exist.", + ), + ( + 401, + "You are unauthorized to delete this view.", + ), + ], + ) + def test_view_delete_with_pephub_error_response( + self, mocker, status_code, expected_error_message + ): + mocker.patch("requests.request", return_value=Mock(status_code=status_code)) + with pytest.raises(ResponseError, match=expected_error_message): + PEPHubClient().view.delete( + "test_namespace", + "taest_name", + "default", + "gg1", + ) + + def test_add_sample(self, mocker): + mocker_obj = mocker.patch( + "requests.request", + return_value=Mock(status_code=202), + ) + + PEPHubClient().view.add_sample( + "test_namespace", + "taest_name", + "default", + "gg1", + "sample1", + ) + assert mocker_obj.called + + def test_delete_sample(self, mocker): + mocker_obj = mocker.patch( + "requests.request", + return_value=Mock(status_code=202), + ) + + PEPHubClient().view.remove_sample( + "test_namespace", + "taest_name", + "default", + "gg1", + "sample1", + ) + assert mocker_obj.called + + +### + + +# test add sample: +# 1. add correct 202 +# 2. add existing 409 +# 3. add with sample_name +# 4. add without sample_name +# 5. add with overwrite +# 6. add to unexisting project 404 + +# delete sample: +# 1. delete existing 202 +# 2. delete unexisting 404 + +# get sample: +# 1. get existing 200 +# 2. get unexisting 404 +# 3. get with raw 200 +# 4. get from unexisting project 404 + +# update sample: +# 1. update existing 202 +# 2. update unexisting sample 404 +# 3. update unexisting project 404