diff --git a/.github/workflows/black_linter.yml b/.github/workflows/black_linter.yml index 6d34fe3..42757d1 100644 --- a/.github/workflows/black_linter.yml +++ b/.github/workflows/black_linter.yml @@ -8,6 +8,6 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 - uses: psf/black@stable \ No newline at end of file diff --git a/.github/workflows/pytest-windows.yml b/.github/workflows/pytest-windows.yml index 34a557e..c8d9e07 100644 --- a/.github/workflows/pytest-windows.yml +++ b/.github/workflows/pytest-windows.yml @@ -15,10 +15,10 @@ jobs: os: [windows-latest] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 334600e..85c554a 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.12"] + python-version: ["3.9", "3.13"] os: [ubuntu-20.04] steps: diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index b120129..59c6af8 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -11,9 +11,9 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies diff --git a/MANIFEST.in b/MANIFEST.in index a948aa7..ecb04bb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ include requirements/* include README.md include pephubclient/pephub_oauth/* -include pephubclient/modules/* \ No newline at end of file +include pephubclient/modules/* +include pephubclient/schemas/* \ No newline at end of file diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..7f01c75 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,7 @@ +version: '3' + +tasks: + hello: + cmds: + - black . + silent: false \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index 60b4ff2..ee5d075 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,8 +1,12 @@ # Changelog - + 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.5.0] - 2025-03-31 +### Added +- Added schemas CLI and Python methods for fetching schemas from PEPhub + ## [0.4.5] - 2024-11-21 ### Added - Function for unwrapping PEPhub registry path diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index a099d01..5728e09 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -4,7 +4,7 @@ import coloredlogs __app_name__ = "pephubclient" -__version__ = "0.4.5" +__version__ = "0.5.0" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 7be8cfa..b9bb514 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -3,6 +3,7 @@ from pephubclient import __app_name__, __version__ from pephubclient.helpers import call_client_func from pephubclient.pephubclient import PEPHubClient +from pephubclient.schemas.schema_cli import schemas_app _client = PEPHubClient() @@ -88,3 +89,6 @@ def common( ), ): pass + + +app.add_typer(schemas_app, name="schema") diff --git a/pephubclient/exceptions.py b/pephubclient/exceptions.py index bb8787f..3b1fdc5 100644 --- a/pephubclient/exceptions.py +++ b/pephubclient/exceptions.py @@ -31,3 +31,11 @@ class PEPExistsError(BasePephubclientException): def __init__(self, message: Optional[str] = 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: Optional[str] = None): + self.message = message + super().__init__(self.message or self.default_message) diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index dcd9b51..83bb195 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,7 +1,8 @@ import json -from typing import Any, Callable, Optional, Union +from typing import Any, Callable, Optional, Union, Literal, Tuple import peppy import yaml +from pathlib import Path import os import pandas as pd from peppy.const import ( @@ -21,7 +22,11 @@ from ubiquerg import parse_registry_path from pydantic import ValidationError -from pephubclient.exceptions import PEPExistsError, ResponseError +from pephubclient.exceptions import ( + PEPExistsError, + ResponseError, + BasePephubclientException, +) from pephubclient.constants import RegistryPath from pephubclient.files_manager import FilesManager from pephubclient.models import ProjectDict @@ -322,3 +327,80 @@ def save_pep( parent_path=project_path, folder_name=file_name ) _save_unzipped_pep(project, folder_path, force=force) + + +def open_schema(file_path: Union[str, Path]) -> dict: + """ + Open schema file that are saved in yaml or json format. + + :param file_path: path to the schema file + + :raises: FileNotFoundError - if file doesn't exist + :return: 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: Union[str, Path], + schema_obj: dict, + format: Literal["json", "yaml"] = "yaml", +) -> None: + """ + Save dict object as file in json or yaml format + + :param file_path: path to the file + :param schema_obj: content to be saved in the file + :param format: Format in which file should be saved on disc. Default: yaml + + :return: File path. + """ + + 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 + + :param schema_path: schema path that has structure: "namespace/name.yaml" + :return: 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/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index b09760a..4010971 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -30,6 +30,7 @@ from pephubclient.pephub_oauth.pephub_oauth import PEPHubAuth from pephubclient.modules.view import PEPHubView from pephubclient.modules.sample import PEPHubSample +from pephubclient.schemas.schema import PEPHubSchema urllib3.disable_warnings() @@ -40,6 +41,7 @@ def __init__(self): self.__view = PEPHubView(self.__jwt_data) self.__sample = PEPHubSample(self.__jwt_data) + self.__schema = PEPHubSchema(self.__jwt_data) @property def view(self) -> PEPHubView: @@ -49,6 +51,10 @@ def view(self) -> PEPHubView: def sample(self) -> PEPHubSample: return self.__sample + @property + def schema(self) -> PEPHubSchema: + return self.__schema + def login(self) -> NoReturn: """ Log in to PEPhub diff --git a/pephubclient/schemas/__init__.py b/pephubclient/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pephubclient/schemas/constants.py b/pephubclient/schemas/constants.py new file mode 100644 index 0000000..e2a8df5 --- /dev/null +++ b/pephubclient/schemas/constants.py @@ -0,0 +1,17 @@ +from pephubclient.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/pephubclient/schemas/models.py b/pephubclient/schemas/models.py new file mode 100644 index 0000000..fe876b5 --- /dev/null +++ b/pephubclient/schemas/models.py @@ -0,0 +1,77 @@ +from pydantic import BaseModel, ConfigDict +from typing import Optional, Dict, Union, List +import datetime + + +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: Optional[str] = "" + release_notes: Optional[str] = "" + tags: Dict[str, Union[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: Union[str, None] = None + release_notes: Union[str, None] = None + tags: Optional[Union[List[str], str, Dict[str, str], List[Dict[str, str]]]] = ( + 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: Union[str, None] = None + maintainers: Union[str, None] = None + lifecycle_stage: Union[str, None] = None + private: bool = False + + model_config = ConfigDict(extra="forbid") + + +class UpdateSchemaRecordFields(BaseModel): + maintainers: Optional[Union[str, None]] = None + lifecycle_stage: Optional[Union[str, None]] = None + private: Optional[bool] = None + name: Optional[Union[str, None]] = None + description: Optional[Union[str, None]] = None + + model_config = ConfigDict(extra="forbid") + + +class UpdateSchemaVersionFields(BaseModel): + contributors: Optional[Union[str, None]] = None + schema_value: Optional[Union[str, None]] = None + release_notes: Optional[Union[str, None]] = None + + model_config = ConfigDict(extra="forbid") diff --git a/pephubclient/schemas/schema.py b/pephubclient/schemas/schema.py new file mode 100644 index 0000000..b3f17a4 --- /dev/null +++ b/pephubclient/schemas/schema.py @@ -0,0 +1,445 @@ +import logging +from typing import Union, List + +from pephubclient.helpers import RequestManager +from pephubclient.constants import ResponseStatusCodes +from pephubclient.schemas.constants import ( + PEPHUB_SCHEMA_VERSION_URL, + PEPHUB_SCHEMA_VERSIONS_URL, + PEPHUB_SCHEMA_NEW_SCHEMA_URL, + PEPHUB_SCHEMA_NEW_VERSION_URL, + PEPHUB_SCHEMA_RECORD_URL, + LATEST_VERSION, +) +from pephubclient.exceptions import ResponseError +from pephubclient.schemas.models import ( + SchemaVersionResult, + NewSchemaVersionModel, + NewSchemaRecordModel, + UpdateSchemaRecordFields, + UpdateSchemaVersionFields, +) + +_LOGGER = logging.getLogger("pephubclient") + + +class PEPHubSchema(RequestManager): + """ + Class for managing schemas in PEPhub and provides methods for + getting, creating, updating and removing schemas records and schema versions. + """ + + def __init__(self, jwt_data: str = None): + """ + :param 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. + + :param: namespace: namespace of schema + :param: schema_name: name of schema + :param: version: version of schema + + :return: 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 + + :param namespace: Namespace of the schema record + :param schema_name: Name of the schema record + + :return: { + pagination: PaginationResult + results: List[SchemaVersionAnnotation] + } + """ + + 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: Union[str, List[str], dict, None] = None, + lifecycle_stage: str = None, + private: bool = False, + ) -> None: + """ + Create a new schema record + version in the database + + :param namespace: Namespace of the schema + :param schema_name: Name of the schema record + :param schema_value: Schema value itself in dict format + :param version: First version of the schema + :param description: Schema description + :param maintainers: Schema maintainers + :param contributors: Schema contributors of current version + :param release_notes: Release notes for current version + :param tags: Tags of the current version. Can be str, list[str], or dict + :param lifecycle_stage: Stage of the schema record + :param private: Weather project should be public or private. Default: False (public) + + :raise: ResponseError if status not 202. + :return: None + """ + + 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: Union[str, List[str], dict, None] = None, + ) -> None: + """ + Add new version to the schema registry + + :param namespace: Namespace of the schema + :param schema_name: Name of the schema record + :param schema_value: Schema value itself in dict format + :param version: First version of the schema + :param contributors: Schema contributors of current version + :param release_notes: Release notes for current version + :param tags: Tags of the current version. Can be str, list[str], or dict + + :raise: ResponseError if status not 202. + :return: None + """ + 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: Union[dict, UpdateSchemaRecordFields], + ) -> None: + """ + Update schema registry data + + :param namespace: Namespace of the schema + :param schema_name: Name of the schema version + :param update_fields: dict or pydantic model UpdateSchemaRecordFields: + { + maintainers: str, + lifecycle_stage: str, + private: bool, + name: str, + description: str, + } + + :raise: ResponseError if status not 202. + :return: None + """ + + 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: Union[dict, UpdateSchemaVersionFields], + ) -> None: + """ + Update released version of the schema. + + :param namespace: Namespace of the schema + :param schema_name: Name of the schema version + :param version: Schema version + :param update_fields: dict or pydantic model UpdateSchemaVersionFields: + { + contributors: str, + schema_value: str, + release_notes: str, + } + + :raise: ResponseError if status not 202. + :return: None + """ + + 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 + + :param namespace: Namespace of the schema + :param 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 + + :param namespace: Namespace of the schema + :param schema_name: Name of the schema + :param version: Schema version + + :raise: ResponseError if status not 202. + :return: None + """ + + 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/pephubclient/schemas/schema_cli.py b/pephubclient/schemas/schema_cli.py new file mode 100644 index 0000000..73831f3 --- /dev/null +++ b/pephubclient/schemas/schema_cli.py @@ -0,0 +1,132 @@ +import typer +import os +from typing import List +from pephubclient.helpers import call_client_func +from pephubclient.helpers import open_schema, schema_path_converter, save_schema +from pephubclient.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/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..9ae7103 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,210 @@ +from pephubclient import PEPHubClient +from unittest.mock import Mock + +example_schema = { + "a": "b", + "c": 1, +} +versions_example = { + "pagination": {"page": 0, "page_size": 100, "total": 1}, + "results": [ + { + "namespace": "databio", + "schema_name": "test_test", + "version": "bbb", + "contributors": None, + "release_notes": None, + "tags": {}, + "release_date": "2025-04-02T18:27:22.829003Z", + "last_update_date": "2025-04-02T18:27:22.829009Z", + } + ], +} + + +# @pytest.mark.skip("Tests are not implemented yet") +class TestSchemas: + + def test_get_schema(self, mocker, test_jwt): + jwt_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content="some return", status_code=200), + ) + mocker.patch( + "pephubclient.helpers.RequestManager.decode_response", + return_value=example_schema, + ) + + phc = PEPHubClient() + schema_value = phc.schema.get(namespace="databio", schema_name="pep") + + assert jwt_mock.called + assert requests_mock.called + assert schema_value == example_schema + + def test_get_schema_versions(self, mocker, test_jwt): + jwt_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content="some return", status_code=200), + ) + mocker.patch( + "pephubclient.helpers.RequestManager.decode_response", + return_value=versions_example, + ) + + phc = PEPHubClient() + + schema_versions = phc.schema.get_versions( + namespace="databio", schema_name="test_test" + ) + + assert jwt_mock.called + assert requests_mock.called + assert schema_versions + + def test_create_schema(self, mocker, test_jwt): + jwt_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content="some return", status_code=202), + ) + + phc = PEPHubClient() + + phc.schema.create_schema( + namespace="tessttt", + schema_name="test_test1", + version="bbb", + schema_value={"b2": "v"}, + contributors="Na", + ) + + assert jwt_mock.called + assert requests_mock.called + + def test_update_schema(self, mocker, test_jwt): + jwt_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content="some return", status_code=202), + ) + + phc = PEPHubClient() + + phc.schema.update_record( + namespace="test_test", + schema_name="test_test", + update_fields={ + "maintainers": "new_maintainer", + "private": True, + "lifecycle_stage": "development", + }, + ) + + assert jwt_mock.called + assert requests_mock.called + + def test_delete_schema(self, mocker, test_jwt): + + jwt_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content="some return", status_code=202), + ) + + phc = PEPHubClient() + + phc.schema.delete_schema( + namespace="test_test", + schema_name="testttt", + ) + + assert jwt_mock.called + assert requests_mock.called + + def test_add_version(self, mocker, test_jwt): + jwt_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content="some return", status_code=202), + ) + + phc = PEPHubClient() + + phc.schema.add_version( + namespace="test_test", + schema_name="test2", + version="1.2.5", + schema_value={"b": "v2"}, + contributors="Na", + ) + + assert jwt_mock.called + assert requests_mock.called + + def test_update_version(self, mocker, test_jwt): + jwt_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content="some return", status_code=202), + ) + + phc = PEPHubClient() + + phc.schema.update_version( + namespace="test_test", + schema_name="test2", + version="1.2.4", + update_fields={ + "contributors": "new cont", + "release_notes": "note new", + }, + ) + + assert jwt_mock.called + assert requests_mock.called + + def test_delete_version(self, mocker, test_jwt): + + jwt_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content="some return", status_code=202), + ) + + phc = PEPHubClient() + + phc.schema.delete_version( + namespace="test2", + schema_name="test_test", + version="1.2.5", + ) + + assert jwt_mock.called + assert requests_mock.called