diff --git a/peppy/pephubclient/cli.py b/peppy/pephubclient/cli.py index 20c90813..a9a86654 100644 --- a/peppy/pephubclient/cli.py +++ b/peppy/pephubclient/cli.py @@ -2,6 +2,7 @@ from .helpers import call_client_func from .pephubclient import PEPHubClient +from .schemas.schema_cli import schemas_app _client = PEPHubClient() @@ -9,11 +10,18 @@ @app.command() -def login(): +def login( + token: str = typer.Option( + None, help="JWT token from PEPhub. If provided, skips the browser login." + ), + url: str = typer.Option( + None, help="Base URL for PEPhub, if not using the default host." + ), +): """ Login to PEPhub """ - call_client_func(_client.login) + call_client_func(_client.login, token=token, url=url) @app.command() @@ -71,3 +79,6 @@ def push( is_private=is_private, force=force, ) + + +app.add_typer(schemas_app, name="schema") diff --git a/peppy/pephubclient/constants.py b/peppy/pephubclient/constants.py index f37a95c9..483ee9db 100644 --- a/peppy/pephubclient/constants.py +++ b/peppy/pephubclient/constants.py @@ -3,13 +3,9 @@ from pydantic import BaseModel, field_validator -PEPHUB_BASE_URL = os.getenv( - "PEPHUB_BASE_URL", default="https://pephub-api.databio.org/" -) +DEFAULT_BASE_URL = "https://pephub-api.databio.org/" +PEPHUB_BASE_URL = os.getenv("PEPHUB_BASE_URL", default=DEFAULT_BASE_URL) # PEPHUB_BASE_URL = "http://0.0.0.0:8000/" -PEPHUB_PEP_API_BASE_URL = f"{PEPHUB_BASE_URL}api/v1/projects/" -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 = ( @@ -30,6 +26,13 @@ def tag_should_not_be_none(cls, v): return v or "default" +class CachedToken(BaseModel): + """Credentials persisted to the TOML cache file.""" + + token: str | None = None + base_url: str = DEFAULT_BASE_URL + + class ResponseStatusCodes(int, Enum): OK = 200 ACCEPTED = 202 @@ -40,8 +43,12 @@ class ResponseStatusCodes(int, Enum): 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 +USER_DATA_FILE_NAME = "jwt.toml" + +PH_HOME = os.getenv("PH_HOME") +if PH_HOME: + _CACHE_DIR = PH_HOME +else: + HOME_PATH = os.getenv("HOME") or os.path.expanduser("~") + _CACHE_DIR = os.path.join(HOME_PATH, ".pephubclient") +PATH_TO_TOKEN_FILE = os.path.join(_CACHE_DIR, USER_DATA_FILE_NAME) diff --git a/peppy/pephubclient/exceptions.py b/peppy/pephubclient/exceptions.py index 00c0d48b..07ea2862 100644 --- a/peppy/pephubclient/exceptions.py +++ b/peppy/pephubclient/exceptions.py @@ -28,3 +28,11 @@ class PEPExistsError(BasePephubclientException): def __init__(self, message: str | None = None): self.message = message super().__init__(self.message or self.default_message) + + +class FileDoesNotExistError(BasePephubclientException): + default_message = "File does not exist." + + def __init__(self, message: str | None = None): + self.message = message + super().__init__(self.message or self.default_message) diff --git a/peppy/pephubclient/files_manager.py b/peppy/pephubclient/files_manager.py index 84bcfbb9..39e9e35e 100644 --- a/peppy/pephubclient/files_manager.py +++ b/peppy/pephubclient/files_manager.py @@ -4,25 +4,28 @@ from pathlib import Path import pandas +import toml import yaml +from .constants import CachedToken from .exceptions import PEPExistsError class FilesManager: @staticmethod - def save_jwt_data_to_file(path: str, jwt_data: str) -> None: - """Save jwt to provided path.""" + def save_token_data(path: str, token: CachedToken) -> None: + """Save cached token data (jwt + base url) to provided path as TOML.""" Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True) with open(path, "w") as f: - f.write(jwt_data) + toml.dump(token.model_dump(), f) @staticmethod - def load_jwt_data_from_file(path: str) -> str: - """Open the file with username and ID and load this data.""" + def load_token_data(path: str) -> CachedToken: + """Load cached token data from the TOML file, or return defaults if missing.""" with suppress(FileNotFoundError): with open(path, "r") as f: - return f.read() + return CachedToken(**toml.load(f)) + return CachedToken() @staticmethod def create_project_folder( diff --git a/peppy/pephubclient/helpers.py b/peppy/pephubclient/helpers.py index 492027ce..14185c77 100644 --- a/peppy/pephubclient/helpers.py +++ b/peppy/pephubclient/helpers.py @@ -1,7 +1,8 @@ import json import os from collections.abc import Callable -from typing import Any +from pathlib import Path +from typing import Any, Literal from urllib.parse import urlencode import pandas as pd @@ -22,7 +23,7 @@ ) from ..project import Project from .constants import RegistryPath -from .exceptions import PEPExistsError, ResponseError +from .exceptions import BasePephubclientException, PEPExistsError, ResponseError from .files_manager import FilesManager from .models import ProjectDict @@ -345,3 +346,85 @@ def save_pep( parent_path=project_path, folder_name=file_name ) _save_unzipped_pep(project, folder_path, force=force) + + +def open_schema(file_path: str | Path) -> dict: + """ + Open schema file that is saved in yaml or json format. + + Args: + file_path: path to the schema file + + Raises: + FileNotFoundError: if file doesn't exist + + Returns: + file object in dict format. + """ + if isinstance(file_path, str): + file_path = Path(file_path) + + if not file_path.is_file(): + raise FileNotFoundError( + f"Provided schema file doesn't exist. File path: `{str(file_path)}`" + ) + + if file_path.suffix == ".yaml" or file_path.suffix == ".yml": + with open(file_path, "r") as file: + data = yaml.safe_load(file) + + elif file_path.suffix == ".json": + with open(file_path, "r") as file: + data = json.load(file) + else: + raise BasePephubclientException( + f"Incorrect file format provided: '{file_path.suffix}'. " + "Only yaml and json formats are supported." + ) + + return data + + +def save_schema( + file_path: str | Path, + schema_obj: dict, + format: Literal["json", "yaml"] = "yaml", +) -> None: + """ + Save dict object as file in json or yaml format. + + Args: + file_path: path to the file + schema_obj: content to be saved in the file + format: Format in which file should be saved on disc. Default: yaml + """ + if format == "yaml": + schema_obj = yaml.dump(schema_obj) + + elif format == "json": + schema_obj = json.dumps(schema_obj, indent=4) + else: + raise BasePephubclientException(f"Incorrect format provided: '{format}'") + + with open(file_path, "w") as file: + file.write(schema_obj) + + +def schema_path_converter(schema_path: str) -> tuple[str, str, str]: + """ + Convert schema path to namespace, name, version. + + Args: + schema_path: schema path that has structure: "namespace/name.yaml" + + Returns: + tuple(namespace, name, version). + """ + if "/" in schema_path: + namespace, name_tag = schema_path.split("/") + if ":" in name_tag: + name, version = name_tag.split(":") + return namespace, name, version + + return namespace, name_tag, "latest" + raise BasePephubclientException(f"Error in: '{schema_path}'") diff --git a/peppy/pephubclient/models.py b/peppy/pephubclient/models.py index b98f2d39..23ad48ae 100644 --- a/peppy/pephubclient/models.py +++ b/peppy/pephubclient/models.py @@ -11,8 +11,8 @@ class ProjectDict(BaseModel): """ config: dict = Field(alias=CONFIG_KEY) - subsample_list: list | None = Field(alias=SUBSAMPLE_RAW_LIST_KEY) - sample_list: list = Field(alias=SAMPLE_RAW_DICT_KEY) + subsamples: list | None = Field(alias=SUBSAMPLE_RAW_LIST_KEY) + samples: list = Field(alias=SAMPLE_RAW_DICT_KEY) model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/peppy/pephubclient/pephub_oauth/const.py b/peppy/pephubclient/pephub_oauth/const.py index 0cdfbc8e..0cc70a2e 100644 --- a/peppy/pephubclient/pephub_oauth/const.py +++ b/peppy/pephubclient/pephub_oauth/const.py @@ -1,6 +1,4 @@ # constants of pephub_auth -from ..constants import PEPHUB_BASE_URL - -PEPHUB_DEVICE_INIT_URI = f"{PEPHUB_BASE_URL}auth/device/init" -PEPHUB_DEVICE_TOKEN_URI = f"{PEPHUB_BASE_URL}auth/device/token" +DEVICE_INIT_PATH = "auth/device/init" +DEVICE_TOKEN_PATH = "auth/device/token" diff --git a/peppy/pephubclient/pephub_oauth/pephub_oauth.py b/peppy/pephubclient/pephub_oauth/pephub_oauth.py index 64ca72ba..393b2e89 100644 --- a/peppy/pephubclient/pephub_oauth/pephub_oauth.py +++ b/peppy/pephubclient/pephub_oauth/pephub_oauth.py @@ -4,8 +4,9 @@ import requests from pydantic import BaseModel +from ..constants import DEFAULT_BASE_URL from ..helpers import MessageHandler, RequestManager -from ..pephub_oauth.const import PEPHUB_DEVICE_INIT_URI, PEPHUB_DEVICE_TOKEN_URI +from ..pephub_oauth.const import DEVICE_INIT_PATH, DEVICE_TOKEN_PATH from ..pephub_oauth.exceptions import ( PEPHubResponseException, PEPHubTokenExchangeException, @@ -19,7 +20,9 @@ class PEPHubAuth(RequestManager): """Class responsible for authorization to PEPhub.""" - def login_to_pephub(self): + def login_to_pephub(self, base_url: str | None = None): + self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/") + "/" + pephub_response = self._request_pephub_for_device_code() print( f"User verification code: {pephub_response.device_code}, please go to the website: " @@ -57,7 +60,7 @@ def _request_pephub_for_device_code(self) -> InitializeDeviceCodeResponse: """Requests device code from pephub.""" response = PEPHubAuth.send_request( method="POST", - url=PEPHUB_DEVICE_INIT_URI, + url=f"{self._base_url}{DEVICE_INIT_PATH}", params=None, headers=None, ) @@ -72,7 +75,7 @@ def _exchange_device_code_on_token(self, device_code: str) -> str: """ response = PEPHubAuth.send_request( method="POST", - url=PEPHUB_DEVICE_TOKEN_URI, + url=f"{self._base_url}{DEVICE_TOKEN_PATH}", params=None, headers={"device-code": device_code}, ) diff --git a/peppy/pephubclient/pephubclient.py b/peppy/pephubclient/pephubclient.py index e8e86400..e5799b6a 100644 --- a/peppy/pephubclient/pephubclient.py +++ b/peppy/pephubclient/pephubclient.py @@ -5,13 +5,15 @@ from typing_extensions import deprecated from ubiquerg import parse_registry_path -from ..const import CONFIG_KEY, NAME_KEY +from ..const import ( + CONFIG_KEY, + NAME_KEY, + SAMPLE_RAW_DICT_KEY, + SUBSAMPLE_RAW_LIST_KEY, +) from ..project import Project from .constants import ( - PATH_TO_FILE_WITH_JWT, - PEPHUB_PEP_API_BASE_URL, - PEPHUB_PEP_SEARCH_URL, - PEPHUB_PUSH_URL, + PATH_TO_TOKEN_FILE, RegistryPath, ResponseStatusCodes, ) @@ -27,16 +29,20 @@ from .modules.sample import PEPHubSample from .modules.view import PEPHubView from .pephub_oauth.pephub_oauth import PEPHubAuth +from .schemas.schema import PEPHubSchema urllib3.disable_warnings() class PEPHubClient(RequestManager): def __init__(self): - self.__jwt_data = FilesManager.load_jwt_data_from_file(PATH_TO_FILE_WITH_JWT) + cached = FilesManager.load_token_data(PATH_TO_TOKEN_FILE) + self.__jwt_data = cached.token + self.__base_url = cached.base_url.rstrip("/") + "/" self.__view = PEPHubView(self.__jwt_data) self.__sample = PEPHubSample(self.__jwt_data) + self.__schema = PEPHubSchema(self.__jwt_data) @property def view(self) -> PEPHubView: @@ -46,16 +52,35 @@ def view(self) -> PEPHubView: def sample(self) -> PEPHubSample: return self.__sample - def login(self) -> None: - """Log in to PEPhub.""" - user_token = PEPHubAuth().login_to_pephub() + @property + def schema(self) -> PEPHubSchema: + return self.__schema - 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 login(self, token: str | None = None, url: str | None = None) -> None: + """ + Log in to PEPhub. + + Args: + token: JWT token to register directly. If provided, the browser + device-code flow is skipped. + url: Base URL for PEPhub. If provided, overrides the cached/default URL. + """ + cached = FilesManager.load_token_data(PATH_TO_TOKEN_FILE) + if url: + cached.base_url = url + if token: + MessageHandler.print_warning("Token provided. Registering...") + cached.token = token + else: + cached.token = PEPHubAuth().login_to_pephub(base_url=cached.base_url) + + FilesManager.save_token_data(PATH_TO_TOKEN_FILE, cached) + self.__jwt_data = cached.token + self.__base_url = cached.base_url.rstrip("/") + "/" def logout(self) -> None: """Log out from PEPhub.""" - FilesManager.delete_file_if_exists(PATH_TO_FILE_WITH_JWT) + FilesManager.delete_file_if_exists(PATH_TO_TOKEN_FILE) self.__jwt_data = None def pull( @@ -166,6 +191,9 @@ def upload( if name: pep_dict[CONFIG_KEY][NAME_KEY] = name + pep_dict["config"] = pep_dict.pop(CONFIG_KEY) + pep_dict["samples"] = pep_dict.pop(SAMPLE_RAW_DICT_KEY) + pep_dict["subsamples"] = pep_dict.pop(SUBSAMPLE_RAW_LIST_KEY) upload_data = ProjectUploadData( pep_dict=pep_dict, tag=tag, @@ -212,6 +240,7 @@ def find_project( self, namespace: str, query_string: str = "", + tag: str = None, limit: int = 100, offset: int = 0, filter_by: Literal["submission_date", "last_update_date"] = None, @@ -224,6 +253,7 @@ def find_project( Args: namespace: Namespace where to search for projects query_string: Search query + tag: Project tag limit: Return limit offset: Return offset filter_by: Use filter date. Option: [submission_date, last_update_date] @@ -235,6 +265,7 @@ def find_project( "q": query_string, "limit": limit, "offset": offset, + "tag": tag, } if filter_by in ["submission_date", "last_update_date"]: query_param["filter_by"] = filter_by @@ -352,10 +383,11 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: variables_string = self.parse_query_param(query_param) endpoint += variables_string - return PEPHUB_PEP_API_BASE_URL + endpoint + return f"{self.__base_url}api/v1/projects/" + endpoint - @staticmethod - def _build_project_search_url(namespace: str, query_param: dict = None) -> str: + def _build_project_search_url( + self, namespace: str, query_param: dict = None + ) -> str: """ Build request for searching projects from pephub. @@ -369,10 +401,9 @@ def _build_project_search_url(namespace: str, query_param: dict = None) -> str: variables_string = RequestManager.parse_query_param(query_param) endpoint = variables_string - return PEPHUB_PEP_SEARCH_URL.format(namespace=namespace) + endpoint + return f"{self.__base_url}api/v1/namespaces/{namespace}/projects" + endpoint - @staticmethod - def _build_push_request_url(namespace: str) -> str: + def _build_push_request_url(self, namespace: str) -> str: """ Build project upload request used in pephub. @@ -382,4 +413,4 @@ def _build_push_request_url(namespace: str) -> str: Returns: url string. """ - return PEPHUB_PUSH_URL.format(namespace=namespace) + return f"{self.__base_url}api/v1/namespaces/{namespace}/projects/json" diff --git a/peppy/pephubclient/schemas/__init__.py b/peppy/pephubclient/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/peppy/pephubclient/schemas/constants.py b/peppy/pephubclient/schemas/constants.py new file mode 100644 index 00000000..d5006459 --- /dev/null +++ b/peppy/pephubclient/schemas/constants.py @@ -0,0 +1,17 @@ +from ..constants import PEPHUB_BASE_URL + +PEPHUB_SCHEMA_BASE_URL = f"{PEPHUB_BASE_URL}api/v1/schemas/" + +PEPHUB_SCHEMA_NEW_SCHEMA_URL = f"{PEPHUB_SCHEMA_BASE_URL}{{namespace}}/json" +PEPHUB_SCHEMA_NEW_VERSION_URL = ( + f"{PEPHUB_SCHEMA_BASE_URL}{{namespace}}/{{schema_name}}/versions/json" +) +PEPHUB_SCHEMA_RECORD_URL = f"{PEPHUB_SCHEMA_BASE_URL}{{namespace}}/{{schema_name}}" +PEPHUB_SCHEMA_VERSIONS_URL = ( + f"{PEPHUB_SCHEMA_BASE_URL}{{namespace}}/{{schema_name}}/versions" +) +PEPHUB_SCHEMA_VERSION_URL = ( + f"{PEPHUB_SCHEMA_BASE_URL}{{namespace}}/{{schema_name}}/versions/{{version}}" +) + +LATEST_VERSION = "latest" diff --git a/peppy/pephubclient/schemas/models.py b/peppy/pephubclient/schemas/models.py new file mode 100644 index 00000000..6605797f --- /dev/null +++ b/peppy/pephubclient/schemas/models.py @@ -0,0 +1,75 @@ +import datetime + +from pydantic import BaseModel, ConfigDict + + +class PaginationResult(BaseModel): + page: int = 0 + page_size: int = 10 + total: int + + +class SchemaVersionAnnotation(BaseModel): + """ + Schema version annotation model + """ + + namespace: str + schema_name: str + version: str + contributors: str | None = "" + release_notes: str | None = "" + tags: dict[str, str | None] = {} + release_date: datetime.datetime + last_update_date: datetime.datetime + + +class SchemaVersionResult(BaseModel): + pagination: PaginationResult + results: list[SchemaVersionAnnotation] + + +class NewSchemaVersionModel(BaseModel): + """ + Model for creating a new schema version from json + """ + + contributors: str | None = None + release_notes: str | None = None + tags: list[str] | str | dict[str, str] | list[dict[str, str]] | None = None + version: str + schema_value: dict + + model_config = ConfigDict(extra="forbid") + + +class NewSchemaRecordModel(NewSchemaVersionModel): + """ + Model for creating a new schema record from json + """ + + schema_name: str + description: str | None = None + maintainers: str | None = None + lifecycle_stage: str | None = None + private: bool = False + + model_config = ConfigDict(extra="forbid") + + +class UpdateSchemaRecordFields(BaseModel): + maintainers: str | None = None + lifecycle_stage: str | None = None + private: bool | None = None + name: str | None = None + description: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class UpdateSchemaVersionFields(BaseModel): + contributors: str | None = None + schema_value: str | None = None + release_notes: str | None = None + + model_config = ConfigDict(extra="forbid") diff --git a/peppy/pephubclient/schemas/schema.py b/peppy/pephubclient/schemas/schema.py new file mode 100644 index 00000000..e2fa8618 --- /dev/null +++ b/peppy/pephubclient/schemas/schema.py @@ -0,0 +1,438 @@ +import logging + +from ..constants import ResponseStatusCodes +from ..exceptions import ResponseError +from ..helpers import RequestManager +from .constants import ( + LATEST_VERSION, + PEPHUB_SCHEMA_NEW_SCHEMA_URL, + PEPHUB_SCHEMA_NEW_VERSION_URL, + PEPHUB_SCHEMA_RECORD_URL, + PEPHUB_SCHEMA_VERSION_URL, + PEPHUB_SCHEMA_VERSIONS_URL, +) +from .models import ( + NewSchemaRecordModel, + NewSchemaVersionModel, + SchemaVersionResult, + UpdateSchemaRecordFields, + UpdateSchemaVersionFields, +) + +_LOGGER = logging.getLogger("pephubclient") + + +class PEPHubSchema(RequestManager): + """ + Class for managing schemas in PEPhub. + + Provides methods for getting, creating, updating and removing schema records + and schema versions. + """ + + def __init__(self, jwt_data: str = None): + """ + Initialize PEPHubSchema. + + Args: + jwt_data: jwt token for authorization + """ + self.__jwt_data = jwt_data + + def get( + self, namespace: str, schema_name: str, version: str = LATEST_VERSION + ) -> dict: + """ + Get schema value for specific schema version. + + Args: + namespace: namespace of schema + schema_name: name of schema + version: version of schema + + Returns: + Schema object as dictionary. + """ + pephub_response = self.send_request( + method="GET", + url=PEPHUB_SCHEMA_VERSION_URL.format( + namespace=namespace, schema_name=schema_name, version=version + ), + headers=self.parse_header(self.__jwt_data), + cookies=None, + ) + if pephub_response.status_code == ResponseStatusCodes.OK: + decoded_response = self.decode_response(pephub_response, output_json=True) + return decoded_response + + if pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("Schema doesn't exist, or you are unauthorized.") + if pephub_response.status_code == ResponseStatusCodes.INTERNAL_ERROR: + raise ResponseError( + f"Internal server error. Unexpected return value. Error: {pephub_response.status_code}" + ) + else: + raise ResponseError( + f"Unexpected Status code return. Error: {pephub_response.status_code}" + ) + + def get_versions(self, namespace: str, schema_name: str) -> SchemaVersionResult: + """ + Get list of versions. + + Args: + namespace: Namespace of the schema record + schema_name: Name of the schema record + + Returns: + SchemaVersionResult with pagination and list of versions. + """ + pephub_response = self.send_request( + method="GET", + url=PEPHUB_SCHEMA_VERSIONS_URL.format( + namespace=namespace, schema_name=schema_name + ), + headers=self.parse_header(self.__jwt_data), + cookies=None, + ) + + if pephub_response.status_code == ResponseStatusCodes.OK: + decoded_response = self.decode_response(pephub_response, output_json=True) + return SchemaVersionResult(**decoded_response) + + if pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("Schema doesn't exist, or you are unauthorized.") + if pephub_response.status_code == ResponseStatusCodes.INTERNAL_ERROR: + raise ResponseError( + f"Internal server error. Unexpected return value. Error: {pephub_response.status_code}" + ) + else: + raise ResponseError( + f"Unexpected Status code return. Error: {pephub_response.status_code}" + ) + + def create_schema( + self, + namespace: str, + schema_name: str, + schema_value: dict, + version: str = "1.0.0", + description: str = None, + maintainers: str = None, + contributors: str = None, + release_notes: str = None, + tags: str | list[str] | dict | None = None, + lifecycle_stage: str = None, + private: bool = False, + ) -> None: + """ + Create a new schema record + version in the database. + + Args: + namespace: Namespace of the schema + schema_name: Name of the schema record + schema_value: Schema value itself in dict format + version: First version of the schema + description: Schema description + maintainers: Schema maintainers + contributors: Schema contributors of current version + release_notes: Release notes for current version + tags: Tags of the current version. Can be str, list[str], or dict + lifecycle_stage: Stage of the schema record + private: Whether project should be public or private. Default: False (public) + + Raises: + ResponseError: if status not 202. + """ + url = PEPHUB_SCHEMA_NEW_SCHEMA_URL.format(namespace=namespace) + request_body = NewSchemaRecordModel( + schema_name=schema_name, + description=description, + maintainers=maintainers, + lifecycle_stage=lifecycle_stage, + private=private, + contributors=contributors, + release_notes=release_notes, + tags=tags, + version=version, + schema_value=schema_value, + ).model_dump(exclude_none=True) + + pephub_response = self.send_request( + method="POST", + url=url, + headers=self.parse_header(self.__jwt_data), + cookies=None, + json=request_body, + ) + + if pephub_response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Schema '{namespace}/{schema_name}:{version}' successfully created in PEPhub" + ) + return None + + elif pephub_response.status_code == ResponseStatusCodes.UNAUTHORIZED: + raise ResponseError( + "User not authorized or doesn't have permission to write to this namespace" + ) + + else: + raise ResponseError( + f"Unexpected error. Status code: {pephub_response.status_code}" + ) + + def add_version( + self, + namespace: str, + schema_name: str, + schema_value: dict, + version: str = "1.0.0", + contributors: str = None, + release_notes: str = None, + tags: str | list[str] | dict | None = None, + ) -> None: + """ + Add new version to the schema registry. + + Args: + namespace: Namespace of the schema + schema_name: Name of the schema record + schema_value: Schema value itself in dict format + version: First version of the schema + contributors: Schema contributors of current version + release_notes: Release notes for current version + tags: Tags of the current version. Can be str, list[str], or dict + + Raises: + ResponseError: if status not 202. + """ + url = PEPHUB_SCHEMA_NEW_VERSION_URL.format( + namespace=namespace, schema_name=schema_name + ) + request_body = NewSchemaVersionModel( + contributors=contributors, + release_notes=release_notes, + tags=tags, + version=version, + schema_value=schema_value, + ).model_dump(exclude_none=True, exclude_unset=True) + + pephub_response = self.send_request( + method="POST", + url=url, + headers=self.parse_header(self.__jwt_data), + cookies=None, + json=request_body, + ) + + if pephub_response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Schema version '{namespace}/{schema_name}:{version}' successfully created in PEPhub" + ) + return None + + elif pephub_response.status_code == ResponseStatusCodes.UNAUTHORIZED: + raise ResponseError( + "User not authorized or doesn't have permission to write to this namespace" + ) + + else: + raise ResponseError( + f"Unexpected error. Status code: {pephub_response.status_code}" + ) + + def update_record( + self, + namespace: str, + schema_name: str, + update_fields: dict | UpdateSchemaRecordFields, + ) -> None: + """ + Update schema registry data. + + Args: + namespace: Namespace of the schema + schema_name: Name of the schema version + update_fields: dict or pydantic model UpdateSchemaRecordFields with + fields: maintainers, lifecycle_stage, private, name, description. + + Raises: + ResponseError: if status not 202. + """ + if isinstance(update_fields, dict): + update_fields = UpdateSchemaRecordFields(**update_fields) + + update_fields = update_fields.model_dump(exclude_none=True, exclude_unset=True) + + url = PEPHUB_SCHEMA_RECORD_URL.format( + namespace=namespace, schema_name=schema_name + ) + + pephub_response = self.send_request( + method="PATCH", + url=url, + headers=self.parse_header(self.__jwt_data), + cookies=None, + json=update_fields, + ) + + if pephub_response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Schema record '{namespace}/{schema_name}' was updated successfully!" + ) + return None + + elif pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("Schema doesn't exist in PEPhub") + + elif pephub_response.status_code == ResponseStatusCodes.UNAUTHORIZED: + raise ResponseError( + "User not authorized or doesn't have permission to write to this namespace" + ) + + else: + raise ResponseError( + f"Unexpected error. Status code: {pephub_response.status_code}" + ) + + def update_version( + self, + namespace: str, + schema_name: str, + version: str, + update_fields: dict | UpdateSchemaVersionFields, + ) -> None: + """ + Update released version of the schema. + + Args: + namespace: Namespace of the schema + schema_name: Name of the schema version + version: Schema version + update_fields: dict or pydantic model UpdateSchemaVersionFields with + fields: contributors, schema_value, release_notes. + + Raises: + ResponseError: if status not 202. + """ + url = PEPHUB_SCHEMA_VERSION_URL.format( + namespace=namespace, schema_name=schema_name, version=version + ) + + if isinstance(update_fields, dict): + update_fields = UpdateSchemaVersionFields(**update_fields) + + update_fields = update_fields.model_dump(exclude_unset=True, exclude_none=True) + + pephub_response = self.send_request( + method="PATCH", + url=url, + headers=self.parse_header(self.__jwt_data), + cookies=None, + json=update_fields, + ) + + if pephub_response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Schema version '{namespace}/{schema_name}:{version}' was updated successfully!" + ) + return None + + elif pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("Schema doesn't exist in PEPhub") + + elif pephub_response.status_code == ResponseStatusCodes.UNAUTHORIZED: + raise ResponseError( + "User not authorized or doesn't have permission to write to this namespace" + ) + + else: + raise ResponseError( + f"Unexpected error. Status code: {pephub_response.status_code}" + ) + + def delete_schema(self, namespace: str, schema_name: str) -> None: + """ + Delete schema from the database. + + Args: + namespace: Namespace of the schema + schema_name: Name of the schema version + """ + url = PEPHUB_SCHEMA_RECORD_URL.format( + namespace=namespace, schema_name=schema_name + ) + + pephub_response = self.send_request( + method="DELETE", + url=url, + headers=self.parse_header(self.__jwt_data), + cookies=None, + ) + + if pephub_response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Schema record '{namespace}/{schema_name}' was updated successfully!" + ) + return None + + elif pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("Schema doesn't exist in PEPhub") + + elif pephub_response.status_code == ResponseStatusCodes.UNAUTHORIZED: + raise ResponseError( + "User not authorized or doesn't have permission to write to this namespace" + ) + + else: + raise ResponseError( + f"Unexpected error. Status code: {pephub_response.status_code}" + ) + + def delete_version( + self, + namespace: str, + schema_name: str, + version: str, + ) -> None: + """ + Delete schema version. + + Args: + namespace: Namespace of the schema + schema_name: Name of the schema + version: Schema version + + Raises: + ResponseError: if status not 202. + """ + url = PEPHUB_SCHEMA_VERSION_URL.format( + namespace=namespace, schema_name=schema_name, version=version + ) + + pephub_response = self.send_request( + method="DELETE", + url=url, + headers=self.parse_header(self.__jwt_data), + cookies=None, + ) + + if pephub_response.status_code == ResponseStatusCodes.ACCEPTED: + _LOGGER.info( + f"Schema version '{namespace}/{schema_name}:{version}' was updated successfully!" + ) + return None + + elif pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("Schema doesn't exist in PEPhub") + + elif pephub_response.status_code == ResponseStatusCodes.UNAUTHORIZED: + raise ResponseError( + "User not authorized or doesn't have permission to write to this namespace" + ) + + else: + raise ResponseError( + f"Unexpected error. Status code: {pephub_response.status_code}" + ) diff --git a/peppy/pephubclient/schemas/schema_cli.py b/peppy/pephubclient/schemas/schema_cli.py new file mode 100644 index 00000000..98b102ec --- /dev/null +++ b/peppy/pephubclient/schemas/schema_cli.py @@ -0,0 +1,135 @@ +import os + +import typer + +from ..helpers import ( + call_client_func, + open_schema, + save_schema, + schema_path_converter, +) +from ..pephubclient import PEPHubClient + +schemas_app = typer.Typer( + pretty_exceptions_short=False, + pretty_exceptions_show_locals=False, + help="PEPhub CLI for schemas", +) + +_client_schema = PEPHubClient().schema + + +@schemas_app.command( + help="Download schema from PEPhub", +) +def get( + schema_registry_path: str, + output: str = typer.Option(None, help="Output directory."), + format: str = typer.Option("json", help="Format in which file should be saved"), +): + namespace, schema_name, version = schema_path_converter(schema_registry_path) + + schema_value = call_client_func( + _client_schema.get, + namespace=namespace, + schema_name=schema_name, + version=version, + ) + if output is None: + output = os.getcwd() + + new_name = os.path.join(output, f"{namespace}_{schema_name}_{version}.{format}") + save_schema(new_name, schema_obj=schema_value, format=format) + + +@schemas_app.command(help="Create new schema in PEPhub") +def create( + schema: str = typer.Option( + ..., + help="Path to schema file stored in json, or yaml format", + readable=True, + ), + namespace: str = typer.Option(..., help="Schema namespace"), + schema_name: str = typer.Option(..., help="Schema name"), + version: str = typer.Option("1.0.0", help="Schema version"), + description: str = typer.Option("", help="Schema description"), + maintainers: str = typer.Option("", help="Schema maintainers"), + contributors: str = typer.Option("", help="Schema contributors"), + tags: list[str] = typer.Option(list(), help="Tags of the version"), + release_notes: str = typer.Option("", help="Version release notes"), + private: bool = typer.Option(False, help="Make schema private"), + lifecycle_stage: str = typer.Option("", help="Lifecycle stage"), +): + schema_value = open_schema(schema) + + call_client_func( + _client_schema.create_schema, + schema_name=schema_name, + version=version, + description=description, + maintainers=maintainers, + contributors=contributors, + tags=tags, + release_notes=release_notes, + schema_value=schema_value, + namespace=namespace, + lifecycle_stage=lifecycle_stage, + private=private, + ) + + +@schemas_app.command(help="Add new version of schema to PEPhub") +def add_version( + schema: str = typer.Option( + ..., + help="Path to schema file stored in json, or yaml format", + readable=True, + ), + namespace: str = typer.Option(..., help="Schema namespace"), + schema_name: str = typer.Option(..., help="Schema name"), + version: str = typer.Option("1.0.0", help="Schema version"), + contributors: str = typer.Option("", help="Schema contributors"), + tags: list[str] = typer.Option(list(), help="Tags of the version"), + release_notes: str = typer.Option("", help="Version release notes"), +): + schema_value = open_schema(schema) + call_client_func( + _client_schema.add_version, + namespace=namespace, + schema_name=schema_name, + schema_value=schema_value, + version=version, + contributors=contributors, + release_notes=release_notes, + tags=tags, + ) + + +@schemas_app.command( + help="Delete schema version", +) +def delete_version( + namespace: str = typer.Option(..., help="Schema namespace"), + schema_name: str = typer.Option(..., help="Schema name"), + version: str = typer.Option(..., help="Schema version"), +): + call_client_func( + _client_schema.delete_version, + namespace=namespace, + schema_name=schema_name, + version=version, + ) + + +@schemas_app.command( + help="Remove schema record from PEPhub", +) +def remove( + namespace: str = typer.Option(..., help="Schema namespace"), + schema_name: str = typer.Option(..., help="Schema name"), +): + call_client_func( + _client_schema.delete_schema, + namespace=namespace, + schema_name=schema_name, + ) diff --git a/pyproject.toml b/pyproject.toml index e4fa7ce7..7ef5b68c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "requests>=2.28.2", "pydantic>2.5.0", "coloredlogs>=15.0.1", + "toml>=0.10.2", ] [project.urls] diff --git a/tests/phctests/conftest.py b/tests/phctests/conftest.py index 39ba8caf..ebf1f028 100644 --- a/tests/phctests/conftest.py +++ b/tests/phctests/conftest.py @@ -22,8 +22,8 @@ def test_raw_pep_return(): "description": "desc", "name": "sample name", }, - "subsample_list": [], - "sample_list": [ + "subsamples": [], + "samples": [ {"time": "0", "file_path": "source1", "sample_name": "pig_0h"}, {"time": "1", "file_path": "source1", "sample_name": "pig_1h"}, {"time": "0", "file_path": "source1", "sample_name": "frog_0h"}, diff --git a/tests/phctests/test_pephubclient.py b/tests/phctests/test_pephubclient.py index 21c7c4e8..3b74f4aa 100644 --- a/tests/phctests/test_pephubclient.py +++ b/tests/phctests/test_pephubclient.py @@ -2,6 +2,7 @@ import pytest +from peppy.pephubclient.constants import CachedToken from peppy.pephubclient.exceptions import ResponseError from peppy.pephubclient.helpers import is_registry_path from peppy.pephubclient.pephub_oauth.models import InitializeDeviceCodeResponse @@ -37,7 +38,7 @@ def test_login(self, mocker, device_code_return, test_jwt): ) pathlib_mock = mocker.patch( - "peppy.pephubclient.files_manager.FilesManager.save_jwt_data_to_file" + "peppy.pephubclient.files_manager.FilesManager.save_token_data" ) PEPHubClient().login() @@ -47,6 +48,34 @@ def test_login(self, mocker, device_code_return, test_jwt): assert pephub_exchange_code_mock.called assert pathlib_mock.called + def test_login_with_token_skips_device_flow(self, mocker, test_jwt): + """Providing a token registers it directly without the device flow.""" + login_mock = mocker.patch( + "peppy.pephubclient.pephub_oauth.pephub_oauth.PEPHubAuth.login_to_pephub" + ) + save_mock = mocker.patch( + "peppy.pephubclient.files_manager.FilesManager.save_token_data" + ) + + PEPHubClient().login(token=test_jwt, url="https://example.org/") + + assert not login_mock.called + saved = save_mock.call_args.args[1] + assert saved.token == test_jwt + assert saved.base_url == "https://example.org/" + + def test_login_url_passed_to_device_flow(self, mocker, test_jwt): + """Without a token, the url is forwarded to the device-code flow.""" + login_mock = mocker.patch( + "peppy.pephubclient.pephub_oauth.pephub_oauth.PEPHubAuth.login_to_pephub", + return_value=test_jwt, + ) + mocker.patch("peppy.pephubclient.files_manager.FilesManager.save_token_data") + + PEPHubClient().login(url="https://example.org/") + + login_mock.assert_called_once_with(base_url="https://example.org/") + def test_logout(self, mocker): os_remove_mock = mocker.patch("os.remove") PEPHubClient().logout() @@ -55,8 +84,8 @@ def test_logout(self, mocker): def test_pull(self, mocker, test_jwt, test_raw_pep_return): jwt_mock = mocker.patch( - "peppy.pephubclient.files_manager.FilesManager.load_jwt_data_from_file", - return_value=test_jwt, + "peppy.pephubclient.files_manager.FilesManager.load_token_data", + return_value=CachedToken(token=test_jwt), ) requests_mock = mocker.patch( "requests.request", @@ -100,8 +129,8 @@ def test_pull_with_pephub_error_response( self, mocker, test_jwt, status_code, expected_error_message ): mocker.patch( - "peppy.pephubclient.files_manager.FilesManager.load_jwt_data_from_file", - return_value=test_jwt, + "peppy.pephubclient.files_manager.FilesManager.load_token_data", + return_value=CachedToken(token=test_jwt), ) mocker.patch( "requests.request",