From 0e2e7966f02e303747da7eda539b7e52ce2471da Mon Sep 17 00:00:00 2001 From: Khoroshevskyi <41573628+Khoroshevskyi@users.noreply.github.com> Date: Fri, 8 Jul 2022 13:16:40 -0400 Subject: [PATCH 001/165] Initial commit --- .gitignore | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 130 insertions(+) create mode 100644 .gitignore create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b6e47617 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..3b61e274 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# pephubClient \ No newline at end of file From d1cee0ffe7b7022a0c9de147b892ffa7464dc856 Mon Sep 17 00:00:00 2001 From: Rafal Stepien Date: Mon, 19 Sep 2022 13:01:46 -0400 Subject: [PATCH 002/165] Create a simple client --- .gitignore | 2 ++ pephubclient/constants.py | 13 ++++++++++++ pephubclient/exceptions.py | 5 +++++ pephubclient/pephubclient.py | 38 ++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 pephubclient/constants.py create mode 100644 pephubclient/exceptions.py create mode 100644 pephubclient/pephubclient.py diff --git a/.gitignore b/.gitignore index b6e47617..60252a3f 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,5 @@ dmypy.json # Pyre type checker .pyre/ +.idea/ +run.py diff --git a/pephubclient/constants.py b/pephubclient/constants.py new file mode 100644 index 00000000..5e3c156e --- /dev/null +++ b/pephubclient/constants.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from typing import Optional + + +PEPHUB_URL = "https://pephub.databio.org/pep/" + + +class RegistryPath(BaseModel): + protocol: Optional[str] + namespace: str + item: str + subitem: Optional[str] + tag: Optional[str] diff --git a/pephubclient/exceptions.py b/pephubclient/exceptions.py new file mode 100644 index 00000000..3a0eb3ca --- /dev/null +++ b/pephubclient/exceptions.py @@ -0,0 +1,5 @@ +class IncorrectQueryStringError(Exception): + + def __init__(self, query_string: str = None): + self.query_string = query_string + super().__init__(f"PEP data with passed namespace and project ({self.query_string}) name not found.") diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py new file mode 100644 index 00000000..7c709f55 --- /dev/null +++ b/pephubclient/pephubclient.py @@ -0,0 +1,38 @@ +import json +import peppy +import requests +from pephubclient.constants import PEPHUB_URL, RegistryPath +from peppy import Project +from pephubclient.exceptions import IncorrectQueryStringError +from ubiquerg import parse_registry_path +from pydantic.error_wrappers import ValidationError + + +class PEPHubClient: + """ + Main class responsible for providing Python interface for PEP Hub. + """ + + def load_pep(self, query_string: str) -> Project: + request_data = self.get_request_data_from_string(query_string) + pephub_response = self.request_pephub(request_data) + return self.parse_pephub_response(pephub_response) + + @staticmethod + def get_request_data_from_string(query_string: str) -> RegistryPath: + try: + return RegistryPath(**parse_registry_path(query_string)) + except ValidationError: + raise IncorrectQueryStringError(query_string=query_string) + + @staticmethod + def request_pephub(registry_path_data: RegistryPath): + endpoint = registry_path_data.namespace + "/" + registry_path_data.item + "/" + filter_string = "convert?filter=csv" + full_url = PEPHUB_URL + endpoint #+ filter_string + return requests.get(full_url, verify=False) + + @staticmethod + def parse_pephub_response(pephub_response: requests.Response) -> peppy.Project: + response_as_dictionary = json.loads(pephub_response.content.decode("utf-8")) + return Project() From 27c596527977daf83fb5d504b57e2de836507737 Mon Sep 17 00:00:00 2001 From: Rafal Stepien <43926522+rafalstepien@users.noreply.github.com> Date: Mon, 19 Sep 2022 14:04:49 -0400 Subject: [PATCH 003/165] Allow passing variables for PEP projects (#4) --- pephubclient/pephubclient.py | 58 +++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 7c709f55..186f28b4 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -6,33 +6,75 @@ from pephubclient.exceptions import IncorrectQueryStringError from ubiquerg import parse_registry_path from pydantic.error_wrappers import ValidationError +from typing import Optional class PEPHubClient: """ - Main class responsible for providing Python interface for PEP Hub. + Main class responsible for providing Python interface for PEPhub. """ - def load_pep(self, query_string: str) -> Project: + def load_pep(self, query_string: str, variables: Optional[dict] = None) -> Project: request_data = self.get_request_data_from_string(query_string) - pephub_response = self.request_pephub(request_data) + pephub_response = self.request_pephub(request_data, variables) return self.parse_pephub_response(pephub_response) @staticmethod def get_request_data_from_string(query_string: str) -> RegistryPath: + """ + Parse provided query string to extract project name, sample name, etc. + + Args: + query_string: Passed by user. Contain information needed to locate the project. + + Returns: + Parsed query string. + """ try: return RegistryPath(**parse_registry_path(query_string)) - except ValidationError: + except (ValidationError, TypeError): raise IncorrectQueryStringError(query_string=query_string) @staticmethod - def request_pephub(registry_path_data: RegistryPath): - endpoint = registry_path_data.namespace + "/" + registry_path_data.item + "/" - filter_string = "convert?filter=csv" - full_url = PEPHUB_URL + endpoint #+ filter_string + def request_pephub(registry_path_data: RegistryPath, variables: Optional[dict] = None) -> requests.Response: + """ + Send request to PEPhub to obtain the project. + + Args: + registry_path_data: Information about project, namespace, version, etc. + variables: Optional array of variables that will be passed to parametrize PEP project from PEPhub. + """ + endpoint = registry_path_data.namespace + "/" + registry_path_data.item + variables_string = PEPHubClient._parse_variables(variables) + full_url = PEPHUB_URL + endpoint + variables_string return requests.get(full_url, verify=False) @staticmethod def parse_pephub_response(pephub_response: requests.Response) -> peppy.Project: + """ + Parse the response from PEPhub and provide returned data as peppy.Project object. + + Args: + pephub_response: Raw response object from PEPhub. + + Returns: + + """ response_as_dictionary = json.loads(pephub_response.content.decode("utf-8")) return Project() + + @staticmethod + def _parse_variables(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. + + Returns: + 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) From b5068e80671d54ebfb3ff6cc26eef038e65b5981 Mon Sep 17 00:00:00 2001 From: Rafal Stepien <43926522+rafalstepien@users.noreply.github.com> Date: Mon, 19 Sep 2022 14:05:55 -0400 Subject: [PATCH 004/165] Add tests (#5) --- tests/conftest.py | 6 ++++++ tests/test_pephubclient.py | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_pephubclient.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..fc3a2599 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def requests_get_mock(mocker): + return mocker.patch("requests.get") diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py new file mode 100644 index 00000000..953f4455 --- /dev/null +++ b/tests/test_pephubclient.py @@ -0,0 +1,41 @@ +from pephubclient.pephubclient import PEPHubClient +import pytest +from pephubclient.constants import RegistryPath +from pephubclient.exceptions import IncorrectQueryStringError + + +@pytest.mark.parametrize( + "query_string", + [ + "", + ] +) +def test_get_request_data_from_string_raises_error_for_incorrect_query_string(query_string): + with pytest.raises(IncorrectQueryStringError) as e: + PEPHubClient().get_request_data_from_string(query_string) + + assert e.value.query_string == query_string + + +@pytest.mark.parametrize( + "query_string, expected_output", + [ + ("geo/GSE124224", RegistryPath(namespace="geo", item="GSE124224")), + ] +) +def test_get_request_data_from_string_parses_data_correctly(query_string, expected_output): + assert PEPHubClient().get_request_data_from_string(query_string) == expected_output + + +@pytest.mark.parametrize( + "registry_path, variables, expected_url", + [ + (RegistryPath(namespace="geo", item="123"), {"DATA": "test"}, "https://pephub.databio.org/pep/geo/123?DATA=test"), + (RegistryPath(namespace="geo", item="123"), {"DATA": "test", "VARIABLE": "value"}, "https://pephub.databio.org/pep/geo/123?DATA=test&VARIABLE=value"), + (RegistryPath(namespace="geo", item="123"), {}, "https://pephub.databio.org/pep/geo/123?DATA=test&VARIABLE=value"), + ] +) +def test_request_pephub_creates_correct_url(registry_path, variables, expected_url, requests_get_mock): + PEPHubClient().request_pephub(registry_path, variables) + + assert requests_get_mock.called_with(expected_url) From 23eece4d178f31591f7ce5d87e8c8ccbf8715454 Mon Sep 17 00:00:00 2001 From: Rafal Stepien <43926522+rafalstepien@users.noreply.github.com> Date: Thu, 29 Sep 2022 15:01:18 -0400 Subject: [PATCH 005/165] Add complete load_pep functionality (#6) --- pephubclient/pephubclient.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 186f28b4..a3106faf 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,4 +1,4 @@ -import json +import os import peppy import requests from pephubclient.constants import PEPHUB_URL, RegistryPath @@ -10,6 +10,9 @@ class PEPHubClient: + CONVERT_ENDPOINT = "convert?filter=csv" + TMP_FILE_NAME = "pep_project.csv" + """ Main class responsible for providing Python interface for PEPhub. """ @@ -44,9 +47,11 @@ def request_pephub(registry_path_data: RegistryPath, variables: Optional[dict] = registry_path_data: Information about project, namespace, version, etc. variables: Optional array of variables that will be passed to parametrize PEP project from PEPhub. """ - endpoint = registry_path_data.namespace + "/" + registry_path_data.item - variables_string = PEPHubClient._parse_variables(variables) - full_url = PEPHUB_URL + endpoint + variables_string + endpoint = registry_path_data.namespace + "/" + registry_path_data.item + "/" + PEPHubClient.CONVERT_ENDPOINT + full_url = PEPHUB_URL + endpoint + if variables: + variables_string = PEPHubClient._parse_variables(variables) + full_url += variables_string return requests.get(full_url, verify=False) @staticmethod @@ -58,10 +63,12 @@ def parse_pephub_response(pephub_response: requests.Response) -> peppy.Project: pephub_response: Raw response object from PEPhub. Returns: - + Peppy project instance. """ - response_as_dictionary = json.loads(pephub_response.content.decode("utf-8")) - return Project() + PEPHubClient._save_response(pephub_response) + project = Project(PEPHubClient.TMP_FILE_NAME) + PEPHubClient._delete_file(PEPHubClient.TMP_FILE_NAME) + return project @staticmethod def _parse_variables(pep_variables: dict) -> str: @@ -78,3 +85,12 @@ def _parse_variables(pep_variables: dict) -> str: parsed_variables.append(f"{variable_name}={variable_value}") return "?" + "&".join(parsed_variables) + + @staticmethod + def _save_response(pephub_response: requests.Response) -> None: + with open(PEPHubClient.TMP_FILE_NAME, "w") as f: + f.write(pephub_response.content.decode("utf-8")) + + @staticmethod + def _delete_file(filename: str) -> None: + os.remove(filename) From af79b2b2b9fb68e64ce4a0d917f54617b1c7966f Mon Sep 17 00:00:00 2001 From: Rafal Stepien <43926522+rafalstepien@users.noreply.github.com> Date: Mon, 3 Oct 2022 12:03:26 -0400 Subject: [PATCH 006/165] Add tests to existing methods (#7) * lint * Add tests, refactor --- .flake8 | 3 ++ Makefile | 13 ++++++ pephubclient/__init__.py | 3 ++ pephubclient/constants.py | 4 +- pephubclient/exceptions.py | 5 ++- pephubclient/pephubclient.py | 70 +++++++++++++++++------------ tests/__init__.py | 0 tests/conftest.py | 4 ++ tests/test_pephubclient.py | 85 ++++++++++++++++++++++++++---------- 9 files changed, 132 insertions(+), 55 deletions(-) create mode 100644 .flake8 create mode 100644 Makefile create mode 100644 pephubclient/__init__.py create mode 100644 tests/__init__.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..7152fe29 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +exclude = .git,__pycache__,docs/source/conf.py,old,build,dist +max-line-length = 120 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..2dac6a08 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +lint: + black . && isort . && flake8 + +run-coverage: + coverage run -m pytest + +html-report: + coverage html + +open-coverage: + cd htmlcov && google-chrome index.html + +coverage: run-coverage html-report open-coverage diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py new file mode 100644 index 00000000..462eb748 --- /dev/null +++ b/pephubclient/__init__.py @@ -0,0 +1,3 @@ +from .pephubclient import PEPHubClient + +__all__ = ["PEPHubClient"] diff --git a/pephubclient/constants.py b/pephubclient/constants.py index 5e3c156e..42f8ca94 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -1,8 +1,8 @@ -from pydantic import BaseModel from typing import Optional +from pydantic import BaseModel -PEPHUB_URL = "https://pephub.databio.org/pep/" +PEPHUB_BASE_URL = "https://pephub.databio.org/pep/" class RegistryPath(BaseModel): diff --git a/pephubclient/exceptions.py b/pephubclient/exceptions.py index 3a0eb3ca..caa529b7 100644 --- a/pephubclient/exceptions.py +++ b/pephubclient/exceptions.py @@ -1,5 +1,6 @@ class IncorrectQueryStringError(Exception): - def __init__(self, query_string: str = None): self.query_string = query_string - super().__init__(f"PEP data with passed namespace and project ({self.query_string}) name not found.") + super().__init__( + f"PEP data with passed namespace and project ({self.query_string}) name not found." + ) diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index a3106faf..770a2115 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,29 +1,33 @@ import os +from typing import Optional, Union + import peppy import requests -from pephubclient.constants import PEPHUB_URL, RegistryPath from peppy import Project -from pephubclient.exceptions import IncorrectQueryStringError -from ubiquerg import parse_registry_path from pydantic.error_wrappers import ValidationError -from typing import Optional +from ubiquerg import parse_registry_path + +from pephubclient.constants import PEPHUB_BASE_URL, RegistryPath +from pephubclient.exceptions import IncorrectQueryStringError class PEPHubClient: - CONVERT_ENDPOINT = "convert?filter=csv" - TMP_FILE_NAME = "pep_project.csv" - """ Main class responsible for providing Python interface for PEPhub. """ + CONVERT_ENDPOINT = "convert?filter=csv" + TMP_FILE_NAME = "pep_project.csv" + + def __init__(self): + self.registry_path_data: Union[RegistryPath, None] = None + def load_pep(self, query_string: str, variables: Optional[dict] = None) -> Project: - request_data = self.get_request_data_from_string(query_string) - pephub_response = self.request_pephub(request_data, variables) + self.set_registry_data(query_string) + pephub_response = self.request_pephub(variables) return self.parse_pephub_response(pephub_response) - @staticmethod - def get_request_data_from_string(query_string: str) -> RegistryPath: + def set_registry_data(self, query_string: str) -> None: """ Parse provided query string to extract project name, sample name, etc. @@ -34,30 +38,29 @@ def get_request_data_from_string(query_string: str) -> RegistryPath: Parsed query string. """ try: - return RegistryPath(**parse_registry_path(query_string)) + self.registry_path_data = RegistryPath(**parse_registry_path(query_string)) except (ValidationError, TypeError): raise IncorrectQueryStringError(query_string=query_string) - @staticmethod - def request_pephub(registry_path_data: RegistryPath, variables: Optional[dict] = None) -> requests.Response: + def request_pephub(self, variables: Optional[dict] = None) -> requests.Response: """ - Send request to PEPhub to obtain the project. + Send request to PEPhub to obtain the project data. Args: - registry_path_data: Information about project, namespace, version, etc. variables: Optional array of variables that will be passed to parametrize PEP project from PEPhub. """ - endpoint = registry_path_data.namespace + "/" + registry_path_data.item + "/" + PEPHubClient.CONVERT_ENDPOINT - full_url = PEPHUB_URL + endpoint + url = self._build_request_url() + if variables: variables_string = PEPHubClient._parse_variables(variables) - full_url += variables_string - return requests.get(full_url, verify=False) + url += variables_string + return requests.get(url, verify=False) - @staticmethod - def parse_pephub_response(pephub_response: requests.Response) -> peppy.Project: + def parse_pephub_response( + self, pephub_response: requests.Response + ) -> peppy.Project: """ - Parse the response from PEPhub and provide returned data as peppy.Project object. + Save the csv data as file, read this data and return as peppy.Project object. Args: pephub_response: Raw response object from PEPhub. @@ -65,11 +68,21 @@ def parse_pephub_response(pephub_response: requests.Response) -> peppy.Project: Returns: Peppy project instance. """ - PEPHubClient._save_response(pephub_response) - project = Project(PEPHubClient.TMP_FILE_NAME) - PEPHubClient._delete_file(PEPHubClient.TMP_FILE_NAME) + self._save_response(pephub_response) + project = Project(self.TMP_FILE_NAME) + self._delete_file(self.TMP_FILE_NAME) return project + def _build_request_url(self): + endpoint = ( + self.registry_path_data.namespace + + "/" + + self.registry_path_data.item + + "/" + + PEPHubClient.CONVERT_ENDPOINT + ) + return PEPHUB_BASE_URL + endpoint + @staticmethod def _parse_variables(pep_variables: dict) -> str: """ @@ -86,9 +99,8 @@ def _parse_variables(pep_variables: dict) -> str: return "?" + "&".join(parsed_variables) - @staticmethod - def _save_response(pephub_response: requests.Response) -> None: - with open(PEPHubClient.TMP_FILE_NAME, "w") as f: + def _save_response(self, pephub_response: requests.Response) -> None: + with open(self.TMP_FILE_NAME, "w") as f: f.write(pephub_response.content.decode("utf-8")) @staticmethod diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py index fc3a2599..6d64ea0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,3 +4,7 @@ @pytest.fixture def requests_get_mock(mocker): return mocker.patch("requests.get") + + +"https://pephub.databio.org/pep/test_geo_project/test_name/123?DATA=test" +"https://pephub.databio.org/pep/test_geo_project/test_name/convert?filter=csv?DATA=test" diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 953f4455..f3d05266 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -1,41 +1,82 @@ -from pephubclient.pephubclient import PEPHubClient import pytest +from unittest.mock import Mock, patch, mock_open +from pephubclient import PEPHubClient from pephubclient.constants import RegistryPath from pephubclient.exceptions import IncorrectQueryStringError -@pytest.mark.parametrize( - "query_string", - [ - "", - ] -) -def test_get_request_data_from_string_raises_error_for_incorrect_query_string(query_string): +@pytest.mark.parametrize("query_string", [""]) +def test_get_request_data_from_string_raises_error_for_incorrect_query_string( + query_string, +): with pytest.raises(IncorrectQueryStringError) as e: - PEPHubClient().get_request_data_from_string(query_string) + PEPHubClient().set_registry_data(query_string) assert e.value.query_string == query_string @pytest.mark.parametrize( "query_string, expected_output", - [ - ("geo/GSE124224", RegistryPath(namespace="geo", item="GSE124224")), - ] + [("geo/GSE124224", RegistryPath(namespace="geo", item="GSE124224"))], ) -def test_get_request_data_from_string_parses_data_correctly(query_string, expected_output): - assert PEPHubClient().get_request_data_from_string(query_string) == expected_output +def test_get_request_data_from_string_parses_data_correctly( + query_string, expected_output +): + pep_hub_client = PEPHubClient() + pep_hub_client.set_registry_data(query_string) + assert pep_hub_client.registry_path_data == expected_output @pytest.mark.parametrize( - "registry_path, variables, expected_url", + "variables, expected_url", [ - (RegistryPath(namespace="geo", item="123"), {"DATA": "test"}, "https://pephub.databio.org/pep/geo/123?DATA=test"), - (RegistryPath(namespace="geo", item="123"), {"DATA": "test", "VARIABLE": "value"}, "https://pephub.databio.org/pep/geo/123?DATA=test&VARIABLE=value"), - (RegistryPath(namespace="geo", item="123"), {}, "https://pephub.databio.org/pep/geo/123?DATA=test&VARIABLE=value"), - ] + ( + {"DATA": "test"}, + "https://pephub.databio.org/pep/test_geo_project/test_name/convert?filter=csv?DATA=test", + ), + ( + {"DATA": "test", "VARIABLE": "value"}, + "https://pephub.databio.org/pep/test_geo_project/test_name/convert?filter=csv?DATA=test&VARIABLE=value", + ), + ( + {}, + "https://pephub.databio.org/pep/test_geo_project/test_name/convert?filter=csv", + ), + ], ) -def test_request_pephub_creates_correct_url(registry_path, variables, expected_url, requests_get_mock): - PEPHubClient().request_pephub(registry_path, variables) +def test_request_pephub_creates_correct_url(variables, expected_url, requests_get_mock): + pep_hub_client = PEPHubClient() + pep_hub_client.registry_path_data = RegistryPath( + namespace="test_geo_project", item="test_name" + ) + pep_hub_client.request_pephub(variables) + + requests_get_mock.assert_called_with(expected_url, verify=False) + + +def test_load_pep(mocker, requests_get_mock): + save_response_mock = mocker.patch( + "pephubclient.pephubclient.PEPHubClient._save_response" + ) + delete_file_mock = mocker.patch( + "pephubclient.pephubclient.PEPHubClient._delete_file" + ) + + pep_hub_client = PEPHubClient() + pep_hub_client.TMP_FILE_NAME = None + pep_hub_client.load_pep("test/querystring") + + assert save_response_mock.called + assert delete_file_mock.called_with(None) + + +def test_delete_file(mocker): + os_remove_mock = mocker.patch("os.remove") + PEPHubClient()._delete_file("test-filename.csv") + assert os_remove_mock.called + - assert requests_get_mock.called_with(expected_url) +def test_save_response(): + with patch("builtins.open", mock_open()) as open_mock: + PEPHubClient()._save_response(Mock()) + assert open_mock.called From 55d8bd6bbd15019d2928f3d13f585f340e74eb94 Mon Sep 17 00:00:00 2001 From: Rafal Stepien <43926522+rafalstepien@users.noreply.github.com> Date: Mon, 3 Oct 2022 16:01:34 -0400 Subject: [PATCH 007/165] Add pull command (#8) --- pephubclient/__init__.py | 7 +++- pephubclient/__main__.py | 9 +++++ pephubclient/cli.py | 22 +++++++++++ pephubclient/constants.py | 2 +- pephubclient/pephubclient.py | 74 +++++++++++++++++++++++++++++++----- tests/test_pephubclient.py | 34 +++++++++++++++-- 6 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 pephubclient/__main__.py create mode 100644 pephubclient/cli.py diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 462eb748..93f3c2c6 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,3 +1,8 @@ from .pephubclient import PEPHubClient -__all__ = ["PEPHubClient"] + +__app_name__ = "pephubclient" +__version__ = "0.1.0" + + +__all__ = ["PEPHubClient", "__app_name__", "__version__"] diff --git a/pephubclient/__main__.py b/pephubclient/__main__.py new file mode 100644 index 00000000..3f8a29f5 --- /dev/null +++ b/pephubclient/__main__.py @@ -0,0 +1,9 @@ +from pephubclient.cli import app, __app_name__ + + +def main(): + app(prog_name=__app_name__) + + +if __name__ == "__main__": + main() diff --git a/pephubclient/cli.py b/pephubclient/cli.py new file mode 100644 index 00000000..bcb0f003 --- /dev/null +++ b/pephubclient/cli.py @@ -0,0 +1,22 @@ +import typer + +from pephubclient import __app_name__, __version__ +from pephubclient.pephubclient import PEPHubClient + +pep_hub_client = PEPHubClient() +app = typer.Typer() + + +@app.command() +def pull(project_query_string: str): + pep_hub_client.save_pep_locally(project_query_string) + + +@app.command() +def login(): + print("Logging in...") + + +@app.command() +def version(): + print(f"{__app_name__} v{__version__}") diff --git a/pephubclient/constants.py b/pephubclient/constants.py index 42f8ca94..450717d0 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -1,8 +1,8 @@ from typing import Optional - from pydantic import BaseModel PEPHUB_BASE_URL = "https://pephub.databio.org/pep/" +DEFAULT_FILENAME = "pep_project.csv" class RegistryPath(BaseModel): diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 770a2115..1c01eb0e 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,15 +1,17 @@ import os from typing import Optional, Union - +import urllib3 import peppy import requests from peppy import Project from pydantic.error_wrappers import ValidationError from ubiquerg import parse_registry_path -from pephubclient.constants import PEPHUB_BASE_URL, RegistryPath +from pephubclient.constants import PEPHUB_BASE_URL, RegistryPath, DEFAULT_FILENAME from pephubclient.exceptions import IncorrectQueryStringError +urllib3.disable_warnings() + class PEPHubClient: """ @@ -17,16 +19,43 @@ class PEPHubClient: """ CONVERT_ENDPOINT = "convert?filter=csv" - TMP_FILE_NAME = "pep_project.csv" - def __init__(self): + def __init__(self, filename_to_save: str = DEFAULT_FILENAME): self.registry_path_data: Union[RegistryPath, None] = None + self.filename_to_save = filename_to_save def load_pep(self, query_string: str, variables: Optional[dict] = None) -> Project: + """ + Request PEPhub and return the requested project as peppy.Project object. + + Args: + query_string: Project namespace, eg. "geo/GSE124224" + variables: Optional variables to be passed to PEPhub + + Returns: + Downloaded project as object. + """ self.set_registry_data(query_string) pephub_response = self.request_pephub(variables) return self.parse_pephub_response(pephub_response) + def save_pep_locally( + self, query_string: str, variables: Optional[dict] = None + ) -> None: + """ + Request PEPhub and save the requested project on the disk. + + Args: + query_string: Project namespace, eg. "geo/GSE124224" + variables: Optional variables to be passed to PEPhub + + """ + self.set_registry_data(query_string) + pephub_response = self.request_pephub(variables) + filename = self._create_filename_to_save_downloaded_project() + self._save_response(pephub_response, filename) + print(f"File downloaded -> {os.path.join(os.getcwd(), filename)}") + def set_registry_data(self, query_string: str) -> None: """ Parse provided query string to extract project name, sample name, etc. @@ -68,9 +97,9 @@ def parse_pephub_response( Returns: Peppy project instance. """ - self._save_response(pephub_response) - project = Project(self.TMP_FILE_NAME) - self._delete_file(self.TMP_FILE_NAME) + self._save_response(pephub_response, self.filename_to_save) + project = Project(self.filename_to_save) + self._delete_file(self.filename_to_save) return project def _build_request_url(self): @@ -99,10 +128,37 @@ def _parse_variables(pep_variables: dict) -> str: return "?" + "&".join(parsed_variables) - def _save_response(self, pephub_response: requests.Response) -> None: - with open(self.TMP_FILE_NAME, "w") as f: + @staticmethod + def _save_response( + pephub_response: requests.Response, filename: str = DEFAULT_FILENAME + ) -> None: + with open(filename, "w") as f: f.write(pephub_response.content.decode("utf-8")) @staticmethod def _delete_file(filename: str) -> None: os.remove(filename) + + def _create_filename_to_save_downloaded_project(self) -> str: + """ + Takes query string and creates output filename to save the project to. + + Args: + query_string: Query string that was used to find the project. + + Returns: + Filename uniquely identifying the project. + """ + filename = [] + + if self.registry_path_data.namespace: + filename.append(self.registry_path_data.namespace) + if self.registry_path_data.item: + filename.append(self.registry_path_data.item) + + filename = "_".join(filename) + + if self.registry_path_data.tag: + filename = filename + ":" + self.registry_path_data.tag + + return filename + ".csv" diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index f3d05266..03e724a4 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -62,9 +62,7 @@ def test_load_pep(mocker, requests_get_mock): "pephubclient.pephubclient.PEPHubClient._delete_file" ) - pep_hub_client = PEPHubClient() - pep_hub_client.TMP_FILE_NAME = None - pep_hub_client.load_pep("test/querystring") + PEPHubClient(filename_to_save=None).load_pep("test/querystring") assert save_response_mock.called assert delete_file_mock.called_with(None) @@ -80,3 +78,33 @@ def test_save_response(): with patch("builtins.open", mock_open()) as open_mock: PEPHubClient()._save_response(Mock()) assert open_mock.called + + +def test_save_pep_locally(mocker, requests_get_mock): + save_response_mock = mocker.patch( + "pephubclient.pephubclient.PEPHubClient._save_response" + ) + PEPHubClient().save_pep_locally("test/project") + + assert save_response_mock.called + assert requests_get_mock.called + + +@pytest.mark.parametrize( + "registry_path, expected_filename", + [ + ( + RegistryPath(namespace="test", item="project", tag="2022"), + "test_project:2022.csv", + ), + (RegistryPath(namespace="test", item="project", tag=""), "test_project.csv"), + ], +) +def test_create_filename_to_save_downloaded_project(registry_path, expected_filename): + pep_hub_client = PEPHubClient() + pep_hub_client.registry_path_data = registry_path + + assert ( + pep_hub_client._create_filename_to_save_downloaded_project() + == expected_filename + ) From 3655f164680fb8994a28779d2ffa744f28c409bf Mon Sep 17 00:00:00 2001 From: Rafal Stepien <43926522+rafalstepien@users.noreply.github.com> Date: Mon, 7 Nov 2022 14:22:17 -0500 Subject: [PATCH 008/165] Implement command line authentication (#9) * Add working version of OAuth2 with github * Add tests * Add readme and constants * Update guthub client and tests --- .gitignore | 1 + README.md | 25 ++- error_handling/constants.py | 7 + error_handling/error_handler.py | 16 ++ error_handling/exceptions.py | 23 +++ error_handling/models.py | 6 + github_oauth_client/__init__.py | 0 github_oauth_client/constants.py | 8 + github_oauth_client/github_oauth_client.py | 106 ++++++++++ github_oauth_client/models.py | 16 ++ helpers.py | 43 +++++ pephubclient/__init__.py | 5 +- pephubclient/__main__.py | 2 +- pephubclient/cli.py | 22 ++- pephubclient/constants.py | 9 +- pephubclient/exceptions.py | 6 - pephubclient/files_manager.py | 63 ++++++ pephubclient/models.py | 9 + pephubclient/pephubclient.py | 215 +++++++++++---------- rafalstepien_public_project.csv | 3 + static/pephubclient_login.png | Bin 0 -> 86674 bytes static/pephubclient_pull.png | Bin 0 -> 88093 bytes tests/conftest.py | 31 ++- tests/test_github_oauth_client.py | 20 ++ tests/test_pephubclient.py | 140 ++++++-------- 25 files changed, 569 insertions(+), 207 deletions(-) create mode 100644 error_handling/constants.py create mode 100644 error_handling/error_handler.py create mode 100644 error_handling/exceptions.py create mode 100644 error_handling/models.py create mode 100644 github_oauth_client/__init__.py create mode 100644 github_oauth_client/constants.py create mode 100644 github_oauth_client/github_oauth_client.py create mode 100644 github_oauth_client/models.py create mode 100644 helpers.py delete mode 100644 pephubclient/exceptions.py create mode 100644 pephubclient/files_manager.py create mode 100644 pephubclient/models.py create mode 100644 rafalstepien_public_project.csv create mode 100644 static/pephubclient_login.png create mode 100644 static/pephubclient_pull.png create mode 100644 tests/test_github_oauth_client.py diff --git a/.gitignore b/.gitignore index 60252a3f..99aa7af9 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,4 @@ dmypy.json .pyre/ .idea/ run.py +environment/local.env diff --git a/README.md b/README.md index 3b61e274..b14b567f 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# pephubClient \ No newline at end of file +# `PEPHubClient` + +`PEPHubClient` is a tool to provide Python and CLI interface for `pephub`. +Authorization is based on `OAuth` with using GitHub. + +The authorization process is slightly complex and needs more explanation. +The explanation will be provided based on two commands: + + +## 1. `pephubclient login` +To authenticate itself user must execute `pephubclient login` command (1). +Command triggers the process of authenticating with GitHub. +`PEPHubClient` sends the request for user and device verification codes (2), and +GitHub responds with the data (3). Next, if user is not logged in, GitHub +asks for login (4), user logs in (5) and then GitHub asks to input +verification code (6) that is shown to user in the CLI. +After inputting the correct verification code (7), `PEPHubClient` +sends the request to GitHub and asks about access token (8), which is then +provided by GitHub based on data from authentication (9). +![](static/pephubclient_login.png) + + +## 2. `pephubclient pull project/name:tag` +![](static/pephubclient_pull.png) diff --git a/error_handling/constants.py b/error_handling/constants.py new file mode 100644 index 00000000..3528d0f2 --- /dev/null +++ b/error_handling/constants.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ResponseStatusCodes(int, Enum): + FORBIDDEN_403 = 403 + NOT_EXIST_404 = 404 + OK_200 = 200 diff --git a/error_handling/error_handler.py b/error_handling/error_handler.py new file mode 100644 index 00000000..cb815b97 --- /dev/null +++ b/error_handling/error_handler.py @@ -0,0 +1,16 @@ +from error_handling.exceptions import AuthorizationPendingError +from typing import Union +import pydantic +from error_handling.models import GithubErrorModel +from contextlib import suppress + + +class ErrorHandler: + @staticmethod + def parse_github_response_error(github_response) -> Union[Exception, None]: + with suppress(pydantic.ValidationError): + GithubErrorModel(**github_response) + return AuthorizationPendingError( + message="You must first authorize with GitHub by using " + "provided code." + ) diff --git a/error_handling/exceptions.py b/error_handling/exceptions.py new file mode 100644 index 00000000..c507df0c --- /dev/null +++ b/error_handling/exceptions.py @@ -0,0 +1,23 @@ +class BasePephubclientException(Exception): + def __init__(self, message: str): + super().__init__(message) + + +class IncorrectQueryStringError(BasePephubclientException): + def __init__(self, query_string: str = None): + self.query_string = query_string + super().__init__( + f"PEP data with passed namespace and project ({self.query_string}) name not found." + ) + + +class ResponseError(BasePephubclientException): + default_message = "The response looks incorrect and must be verified manually." + + def __init__(self, message: str = None): + self.message = message + super().__init__(self.message or self.default_message) + + +class AuthorizationPendingError(BasePephubclientException): + ... diff --git a/error_handling/models.py b/error_handling/models.py new file mode 100644 index 00000000..044f8c6f --- /dev/null +++ b/error_handling/models.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class GithubErrorModel(BaseModel): + error: str + error_description: str diff --git a/github_oauth_client/__init__.py b/github_oauth_client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/github_oauth_client/constants.py b/github_oauth_client/constants.py new file mode 100644 index 00000000..7a631a95 --- /dev/null +++ b/github_oauth_client/constants.py @@ -0,0 +1,8 @@ +GITHUB_BASE_API_URL = "https://api.github.com" +GITHUB_BASE_LOGIN_URL = "https://github.com/login" +GITHUB_VERIFICATION_CODES_ENDPOINT = "/device/code" +GITHUB_OAUTH_ENDPOINT = "/oauth/access_token" + +HEADERS = {"Content-Type": "application/json", "Accept": "application/json"} +ENCODING = "utf-8" +GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" diff --git a/github_oauth_client/github_oauth_client.py b/github_oauth_client/github_oauth_client.py new file mode 100644 index 00000000..53ff9475 --- /dev/null +++ b/github_oauth_client/github_oauth_client.py @@ -0,0 +1,106 @@ +import json +from typing import Type +from error_handling.error_handler import ErrorHandler +import requests +from github_oauth_client.constants import ( + GITHUB_BASE_LOGIN_URL, + GITHUB_OAUTH_ENDPOINT, + GITHUB_VERIFICATION_CODES_ENDPOINT, + GRANT_TYPE, + HEADERS, +) +from github_oauth_client.models import ( + AccessTokenResponseModel, + VerificationCodesResponseModel, +) +from pephubclient.models import ClientData +from pydantic import BaseModel, ValidationError +from error_handling.exceptions import ResponseError +from helpers import RequestManager + + +class GitHubOAuthClient(RequestManager): + """ + Class responsible for authorization with GitHub. + """ + + def get_access_token(self, client_data: ClientData): + """ + Requests user with specified ClientData.client_id to enter the verification code, and then + responds with GitHub access token. + """ + device_code = self._get_device_verification_code(client_data) + return self._request_github_for_access_token(device_code, client_data) + + def _get_device_verification_code(self, client_data: ClientData) -> str: + """ + Send the request for verification codes, parse the response and return device code. + + Returns: + Device code which is needed later to obtain the access code. + """ + resp = GitHubOAuthClient.send_request( + method="POST", + url=f"{GITHUB_BASE_LOGIN_URL}{GITHUB_VERIFICATION_CODES_ENDPOINT}", + params={"client_id": client_data.client_id}, + headers=HEADERS, + ) + verification_codes_response = self._handle_github_response( + resp, VerificationCodesResponseModel + ) + print( + f"User verification code: {verification_codes_response.user_code}, " + f"please enter the code here: {verification_codes_response.verification_uri} and" + f"hit enter when you are done with authentication on the website" + ) + input() + + return verification_codes_response.device_code + + def _request_github_for_access_token( + self, device_code: str, client_data: ClientData + ) -> str: + """ + Send the request for access token, parse the response and return access token. + + Args: + device_code: Device code from verification codes request. + + Returns: + Access token. + """ + response = GitHubOAuthClient.send_request( + method="POST", + url=f"{GITHUB_BASE_LOGIN_URL}{GITHUB_OAUTH_ENDPOINT}", + params={ + "client_id": client_data.client_id, + "device_code": device_code, + "grant_type": GRANT_TYPE, + }, + headers=HEADERS, + ) + return self._handle_github_response( + response, AccessTokenResponseModel + ).access_token + + @staticmethod + def _handle_github_response(response: requests.Response, model: Type[BaseModel]): + """ + Decode the response from GitHub and pack the returned data into appropriate model. + + Args: + response: Response from GitHub. + model: Model that the data will be packed to. + + Returns: + Response data as an instance of correct model. + """ + try: + content = json.loads(GitHubOAuthClient.decode_response(response)) + except json.JSONDecodeError: + raise ResponseError("Something went wrong with GitHub response") + + try: + return model(**content) + except ValidationError: + raise ErrorHandler.parse_github_response_error(content) or ResponseError() diff --git a/github_oauth_client/models.py b/github_oauth_client/models.py new file mode 100644 index 00000000..e0849088 --- /dev/null +++ b/github_oauth_client/models.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import BaseModel + + +class VerificationCodesResponseModel(BaseModel): + device_code: str + user_code: str + verification_uri: str + expires_in: Optional[int] + interval: Optional[int] + + +class AccessTokenResponseModel(BaseModel): + access_token: str + scope: Optional[str] + token_type: Optional[str] diff --git a/helpers.py b/helpers.py new file mode 100644 index 00000000..1468c33f --- /dev/null +++ b/helpers.py @@ -0,0 +1,43 @@ +import json +from typing import Optional + +import requests + +from error_handling.exceptions import ResponseError +from github_oauth_client.constants import ENCODING + + +class RequestManager: + @staticmethod + def send_request( + method: str, + url: str, + headers: Optional[dict] = None, + cookies: Optional[dict] = None, + params: Optional[dict] = None, + ) -> requests.Response: + return requests.request( + method=method, + url=url, + verify=False, + cookies=cookies, + headers=headers, + params=params, + ) + + @staticmethod + def decode_response(response: requests.Response) -> str: + """ + Decode the response from GitHub and pack the returned data into appropriate model. + + Args: + response: Response from GitHub. + model: Model that the data will be packed to. + + Returns: + Response data as an instance of correct model. + """ + try: + return response.content.decode(ENCODING) + except json.JSONDecodeError: + raise ResponseError() diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 93f3c2c6..1367e951 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,8 +1,5 @@ -from .pephubclient import PEPHubClient - - __app_name__ = "pephubclient" __version__ = "0.1.0" -__all__ = ["PEPHubClient", "__app_name__", "__version__"] +__all__ = ["__app_name__", "__version__"] diff --git a/pephubclient/__main__.py b/pephubclient/__main__.py index 3f8a29f5..60ec991e 100644 --- a/pephubclient/__main__.py +++ b/pephubclient/__main__.py @@ -1,4 +1,4 @@ -from pephubclient.cli import app, __app_name__ +from pephubclient.cli import __app_name__, app def main(): diff --git a/pephubclient/cli.py b/pephubclient/cli.py index bcb0f003..96155378 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -1,20 +1,32 @@ import typer - +from github_oauth_client.github_oauth_client import GitHubOAuthClient from pephubclient import __app_name__, __version__ from pephubclient.pephubclient import PEPHubClient +from pephubclient.models import ClientData + + +GITHUB_CLIENT_ID = "20a452cc59b908235e50" + pep_hub_client = PEPHubClient() +github_client = GitHubOAuthClient() app = typer.Typer() +client_data = ClientData(client_id=GITHUB_CLIENT_ID) @app.command() -def pull(project_query_string: str): - pep_hub_client.save_pep_locally(project_query_string) +def login(): + pep_hub_client.login(client_data) @app.command() -def login(): - print("Logging in...") +def logout(): + pep_hub_client.logout() + + +@app.command() +def pull(project_query_string: str): + pep_hub_client.pull(project_query_string) @app.command() diff --git a/pephubclient/constants.py b/pephubclient/constants.py index 450717d0..269106b2 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -1,8 +1,13 @@ from typing import Optional + from pydantic import BaseModel -PEPHUB_BASE_URL = "https://pephub.databio.org/pep/" -DEFAULT_FILENAME = "pep_project.csv" +# PEPHUB_BASE_URL = "https://pephub.databio.org/" +# PEPHUB_PEP_API_BASE_URL = "https://pephub.databio.org/pep/" +# PEPHUB_LOGIN_URL = "https://pephub.databio.org/auth/login" +PEPHUB_BASE_URL = "http://0.0.0.0:8000/" +PEPHUB_PEP_API_BASE_URL = "http://0.0.0.0:8000/pep/" +PEPHUB_LOGIN_URL = "http://127.0.0.1:8000/auth/login" class RegistryPath(BaseModel): diff --git a/pephubclient/exceptions.py b/pephubclient/exceptions.py deleted file mode 100644 index caa529b7..00000000 --- a/pephubclient/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class IncorrectQueryStringError(Exception): - def __init__(self, query_string: str = None): - self.query_string = query_string - super().__init__( - f"PEP data with passed namespace and project ({self.query_string}) name not found." - ) diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py new file mode 100644 index 00000000..6cf11193 --- /dev/null +++ b/pephubclient/files_manager.py @@ -0,0 +1,63 @@ +import pathlib +from contextlib import suppress +import os +from typing import Optional +from pephubclient.constants import RegistryPath + + +class FilesManager: + @staticmethod + def save_jwt_data_to_file(path: str, jwt_data: str) -> None: + pathlib.Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + f.write(jwt_data) + + @staticmethod + def load_jwt_data_from_file(path: str) -> str: + """ + Open the file with username and ID and load this data. + """ + with suppress(FileNotFoundError): + with open(path, "r") as f: + return f.read() + + @staticmethod + def delete_file_if_exists(filename: str) -> None: + with suppress(FileNotFoundError): + os.remove(filename) + + @staticmethod + def save_pep_project( + pep_project: str, registry_path: RegistryPath, filename: Optional[str] = None + ) -> None: + filename = filename or FilesManager._create_filename_to_save_downloaded_project( + registry_path + ) + with open(filename, "w") as f: + f.write(pep_project) + print(f"File downloaded -> {os.path.join(os.getcwd(), 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. + + Args: + query_string: Query string that was used to find the project. + + Returns: + Filename uniquely identifying the project. + """ + filename = [] + + if registry_path.namespace: + filename.append(registry_path.namespace) + if registry_path.item: + filename.append(registry_path.item) + + filename = "_".join(filename) + + if registry_path.tag: + filename = filename + ":" + registry_path.tag + + return filename + ".csv" diff --git a/pephubclient/models.py b/pephubclient/models.py new file mode 100644 index 00000000..0b0686f0 --- /dev/null +++ b/pephubclient/models.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class JWTDataResponse(BaseModel): + jwt_token: str + + +class ClientData(BaseModel): + client_id: str diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 1c01eb0e..672d93b4 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,62 +1,123 @@ import os -from typing import Optional, Union -import urllib3 +import json +from typing import Optional import peppy import requests +import urllib3 from peppy import Project from pydantic.error_wrappers import ValidationError from ubiquerg import parse_registry_path - -from pephubclient.constants import PEPHUB_BASE_URL, RegistryPath, DEFAULT_FILENAME -from pephubclient.exceptions import IncorrectQueryStringError +from pephubclient.constants import ( + PEPHUB_BASE_URL, + PEPHUB_PEP_API_BASE_URL, + RegistryPath, +) +from pephubclient.models import JWTDataResponse +from pephubclient.models import ClientData +from error_handling.exceptions import ResponseError, IncorrectQueryStringError +from error_handling.constants import ResponseStatusCodes +from github_oauth_client.github_oauth_client import GitHubOAuthClient +from pephubclient.files_manager import FilesManager +from helpers import RequestManager urllib3.disable_warnings() -class PEPHubClient: - """ - Main class responsible for providing Python interface for PEPhub. - """ - +class PEPHubClient(RequestManager): CONVERT_ENDPOINT = "convert?filter=csv" - - def __init__(self, filename_to_save: str = DEFAULT_FILENAME): - self.registry_path_data: Union[RegistryPath, None] = None - self.filename_to_save = filename_to_save - - def load_pep(self, query_string: str, variables: Optional[dict] = None) -> Project: + CLI_LOGIN_ENDPOINT = "auth/login_cli" + USER_DATA_FILE_NAME = "jwt.txt" + DEFAULT_PROJECT_FILENAME = "pep_project.csv" + PATH_TO_FILE_WITH_JWT = ( + os.path.join(os.getenv("HOME"), ".pephubclient/") + USER_DATA_FILE_NAME + ) + + def __init__(self): + self.registry_path = None + self.github_client = GitHubOAuthClient() + + def login(self, client_data: ClientData) -> None: + jwt = self._request_jwt_from_pephub(client_data) + FilesManager.save_jwt_data_to_file(self.PATH_TO_FILE_WITH_JWT, jwt) + + def logout(self) -> None: + FilesManager.delete_file_if_exists(self.PATH_TO_FILE_WITH_JWT) + + def pull(self, project_query_string: str): + jwt = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) + self._save_pep_locally(project_query_string, jwt) + + def _save_pep_locally( + self, + query_string: str, + jwt_data: Optional[str] = None, + variables: Optional[dict] = None, + ) -> None: """ - Request PEPhub and return the requested project as peppy.Project object. + Request PEPhub and save the requested project on the disk. Args: query_string: Project namespace, eg. "geo/GSE124224" variables: Optional variables to be passed to PEPhub - Returns: - Downloaded project as object. """ - self.set_registry_data(query_string) - pephub_response = self.request_pephub(variables) - return self.parse_pephub_response(pephub_response) + self._set_registry_data(query_string) + pephub_response = self.send_request( + method="GET", + url=self._build_request_url(variables), + cookies=self._get_cookies(jwt_data), + ) + decoded_response = self._handle_pephub_response(pephub_response) + FilesManager.save_pep_project( + decoded_response, registry_path=self.registry_path + ) - def save_pep_locally( - self, query_string: str, variables: Optional[dict] = None - ) -> None: + def _load_pep( + self, + query_string: str, + variables: Optional[dict] = None, + jwt_data: Optional[str] = None, + ) -> Project: """ - Request PEPhub and save the requested project on the disk. + Request PEPhub and return the requested project as peppy.Project object. Args: query_string: Project namespace, eg. "geo/GSE124224" variables: Optional variables to be passed to PEPhub + jwt_data: JWT token. + Returns: + Downloaded project as object. """ - self.set_registry_data(query_string) - pephub_response = self.request_pephub(variables) - filename = self._create_filename_to_save_downloaded_project() - self._save_response(pephub_response, filename) - print(f"File downloaded -> {os.path.join(os.getcwd(), filename)}") + self._set_registry_data(query_string) + pephub_response = self.send_request( + method="GET", + url=self._build_request_url(variables), + cookies=self._get_cookies(jwt_data), + ) + parsed_response = self._handle_pephub_response(pephub_response) + return self._load_pep_project(parsed_response) + + @staticmethod + def _handle_pephub_response(pephub_response: requests.Response): + decoded_response = PEPHubClient.decode_response(pephub_response) + + if pephub_response.status_code != ResponseStatusCodes.OK_200: + raise ResponseError(message=json.loads(decoded_response).get("detail")) + else: + return decoded_response + + def _request_jwt_from_pephub(self, client_data: ClientData) -> str: + pephub_response = self.send_request( + method="POST", + url=PEPHUB_BASE_URL + self.CLI_LOGIN_ENDPOINT, + headers={"access-token": self.github_client.get_access_token(client_data)}, + ) + return JWTDataResponse( + **json.loads(PEPHubClient.decode_response(pephub_response)) + ).jwt_token - def set_registry_data(self, query_string: str) -> None: + def _set_registry_data(self, query_string: str) -> None: """ Parse provided query string to extract project name, sample name, etc. @@ -67,50 +128,37 @@ def set_registry_data(self, query_string: str) -> None: Parsed query string. """ try: - self.registry_path_data = RegistryPath(**parse_registry_path(query_string)) + self.registry_path = RegistryPath(**parse_registry_path(query_string)) except (ValidationError, TypeError): raise IncorrectQueryStringError(query_string=query_string) - def request_pephub(self, variables: Optional[dict] = None) -> requests.Response: - """ - Send request to PEPhub to obtain the project data. - - Args: - variables: Optional array of variables that will be passed to parametrize PEP project from PEPhub. - """ - url = self._build_request_url() - - if variables: - variables_string = PEPHubClient._parse_variables(variables) - url += variables_string - return requests.get(url, verify=False) - - def parse_pephub_response( - self, pephub_response: requests.Response - ) -> peppy.Project: - """ - Save the csv data as file, read this data and return as peppy.Project object. - - Args: - pephub_response: Raw response object from PEPhub. - - Returns: - Peppy project instance. - """ - self._save_response(pephub_response, self.filename_to_save) - project = Project(self.filename_to_save) - self._delete_file(self.filename_to_save) + @staticmethod + def _get_cookies(jwt_data: Optional[str] = None) -> dict: + if jwt_data: + return {"pephub_session": jwt_data} + else: + return {} + + def _load_pep_project(self, pep_project: str) -> peppy.Project: + FilesManager.save_pep_project( + pep_project, self.registry_path, filename=self.DEFAULT_PROJECT_FILENAME + ) + project = Project(self.DEFAULT_PROJECT_FILENAME) + FilesManager.delete_file_if_exists(self.DEFAULT_PROJECT_FILENAME) return project - def _build_request_url(self): + def _build_request_url(self, variables: dict) -> str: endpoint = ( - self.registry_path_data.namespace + self.registry_path.namespace + "/" - + self.registry_path_data.item + + self.registry_path.item + "/" + PEPHubClient.CONVERT_ENDPOINT ) - return PEPHUB_BASE_URL + endpoint + if variables: + variables_string = PEPHubClient._parse_variables(variables) + endpoint += variables_string + return PEPHUB_PEP_API_BASE_URL + endpoint @staticmethod def _parse_variables(pep_variables: dict) -> str: @@ -127,38 +175,3 @@ def _parse_variables(pep_variables: dict) -> str: parsed_variables.append(f"{variable_name}={variable_value}") return "?" + "&".join(parsed_variables) - - @staticmethod - def _save_response( - pephub_response: requests.Response, filename: str = DEFAULT_FILENAME - ) -> None: - with open(filename, "w") as f: - f.write(pephub_response.content.decode("utf-8")) - - @staticmethod - def _delete_file(filename: str) -> None: - os.remove(filename) - - def _create_filename_to_save_downloaded_project(self) -> str: - """ - Takes query string and creates output filename to save the project to. - - Args: - query_string: Query string that was used to find the project. - - Returns: - Filename uniquely identifying the project. - """ - filename = [] - - if self.registry_path_data.namespace: - filename.append(self.registry_path_data.namespace) - if self.registry_path_data.item: - filename.append(self.registry_path_data.item) - - filename = "_".join(filename) - - if self.registry_path_data.tag: - filename = filename + ":" + self.registry_path_data.tag - - return filename + ".csv" diff --git a/rafalstepien_public_project.csv b/rafalstepien_public_project.csv new file mode 100644 index 00000000..2d4483a6 --- /dev/null +++ b/rafalstepien_public_project.csv @@ -0,0 +1,3 @@ +file,protocol,sample_name +data/frog1_data.txt,anySampleType,frog_1 +data/frog2_data.txt,anySampleType,frog_2 diff --git a/static/pephubclient_login.png b/static/pephubclient_login.png new file mode 100644 index 0000000000000000000000000000000000000000..61be8b043e40ceed761e88c6abd62923b28232c8 GIT binary patch literal 86674 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%0A-+!+;{+YQi z4vp)tYBy)KAJQwP{Z2qEkMmGQUBUG~m2-V1y}vxdzJKukqT8?3ml2FI+HTlU^r*2o z^1z%wDNFIn`Vgu%(g>NwGD%+^2VH#3RWcy}5}6-g-)U{%uhJdUQ-K~At3M-h%gkt} zhP0~mrS{wUx` z%*Jb>SJa7aW`3qk21D9cCRal>Zj1)ZPqnd{(vU;inqwi0AUv23L;bd3l;pyzg(bO^ z35a!cgXGRN9~d1?yBdx;HLV0X%(u3i7_3iDU-tIx;u4QIE>Sr@4FF8=7y?+}EJR~G zsWpVU4{0Q)?$iJYnErTX3yhgI;B*hZD6+4j1U~?T>PFf4{m!xSR=8wL!Ty}mOIJdu zvEa~`D28V|`TDuv!(e;kqUY6E>EQC&nen{A|Iiqn zkk_xBVC>g+VRHM6lDbug;$MxqktE}UPL?Ar*%_4!vo`D3GrYrIQTwVOtLuK&0*L-Y zj9VmtoE_(9r!?dazW?l}8B?GM%N;qgtFXDs2QZy5Lq*Kek$a*4rNGY)OKd;>HC6HW zSMt_LD6+`gFRSp?8fdi*)5!OO-PDK1T`dQmhyI5M3nUf&`j!SRd>d>Xl2fU;Osc@t z8s`_MnFzbQPv8HxyCtJ4qt&lu&EgxFcGB3^_B>!C*QZ9>I=kbLvoM%vJSl{#fD8k& z86y_4iRO*hEU?$PhZn^wpvF!|zr1_S>@3Q;aP*S0GY0Zhqfi1BFWvh{4U%KJsP7^^ zOjnKbj#NyxI&<5%yGFg*e?twVybfs&jcU4-et5CJ(v}gV_a7Mb$m&7==bO~pI8q@q z`a;&7G$RL{^2;mYJa+Be*($r&PH8R<#@cyq)4nT__q{Y1U-zlh2dAVq`aYJ?ef^Az zWX47ANQX9T+cBis`Nw?fX+>+Kk)GV*2>Hk<#ktsXcCs16Ic2C)5#N=XuH3-h&o~CC z35-Ad>RnIr#D8qn##JACDx9PDIZkgg5PZB^-JhwbAM~4@5sN z(H&eA+JlXEZ`iCuIincVRrIcLW#qzseKU+_%D1a&I}AmW4GpPgQ9ReWMeQ1}O?&Ms zj|bK!8}%Z3?3&7rr9igz{lzn9UT=_>CsLZzQEbWIZ|YSqigOWXmsf>v&)do^ZcWufC&}s*)`mz$a>O~1U*yqxr^!UmCaHF_Wqs&; zdl9>R!?Qp=*~JCD&P(!rU1B!k!j|^!G$eD&Dw~v$_wkWQ1$+o+-u6G3nNq~Ikbtog z%Uy``x3SzPXiYB*WQVR?E~xMss3DaJaw^*f^%e&O<35QH6`LzJJ-bV%KkG;4eu>!| zVLTymuPm=nFbWcj_~Q1xPbUA$RRt9^%>EFPa;TU-yyBkH#A4q8@w0v!FankyRd zJRshM!!f_+WPWN%NZ4w1r`yJkhrL4;AZ8A{pnKnCpg%N*q5&JqcFa5S|7xnd+_Pjr z_JTa5>SDfe^NHFpc%?)wkngDf#Cwf9z^AHHi2G{;6bNRkQ+UeGBQ0 zk1i^xW?~)oeA`Ix*)uc&xG}x&K?u?%yna!g039rqf-;)S^J(=(ye#X|4;SApNXAv# zFv|f0#Ao4DQ!aXGEq?fvA}&7&`Z4F)9WY)YxXQ6zn0$H>qt;L`+C=L-IBkC&p~83l z&j~BrD5rg7!*`N7EjK7C{!&|-wmY#A}18mH}!=$MMw_g~v`Bm{Wc*+(CqIKX@VQU@8cV#A-f znre1B%Q9Rs_`eS7(9Q-BB?_DyEf(bMQ`R^W)chnpq5iUOE#OwTYvyNthxY0&jx9?i z5f`^*q7x6uKgo_vn%dK#uEKB*lhx>2u4ixlUt+OPg2M?wNv8mT68W1p*KN#d_^8Qv zE%uy%FPLt#r$p*McCv4EmuMtsi=n7so`pj03-s8q8d{AkWsG(dW*sikIrm=@EnWTf zqoBvRDPB$*3QWaL^ef=n3`VNhG!;g5{q`Rqq_woOGXtmUERQv^DIIeQp|X%hKnHA2 zu3U>)`yXwdtqb`>W-Al8vWmzOl|)~I^+7e?SdmGwP)1}f;1<6DQ7%qumP*b}jbs4- z<(|W+GRj)LAz{~0&Bi_Gy^gtuZ~hnd$nX3#QExO-I+D=-o@*sKkZKG`PTPfwhqnMy z8n+|yUHX4WBv>lp0KZ0~v|#7UY#>98u>`jp33>Nt)tLWkux_VB^_r(2p4^E#urYw3 z7;f3_L%~vd5^5Z^!ha9CQ?gEeW4Cy(ZBvj@yW@cCG2htQc#s_QX})OsM4DVRFdx&S zox6Sw+%J(ZQM>Iiu!iGOj4dnv*tVdsl0W^jL^5E@`t_+hRiv~0Vt1Ws%@|NPW#^9YVKGb zzbW8L0pvZ1nC%UEQ(lv36ysy@-mdlIk5kdKx&-{kvrbR98D?Hz89wxKD8HwF7p~-p zKAvl5uTnFSAC&Iry;)geho%_ zUa2EpH#&a!-K{XMB= zUAuO$OgLWDJB<4x1LaWy!k43Hq!qufVfWcHsQW+^iT! zJKvVb%;6%|iz7L)mG;$Zs`r*!Uyc)crgeSuxiM^bZ!0Bw*z~8xG{yJfY;WxjoQ@Z8da~bo zXxfif;iDlsmT|vT^Trac)E*gOYvFFwkGll@Xi;-g4~pyB_xt{iTSUnG>`2~ldwxcb z|6rosHiZe!l5v@$sRb;{~ZYNwtWV-Kv7hVtajpocvE zmyBBJhFpE@Y>^V{+10fQAOI)??kpdyCN~!byf_q{bA(j51&@xJds6wK^l5yd;LYxV z{gD{mL&tKyY^sUUoROK1Uq@dtxA9s~tVLhg{`(E{Grio$fmTh`pccnSN9W6zt5-MA z^g_Zcul4(am|vj*O(ebKZ?2#Ol_bnBtO%jf52}g8o35<(Y2s6R$L)9A=xlouEYCJs zt9kV0tV(<#_2F)d=rVJKg_d&}$_(I}8~~K_oUBHc!wqZli6s*9%37cn62^S>Y;565 z+7WcHJP32YYqHs2D-P@ZHca@`eZ?5^HA%|w?`U>N7|7Cn`9r)hsT#NGRcf_~_nhoK z!m^Hpk>qu*ZsN5vCbBrClfzleW6cBdO)EXmfeAj5)AQ;JKKHw-+jhnMLkMYTm`z8^ z9e2wMiiTUX^Ol6{&ik2pIu$2QyBTWFiVGILWBM9;cFj`z%m-?suqXql-SoD0o%6Km zuip+7%-r-mcCW-5fA?9i7*HtX#+PYuP<&)r{6E5lF2` zt_{)rTcM1NLBeaPx3`#@tiwq(SH_Om&g6C$ApSUgW=^R1s_S_eKFiI!`>WLi#X!MO zgflz6re&?s_Y{!Y(8CtzJW@MwfJDGeHJGg}+2lCb-T&n5#w~F6Rj~! zy*Hozd^&}}oyvhmlxOP(r7up0gRS5$z7@N?{KoAA4ZTjR-n1_zyJKZFbld_$pjW3G zmfi{AMQ3gEi+=y+@#(~=F_1eNI28`WAbj)!7)T~a1sxEMC1l|;cS=i&LuWH;?B4s` zQWB20oZd(V3X*bMOT7CLgF&t8^zXyjd6xnIzBd6PRB3>I-t>5J1)ZV?B8K&J0B}6n@ z*j5)%+k1SDld)DYyjs7fiblQ>yA8l%=;}>PAU4r*&%6KLL?r8Zo1Da(-Ci#=is>(} zYQt~6l@*gFPQ0gQ<&M<(UB)TT<9|}-)ZIX%n3(emVkY%t)yw$1IO+5pyTt75Y~O*K zDM$5hk3_t;C8cFe)nxk*?prn%<*dyh4@?d<<4eB$fa~3D7%%Gdz$#`;Ry_)O&V_g+{Y_Fz>11nO$_4@V87=q}mcXGf;PH{p?gYLb8zUDZ* z(S0%d9Ef(r0WY1?Q?CE;v(p*+%vftqyf_~+c=d@I)bhfXf%7|@i`cX8qA;&+*vRd< zXUKbhUj6WBTV676Aq^Sj3qnl>K?_3}$q8yNZxQnk;1l73;-*(w20zAsEYxReg6hu` zN2LilD%CoDE32edoeDMvxy7lmvT+h-f3YzyCG8MknvKzpD;7{{l2!4yG&j(9hh2#J ziCN2*gY@;fWM?s9RbBVhvq4#*BM8SC^UOMszEeXI@x<0!L~NpgP;iLz#z%rbs7_8IoJh<~% zdrQxcm7DhS&gRmI1{cOw1`VeydcTja;6*zVQEL$0K^hK6J+~iPRmdJlOKFb;IFNeIIU&MW#^RzPe zRf|HNT~79bu18mLCsE}ZUZ2!L#^EkwL)%OFEE?z8lSe3BuYwxeUe)2xpfGvJN#zC# zSvithmVhY>p^kNxBo^-7$RteBJNcpkN9 zj0f-T=>A%r9N&%;{8d4kv${!5yufb8o9j8ep|X!j1`6F0b->0L;Nv`P_BL!gbnIR} zH67O3u!q}Ijuf=bBq*=Fl2B6z>`axymYX+>X8JZug8JjudsCiQr$7naKEMM=f#48J z#vQ^$8be+kVf|@!#Wqqhu6!Aw|HdDrUQ>;WRP7#v7o?HTADO^{ue$Gn_Rp1M_1wM| z*WIer9+(f?$65UiB-#>~X41dsSXf%JKqS$-`Eia~v&7R=>8JMadAbeduhWeLGVyhX zJZdgaw6-4`x)GC5^D;Z{K1p`lY|$wRRQ7KbAds8z_5-G z_x_lc9%N+MGVIt;Se#sW7VTreG`Jit4?)x)n!5$^Xm8k;-C5S8{2J8UM53@~h`srD zs|no(-;);7*kX43TU!GVtSrS^Fh_(PqUie?py;l<{Mp3`q0t6u`|j?#oSl6s&t*bG2$Fw zlgZKN-0Z39@GxAS1;-*dooxM{a9}8_i7WHk?0u)*HS_%=X^b#%Cnx^Rj?ze(wBv;O&-OHVRsp z@QittZtPxtu?3*SF%!9*NcNjYp?6`1ASU_YaGCkp4RzkG$G>@WUFHW>U3sM~McmC_ zlWeK<`|>s0^ipjPA}_~N zj#cc#rFf(H8DR_Ei8#Qzf{u?@p2z~;Uh{X?E8sYkW_8{w?R9J~<|gOgxybQ!4@nOF z<8yrN27dPMoRf<2YKK6%L?NfMRsU6%QujGTFOO4Gm{Z{oZ1o3_5xm&>y1Li6IoG54 zDW~HraD>O%ny)H@f@sax0D{k+IKKOLdWVVZ2@?5gX!Lc<@cVt`;`CP6J?}b%mC^iU z)WkR7tRL8sg}>H83ceq|p+xSsH?TBlP(#Dda*SdP%{+c;P{R=E<#}>O5dm( zum(0LJqTDOvtzW(Iwf|xs%z=r!2kzGP#wZIED;ANBNQc83b!{FpWK~=d-^sj0Ci#V z-i_WFr~ISWTlcqL6LiOA+7K7*7EE(KcG#JGSl6(FaS!EaI+AI6Kg8YhKPWX05=(vn@Aqg4X+B8rYZ1qotnF zsjX~YDmagI-<)y}mr$CUa+t7^D+>qrTr^R7CHZk=AIJ)|jxuEFNoDWjSUah83Gz%} z_7?shX7A*KAlEG!yupLa;{n%w@5TQp{_6hLRG}--$;Gt<4_?;XaiW(q{jnV#K^r+p zYM_naWYuubz6qsA&{6E#|PqwCScfg`pczdeI=~Q>Oe#+ zAsoOc2829@{oA%3^t;VV>Ta9mMkmQ;<%0SgDVRoI!LWVycVA~8jrrqF+s=UZGGDi$ zK)UW$F+{y#^0nND^YReWw7H>Xltk^M_j}F111Aw8fTNkT=h0&4ee(CO{8z3dePP9x zg3Rn|Dkt6DQ~FQ4LwS?QYS^5#4u08g;x<`h($myUcI^c|@tRDQHQ|<%JxD7$0E|vN zs(n2FHt+RqGMZlE_H?y^(eW-RST;WUy{%j^>ZH2z6emP7PLPDRyFWkOS*IeJ8B>t6 ztr~yvR03ttnq_G8wQpHF?kgxWFxYVerBQr`s1LcAqrckKb&5B z+#XkQEvv1ZVE3C_OY%s8_fvS`hb}ApaJ2NfLyc0PAvhCfg`nO34S)Bt3I;I3F2HS4 zoM!o&{2+Y0ZQHa=SO57d?1+X`*1bUM35daVx9!#Z@J5 z;M|h5X^9{dm09ExRV<+1U8=WU{H$s4>RRRCRnTu+etODw;z4-aU`w`JqRvB86C<$-Pk;(kAoa*ar7ryu#IBm}$6YrI2){`m+L_gua_x((ZMSM) zh8vpE;<`1VyM+hBY++@&4zWxel{zdJ{&3g2cVHqwvlh-Z8UEY?98eQG?Tg}lKH>a8 z4H~o+;yNPhDL6n%r54n*sa-$p?~``3pN2_psoLaRNFI_+G~}Th@z-A!xCeqTi>^i6 zz@L$gLKh;kH+GwE+d3dCfh~UMA`~I2Pn)#yR5Gd^oV}yg1x&8Mw+JR76EU*UuDTlNFfw zg`*|v;-G@E1c)`p%-5elvWRy*?BBR8kFNAm98isL*5Ae3>pXqy?)I{sRII(Ep-+rA9e+cfPB+s8|m$FZ(R~S?p+P4^E#kd$TJQQB}oUHzH{j_^oMWpqS>ejI5Wq!r@Z_2V|_fZ1~CcQzUua!)Ri*s&83^POwYdH!$CL)qp}F%b8!*|Q@;UNUkkUGb2Q?FCX- z9-al)ACbl9k$R-|Ya|>l5k=J{8f2(N*L;Zi{1{h?yQ5drd?h-j_Gc|XM*W9e&wA#& z*NnRAk5_#>teMrh6?KJmNro96uzf>=-aqVdpLco>%53@KI^{p+QdnLTvgK9wjio1z z!sfPxP^o@c`CSrnt3gbS@HZU<`*JG(Pvz39>^(0N^1GBW;Gp|+9l$!S*gB!O&OAe0l_7OO*5?y}e z+<4KJ$r-7N(_>n74fbrAjTTJ>I8O}|+Nx?hTSgko_|-W%iS^D(anZ|ru#<{jI>7k> zf;)$~aOi(?Okekj-x(hoFMH&@igF<2-HJGcv5z_48>v~gW4@Ew`+iuu&bdT%oeBT&?%1*C+j~DWk)jigDkm1& zL~mn@6O4=+`_UCs9Z|7kMD3%J>-1c~Ug?VA_%GN|O8E0sKmImPWv@!JQYs0dwkhr( z$$w*XAf$iXC}{XrNGvWtcw)@6UX`2Fb)Y;j9aLIH_Nl^#d*9m{ZT~*pk5-|6H|9;9 zXv`v&upNr~0luz#544%Q5G=3w*Qo&b`uxvvAm3udZ?Aic*BYd8ICFPSnHqe$Aiwnn zo?PVVePfF&QMb`+B9nHS{;BM7w1G~{a9j2zdd3w^*YR6V+edrir#=VNC&|$}N~(oF z>UN1UKISRUZGJi}U}!NWNyfeTejEaqbWn}}Z&!KFc76;U?%@R!^18RRod)I3!!@&o zEt&57487_v)I=HRjPbx5C6x;Ir{2qM`PLgU2wowh4z}W>zE?@541==rjL$k15ws|grxFlZYmL8dJ3Mmd3!^N z7#rWH0=-G9kGB8cRMmG7JBxi154^RQ)e^CeN}0yIxr>HsE;%{Np#Cq*n9X0Z!rTMPF$ug;ko8?u-)aloke@a?$fBuH`mAzS`tlcY5q5tx@j@?AX z7qdRLos5RiE`mc)>7A|CHrjWr3vgK6=VdFyZ~7LF@ZUaBo$@aaTXFyU>*a$0Aa_X< zISO@-NpnV<_7h&~|6$ymobg zN6X)i`M>+%ZTRIYYgY8`cYM~m9DZ1JRbXABz;h zp>%ir>o(qR@AsTB{_l*zR<`4L?zm#bTyxD!%Y<|Ph~1<$!e6{^C94&N?f>^FyT{OT z`&S<=jXL)THlwxF`>(gNai}JWWXQiB>qqQXQpL8#R5tB{;g9beMi$Ezzbt=EF1p5R zZMHG5*`B9Xci2_)_D)SxPJKr#e;Ly=Pi^H({m&Lutrjij+-&;Iaw3{EGStbJ#2(&e z*S2-!BWBaiOlNvVHKpHY4Go@0a)S6iO33Q8pt^?spK>fu#xDb$>9IzRLOaH}?Xq{s z-hjIQ)nmwUz1|;3fOh)5s#Cv-Q*LgX31!lQj?eUCYR#7un(ut=y1ahOAHR2iW<6GP z>ngJfo3k;OX1?0Dxcae;@GiDHxr3Oag*e?;#kzXt=smw2%CINp`~YX=keM;==75{^ zpifeB{05lVzG(pr9>C*GXI;OB{zt{7CG?lEh+C^N>-fFs{Yv07x5W(31paa8SjKM9 z+X<`nMCR0=f0{(VY8%z4wf}K3yWnyN&DTl((h7}`kC@^`P5#eE5&>P2haLNu&+Qin zcY1kUEdZSApA84!X%YU19l0=#dj3Cys08s)!;kW-e?RRlE`+6)7Kg#(V=7eXKl0b6 zt82pNGzH6%n&5AyMR^ z9n6Ts854?3yYsBdaahfgBih2VjLk1RH$Z%s=&v#JkobG`mvHrfA#@V~xSu#3n%O

_v@N0C9aW*qqp}alvmK9v?Ps1v9Pzq$9k#{O$I?Y zMD8WdqdRs$ihYS0qNQD}n9Jg>a>CB3nj)0{`rzyQYPF`2t7EK#6E5FVt>yH^aG5ov zdh2WOEBg6is4f)rXC{4MM9$1MeMSa^1&iVv-<=9A53cU=+cRC%1#}sgS!^dmSeN(T zp+Zg*dEM{K4`?sUeMxN`H3N zk8WpK>FCI{u~&&-$m(f7>wky7kd!mW_`n%&6^-IKj9z-xujTNq#^gWPPcO2b@06F5 zBX?s$JKziLA1fJWvrStMdE=@VKl1$^+33f6>CY)g(qCyV_b`$Orym@Z)552Jb!DR| zS4;j~^c^sM4U0ZJ6d3p|j%ZiUYFV_WaI?9;@ntidqcZ8Fid#MdX;4pQMuXFSBW<+x z?kjt;H_6M7CjyWOLDEHxcn_!l%eoKyiNV+X*ns)Mo12cEdeLQS{^u^AynAxF#Hooz zhBPSz79n)4wLSo1+{o3ruB1MMMNB|{ z!@Mxv4PYY5OL3&!C$?Zp?_UjwxNA24==f@xy zyFMBT^_&aQAYWGOc=vTYzbzGieqWX<6f2f{Tf*s+UTEg1 z%Rp7M4@gzT5Bk}XuDffMSPp!J%1VBAcJ_x27$fd$<~`y=MJ9bvoBQ(7Y2iaRv_r&* z_l=VUe#e))|7#7SJexj zhf07qkq*6i!qEvp)@gD1^{}B{xIa(-eb`)h*j#)2tH{HSVr<(UO1OXZD2UJ;&J+K9 z>F1;IbQ?#P6@{hSyb?{hV>xsrHVX5r7-A_S;tnlgv~GuM&6@8`l{pQo4}C3Dgq#$( zD_4D?c9Q|vHkGm~Vg``%(P*1#=iV-D(s3(#Ze zaIBB8VUvWlp{qsy>q|jwz~5D1^I{KcM%rpGqkU%6t8n;s9rMlKWEm^{6+AiHJ$qK< z$tf{QyS(l+Z*!^71YD2ebWe7y+-S0On%%T3IM@(t)7#{cF+PL*44MZvIJ0+j#D)@M8OfHBH^N$#6&Jl~PW>O|dqf8GG5&nz@}Bs%c{H4xw9 z*G8_}(dwm#`yz`~E*zdCWLJP3es8(rOTL*AKAidA zwd3!&Xq{pwhkZJ#2!FfoS2#fKVaxa*E(kH~q0FEckqE#QO0b&Pm&=;;2+s)GD{=>kxi)*o+iezc8nGyaEFHNL(hB+{SqnR0pRQLsg_s`Ah(+r!*C3 zwYR}olk)9Nm29!|$k$^jPSf(f1uUYuz-hxBQEYpPn1!MAEA5rLG&IUzt>s z^P$gCf0HUpHRUUG>r7c;p`caP?Cd&FSXkKD6uLz=LeE^F$Oh(P+ndXB^gZXKpWY{E z5di;6JjktA+OfCP18jvP+tkuhSxMMy9$grh<#GAxRi)g^AC;)ax zepZU7E>v3F3Z3~|kD+kVwRippRGo}hDvx69fgK+5?3pGMiXB@SW3vIv?{Xi_zDZwV zH575@RHVktDGW5Shc#s*(C<_v)^|wu^6LEnCgu2P4g1^C0gnw8vxo4mZ$Hk5HHldQA zu-kTgEjysAcfi2CV8hM}O?d}RE5kw}+xmE8{Nz|0Xu_E8)~D%~p!z@!W|M z@3UpQAWb6$*|a$yw)$v)F!@XIsc=QclU_@w#aq0vl79Mo%)dVSE( zCZ4!F)Jqm|b%YX!Ag-mM)#>Fh|FrK|#$mP4rJULvZnX@qtm1Jmr|snugRX)wAxn@pDRB*++9P&r1a3!&iq7~ zsAfng2FhoTL>DicR|7EtA$g8LgR=C~Cmf*h)WKc7E4jT>b`*M#S{Pg*kTkz*Sbnl4 z_0w%ZC@M&Er`_>z?Ij=6A}VeG-l4m6bNuuF+mb3k$Hi#7E8 zxmf|NAzi{_K==bDk}7zuin~x4TC~eeUM$PqPJVm)UA^F^i_RVHTiNiyE3Q#I zIa={zxBQwP&tsx6GTy%3-c&Z`rnpkIlf0pL1NYKnbY=FaU9gx=P%WQ_H@sEypnrw4sEy^oGLNLv;V@dazH z71_3F-*JjgxjtEVm$a~kc$B7R-{RKg#lY`s7Nj@6U9?hg1@_EVBav!i*(2phCgsGP z;j*Rin(>?)Th`muEI>;u-AK?*h_I<)Em4aCchX9PjCS=b$8@^FhNNm9M;Z0)d}yW} z-m7aJXwBvA?6QVBhV^i%lVrzkXRaKFK&j#2;Ewb`hVkKwbF2HSME_sOi+DX+V5v%9 z*9>)@i~lT}{(4BNkx=3N>PNPTZwO1MH0pk&dr7=hYc9Zg1;y2G70z!rEDnT+Og)EG zrnKZO+8ah7hC?J0ZZQ~niRryhDJaNwfNplXxuK#fe0o;jKRqi-k>waHOc8V2J~SMB zplCd_HImy61j-zUIl9*P*cFcLw;+HwCFC`XG-xD*3%AIX~!O!ORShG@7 z+G^#G&quR$UcLWGVCC)OTZIT81d=p>NH%gD)oMR#q~9Cw!?&o2s7S($Z7bLB$lSGK+b2X*k6)X023#w^n+B`#Dx<#=aW``>_}t6 z<)jYdvG=67a$TcyiE|?(t*$FEPY(ivWnZ0(xVjQ+dX9iLf$Zcqm+G=%L}*h$ux#&% zzo~2smT=@L>@e>-3>NbM^e^g~+mJ#?wE8!EWZ?15lsWfA&2N6t>Wyq|jB}<5N!PNx zf7k_zj-~Pw{D{-GiW)2I39YOD?YT;CERP9G?`)6(_0P-_$9l0B6RMQDb`6nEo^&4f69I zOJ7)2dD(E3F{el?Dm_Q+n&r!fOJh}@w{4P-WC$}j=`Gfsu*<}R!;n{ajiuIeE)W%Q z^7_HGb@RbKI(qYZF3Il?S!8VR`3C6lli>}nb7q^-NJoCDV)!x10Kw#c<$%SWqCJ^ZM}mmf zU!an%n#_@)p+NmhvCT? z?kaoxnhNYl#iJ}58yck4o^}3ua3gyb4#%Z$*W3e)sF`VD2ADi+^JN~GZT3>|+ayAu zD_iw!!iiY-$x*bOU;PuIcka+s)*13nby2LeqH*V1z#$4f$Y<#EHwT zbcA&iYP*yQ*q;Esim}{b?rY+JOIN@^5A`bs5ia^(8Va-42$@wLGf0>T%A+33|9USu z8g-0igFzPl6MdF}=dXTc@*q4T-$B8~|nORgs zrsV>yxCAdn2TPc*6mD)}EQb!ym$8L`w`9axJ}z5RO}G#$J#Tz=SsM)@Uea-1-}wxREP=#pNd`oZXjQW9*;@{OBY|nhs&VyhX$^6zx{N#H0C(S zj`#`kYjCVUlf;|T_f7^3y*ObY-dkSJJ|_Y#@+P;+?M*7)7%>w*)JCJN;=SpIkW2Tc zPJ2-cIm)GQk{U%C#J}R!H>mbt<+mBZ+9}-wUi%Di{RpuV2rOJ^pXC1FHSOpjzGQ_= z82n6Np*eS*u#~BAhvVIAjCdjXhAmj#Z$$}z$aNG?fRVq;Dw_YTp+XylGNaC{ZuqsP z1!R5HYM>FL8tN;hAsLywYRU(TzFl3L&A%guIVD8Unnwrou7w50%kuk7?Ia zPUPY%lNfG(D+(COqD73)mH1Pb%ECdV5Be2myHnm86|Ht%DWd&CMq9HT&`K`IXI({o zdXe1kEY|tmNCCSkwe$72$fnSb8RR1xe@a&ky*INoI`M6_2E--+m{so{!=eYLpE(M^iwLeRQHLAgi2;#BRiHw`#l{3Z1MmS+!wsyd(}QjDVd>}tdZ?31I%e!up5Vl?uOkIv9eV}*}Gaw9$>olP#; zHl9`2@ES6s_=P-3#?VxIAP1;DKk~6XAe?X8!M9OVo z)p8V+k_6FzE6l)_nHg|{FN`o=kU@JRd#apw^5{G*uW^E)L#CT)JvxO!!q@WysS~7c zaPvOIE)WC{h7yFlXq#CL3S_nfY9pO#?G4-MPs`B5F?;^G9Ld%W)yp%#ZIc`gG1?AS zd>TsnFBjm5p{)B&jCS|3#S4tqB6(k9YUthxC2YT7w=uI3xITR>Ggg_3WD5f2^eR99E?K&x4&6xNFm zGEYgbI(BwFzRT}`4UKM+E6s~T?=3x*f)c{5@vol6qvG&7+KN41ti2xs!%DM`k*S&j zo%D|3&{|+|EXy~Hfsi-56A`^6!;5r;uV9o2J~J3YdO zq-Z{|UNN=o!!s5WL#eE3E@~7n(h1pZB!dZflUuw*@m&vY7}U72ai?4ln^RqGYo9yG z+ra0x(~?ho1b>{WDGQFNMky4H(p6I2JQ?477R{#F zJEv<-K(Oj4gF3UW3#p(a?$IPBp0u*aS!8egGA`uUB3>>!RO6yds>{c$sw*kc+TABx zBgOsqKFQOq5t@*^NEe)GnBHSl@!jnDz|a+pe65}l479%V$|G^3Qxp;S$?jnGQ!ha1 zNqYE>7{^Z9eyz>Hq3huLnTGDp#09!;i2!D_7t(2*(|Bk zw8T3;EO%q?3Wc;als&|LAVrvNwPeM*EkeAbtwbZLAM+92-W zlf1iIhxXV&=Th?h-E+^$xRWbAk95N>VEB73j~n*j`>*VgZ@n-|Sk)GIU zZ(g4ty5o8@AKWrLKz9;Md-a~1T*n7_#vA!~cPv;F*%3qLr{L{7>pn(-=!MYCk*`0Z zEe0o_ht`c>43U%~1T6g(uOCj<#}9P$^=(|2ou27ZIpw-IF$(tnVr;jGGk9P?KTE8} zDs>*d)mygKbN4==+;Il?k^u;E1e5Y5vvIbr7Z~~>r|gmV;b~I|-8)NsUIIp+xr@?W zyk=eI#eOo=euZ(IdVH-(!ezwlT1sph*{LL*e`oIZ{CQ&}MuJTt+Qv+##=0f-<5I!_ zfp=jw&iHG22iAPrN@y1tyys^{MMZV>ag&d& zR#fi-sHQ7z{-Y;tU+Mnei16)EDW_6QR=UGT|E#k=@cOGEQRg)e6z5oaE7$3w~%;Qh>`zygm(mJO!I{^kGcFKzJ)e z{)#JRG1Mk0qPzI525As|xP-ndrhCMwXOnX5zrXS4&k1v6^k`Jz+u102StZ>~q&;$QmY7Nd{3R4V8<@OU1&43%0ZXuQ!E z{FcwoMkXo2%l6a3>d!j*m1PhHiJZoX4>u#5k49bst_O$}E1+*-^s>+8KpOIn`Rul8 zG@bWR{$M0VDBZ-;5+`6S5m3ER1jynU&%)P7yBrP+OLJ3y0?l8`OaCRZ6Zl>rQ*o!o z7*L<4?rv4EIGhk2IH$!8i)n_O+J1jemGDZ4ID|09fWQ?w%&D+Klh94m(Qg-0Ol+!- z^hN$Lz23OUB-PK}>_MH*#f`HXEKJMSFINX=!vI>8|9wrU?U{f>D{|klVb#oy`rlns zdTSE8*X6~-BzZU8zd>FiQ&3CnaUk911+D>d2SJ&2hf7}@e zaw4FyjX%q%W1ZFfQbV}bzqWeqXK1ll*IKK19LVWWw)`V0?-=6v!FOeVyVNer?@{vS=W9 z7XdADEl@C`2VK5*!gNDDikDR|I&?CrAn8`*w)H>|8cAsgy_b$)9z47Piht)1fB(FR1X0*u zSbrNq$(}~qJ=hvtsD&WqXgcvmmv{Ww7bQ|b+NB6Iv&tQRMx?8zjA2FE*gk=&uEE6m z>pGC}u9f*U)!+Y5i~1#3Q}OFtLvGmA(V%?-D=Eo2ml&1gY%9R~uMxv~itM{(yg$By z7OAZBv{;^9T^^ZAq9YwFm6}s@}J-GBrm7q+@1X!j0uMr2^PGW0oYcW)m zacl(6?MjL`1W40R&xvr~S(cw_4DO`vIe!EE!kL5)#)w~l0%G9d+KR|b4r=|&P{R_4 zf-y%Le;FivF>F*CUe4U=K?Tqk{QlCPKfPZgqw6DpIJ{m4#-1ZJI zx$RaY%nJY7{dI`1pA0~poCX`Ae zGI;|tiI&f2Nu#|{q5-(Y{`;r%s*Wd z<>&wNUT;*95pkFMX9zF>-Pm6b<5qkB0|Wl&J)DtH!*4V&bq_GEPNz?QBk3FXEs28f zD_zzk-xyRq@DnU6JiWF9DzNGw9W5HZRsh)}$FQdQ@NlCW@iscu9_2=*7D4}-8@XCh z*jG#6iPH=Kc#e+cHdOBk#lQB5>6fL&dMb{jhQnms1`I61*y0|3sY7oKYaYHuusRPY z1sZKvJU%FeTCYBz?U)i|Bd-?!n#bQSJ4=A9JiUMIT1ZnO!R@pIfHe}|2x=e3pfIpM zRR|T6ULb`09IvSn@jSepEat@pxnl+3jRs(Wm=QDyWI@_MHWU+cxU#AWvu0QP!{*jj zKSeF%swAqasw5%Is>vJ>TSpGOHEai>gUR9UnmY89(TpJ;1BqOqGZN{%<+12x2!?Fs z5WpfKAg#VHep?{;aVyjAUml&|?iE;kXVCox|5(-CwEEL7fR`dxUeyWuqu9I_CrOD2i~y8K@Bkcg%dAy5#4|ty?Td%5ghY(gf66j6t4f|5~~$qMo2%@kkj8 zrwmYujj;Rv$pYu1i+K#tdJd*|FHyyS4qgm2hnowy0l<_2pah$_sLQH2$P_TZf+g_s z^0I)N<5xfd^XCp|n!Tcmhg3Eul36u3dk;|P>>f9es7`Q@zRCPIz5X5Z(VV`I7=(cP zSJ?k-s8N}oMj*~7}$i6^T})qx!jfX8-W-UCmy~Q3GjLz);^53 zB9s-s91t12JUMw9VL+zd;}^?Z|LybLW{47@s%+u#90WpKy8Acm|Fiyo|APC=4&eUJ zV{;f*SK^!ksUpX;(Md=@MA2HZepp$9gjmECBgvn`WpCdrbB>He-cY{i%ar`?uF>ek zwcxNr;6df427A4x32Txf@-+pWsV@h;x~auj;VjFM^s*PO?M=55ZB-*;rZ`#noSdAn z>F*F@`7#~j`Sg9Icy>=*_&*!@_b>PMs*VRvf?D*Pu_B!sGjH`cVwlWPHr&C0yyrCJ zJ!2`g?b>grI}^%QY+KHAG(vY=Fgb64l-53wKFmcsfYzWa^92Nj+CZ$@1QCP4RGzMt z=IVfU!3W7o)8G8ErV&>KV;IlVKqoEPa|pFpMj*>0p<8DTAn?|Lz*Ot`2BZf%aJIcs zbhLSidW)$zRRw~Qp{DMBXq+8&S)BNn=PG$=S?B_p zBpYb80%p^K)iz2jb0H8ng0ttY#$frle&xP)>e+I}T${iSfcqnoG+RcaG!8qG54qlA| zL=Xx5H%shJ5eBjNfJkop(OJA{&pfo3d1? z1wEz<#V4d)?7?T6tfoq=PfVh3p^Yr8aKp66pr6HSNqcg(sX|tD>mr9%agauix+a%l zHN#w23UCH9+nyZkWY|T10vZnK(Mh^I(X~Z#L)*z2#?gvZ1EdJ| z&mZx02-QMSAA7iA*P&^|qmsO3dZY=?2ZiEu+$8w`_HG?iLmo(+_e+26y8@~jZ~A>{ zm5zaBqHB&$n*zr1^MeMUTNSgK(=ogU2z?(Zh9J3Moap0^KP|tXV!*$N*3#+1P9uJJ zINJtcT!vbmul?+zPL6(g1nRixUGYkf1hAP{-kWwslq`JBS=D*va#g3uF^)s;qlV3R zIVYIo$Z}6l9`Jg5);m<4O@lRtVndZCPop?)>O&KJy5tO$^o+X}hl=(Cuc?=*P;MH=X(HvTj4 z*(jm#BP()&yWSJ;*>24$^=3Gj2_83N2ZPM$bZ4Yq>B=CjOU~(VVygItfD4-8Bo0;X zbI+q#zq2x{Bu4 z-XOy1%d}o!*KUspIGunOYJm`*0dWX)GB|tld0thG4^v)?pJhX9SRgE446}R#{)v7uB2cF* zB7L^!)`%q^cE!iVeWynhN?}nDnzyH9spObB3_n`ZSPi_3Z7WCo)M7BTdU0($4{f?~ z50?RwIA4!wlD62`$gJaYgs6>~Zf+9xs91pRV?ROmlozFtChJ z>A@mIA_FXTU@>YP8`8X*glAV@e>d3*#wD; zkhgDbd!G9fqi#y0_xnj{VevhDY7v4lmT(HA6JAp>@Hu6_*0R3f%$#AVVkFbErUBfz4vEr~wcWs!pH^QVgxW)$YP z%$J;kuWTP3Y-6wM*o+@Pr4VqDYiQ-{G9THKARh(Gn`(wEweM>$vdA> zSBJT_S51nW3;3vaqAn@AA}Ct(3*O~J^Y`xz&r0(%F*>JMNo3gYv}lCN8EQZD5%ZtZOlYU1>(trP#Mfz z05Pk&#_~8G7%rQkA{~dR3zty01Mn_0b*G5M+EyQF*__45KFfc+WG=yLOq&Uz0IoGdNUO-Ye?VU&ew?$Qoh8w{yEi1s@!wwBa-M{ z$7_CmnfbBXj%Tx|NGS)Vm|Ela0YhhV(Kc;vLxnC-aQK=(Br&GFvpkBc6-6)GmB@$d z`I6TBm4fiPUOhSvrWd3m901aXL0%yTerp8rwzW);NQQ(3oyAiw_I!NM2CH+re?hx9+lYSa zhjf>`y9f%xT4rXI#4urJoA&%6hhi5}oYS3~ThTBb=AK;#c*~X-I-zdYUhDJ3*A8YY zN$?i50OWTE3B3C*2XR;liyBn+eYXN13LRJ25%L>|$j8z7cO-vj29Uml)qJeNUHcOM zu{t~qCq#E)&ndsP105BZPV6EJe(JkCODNXYaI0(E?`yX$)9Rm+V!7< zR1+XTV&ym=IujRHf4hg2yRWIYk3o!C_eT!kO-K^eX&GWh?RRGy|C7jCQS-;4O9!9` z7tecf>dGgotM~jgay8`VzJ0K}Pm=wPl=ybT07&q&j*X28X;(`7mI+r*{C9oB_ZI?7 zDS>3HXX5=*fA%%YqS@5wt=^v=Fl>+GJ!zR@RGjHr8diTLZ3jV$Gw1WZ3`H|mjlwYF zt_YZ|#T>)Hvzn8txUJH%K{^bS`;j#&Vt*o(`#3&^-Fazo7hB|b81^=LCF=Tn zkV_f^%FJU_kMWrN37R=pUSJ@TwBn##vPgM#?a&9i*VU`OgF#`B(#Jjk?#H43LG2cX zcvPbz$AYP)RXToJ2&A=&A38DkyqKW4GI9^8AkZE0Im$@!Olio)8^@jLsLR29G zi(^L#ZmeA?{Hml}y7|e{(AC;=kIKBw4Qal?Um};pH^hgBuKv_Rpil zGMwIH#Y5bBu4E#R9#h$c3;;wWyqVPp^J7&NA?K_Z@F}r30f}h2-yOPDw}3CH<+xV; zgx^S77ZiBYjT!>xA7e`pP0>h8V!fa~_FAKL(W1-16sgWqPROJ)x$Vnja79AI?`*cy z6MVsk6XIKM8rv_1zJm}g!I!D=6KJI4Y*QBiM6RADiMf^7$omoEUp8O54v0q>Zqep~A*)zin?9-zNTRNzf|;nlo0TDW~%Q8h71dgd!b&B z^licVv9)#V=!I|tbjUoPy^@SMBhmMD5WM>fvE$pDaapa6qUf^mY`5F{XR7)+6rvUL(lmXQT?`K2+5Ws0(nD{v44IzFJlYp00g3eK z1SFw~(Ht4&C~&scL$(lcksh`frh0cIUsr^)vI>YmyQje8*tOtpIdeF5w|W#k>`LIp zjN5M0oW^%Ko??^`!1lot-F;E{c;f~RF$)beyM#lh&jX{Y(?xz{Gr+Y+VC0(TUc>C> zx{_UBomqe`-RDt_Pi%CyoSk6#S&zMo9F@RB@$1ZMp_D?7=4vgjpyr5_i6m&}5Dmpw z%2;-Iiai**eu;q;muZ)=?yvM^y$FbsDn0_jXgcHwA3gmN=|d?sr`H~L5EvZBth%^_ zN(PzyR~n3^;_b_{f>lO;mjJag^0;i`Y>4Y^3}tTwDCKAxHR7 z3pi{&Chab5X$`U((hHNLcRqzuGWTTxwY}VdUcc_LD@*tsX>{xRxt28lP>NBtk_xpt zwDlb^Gph^dC3v+uUdFQ%CBI;)eG;Iy3OPx3AS3X_e&E%2($ks0x`J0%o z%Y8ws!7F<|UcW%SWmjMAVna>S@ zJf@4cA#2VLAXiP`)4bOd%9zua9z)LSQ6h&M;!P{^QM}G~>U*EIHSOE)y^K#yeiqH_ zd+9sRtdsm4sHt)MIU;!5&oHdnZPcF<>9CL4+#Y~iV=c?hm;B(MW; z7Xjg7ODddd_@!*zoWzkh*kb%m!3Yjk7*MQQAeO!6SAJ)h&wgfJ)Ae{U4ggUJT^CpR z_nFGZ&2MW$v~jt&)QT%LbV?m_AX&d7GUaOCb}k}M=$m2;hZdf)%bfsY@5HFp zp8o@s_Gy9OoLtAg@;L@nd~;9n|Hgo=TG(jhBzqPI-cn;KO9k}?ea4U^LamHdBtx#-L&nVvKZ`8($ zcQEaa(V)(VfW)cygrRtUmAr5bgW^4U?zd6ILj`t>rH`Lce`_Uw{NMyBB<3HR_#DC+@DoopD*6T3 z!tsv&O(0T&p}8e0v^b5c_(4FVNUgShD$RBN z3oAk`N(9k9(UU`mx|0S%R07K4)ho8e)G6)|m%?!Ga10yF5iMIu#TLOz+MC!Fc9BfF zg_vk3`eI_|t898d1e4kh5!XK<-*JiQh@0u1XG$XsAKRq{IUGn>q!YgAfUQxY@Fq z8EI@QfL$;^3!nYox=BIr<(Smq2lWunX@>JY-rqz}r7I9rnn+&p$M*}VSzQFvLXR!t zy064z(!nyvtsi#r>OJ#a=Q))mM$`P;Y-6vdwlWlB(><%WjfYjm6pw)@+A}G2$@lz9 z12A6{jt}>wNKgci42tE9S`!7IdI&#=`X-%W=WxrkX<9LZb7gD^@fV3}J`u<)<5uhk ze9cjqX`AAAegh!2BBJRgxS5ZM=N_uJzJ$~XvfYg_J3%HGC*%=L=twW%aEw4&J4Z+f z)|)&tHMns7PqAbCgPl}EOC9M+ATv{?6esN*MC!eg?D(Qc;m7ObsO);XDK#O0>$!<| zmju*qUG_L^ZC;-^ehlGi?sR*K;m1cybawZe!^yY}vmXZaI1poAIvEuRzRaDRt`sh5 z+P&825ufI2evb}TLUbgvO3=yJV}RRE4?KSkSG8dkN2d5|7cBtG@gVdqUi zY@DVTpH94-^PX|@H9VuM8W|dCIRQWD}-;xcaxnp{hGfa*v zT_t3EBzdwm7aSTelwDqaclC{>Tj}GF>GeF`GkeVCqa!^G3nc@TNbRA57i7?O{LjD{ zekWi6vZZhB;OI3hX|0~nmigt8>H)~jWK%yNf=XAVrtZU34kB1>8G#}@ z>6v)I_tXIR(W9MB-3`p3JHWWQp(%8wB!3fV<-DeLf(=_Ksa5wIx`nqjP^a{0E)`;x5(l z$ZN&3mJ$v_y%{`@KRK=i&7~Q|@S5(<*S*is#x3{!9^iI0Nx)7LA_Q))V^@VH*yNdt z;q@sBOW&M|S3k|LYa5S#q`rn4?wz150X(QF&p-BXC&9MyPt{_!IPXqH>E@5mycbo7 zKU<7d#&x}Rdp6JG)JD8_mMV@h#PaHMP*N~AqVx-=b0qe60%kxS9kWP9rEY1|Sd|;L zy*}Lvb@dC&a3Xv!?QFH1RhepG=wrsKUvLW2a(P9VCM~d6OVOZthNF5va=P$ z8+8Lv6MZw5u=-W{_6J6&Pc_-q9PIAO0ny9NC}6Y4Pi`NfB42~tsc)|vn)By;3J2jU z4+IV>T{lpNvCl9<8CK`}E$KTye|0Ae0{z}FOuvQi@3#OqT3;OEmFP046hTVXwF?AB z2S~lTdei%?U}eQxK6G=cGJwZ)QgPY;gI}Gmhk*NqY|xM$WF7P*mNZQ z>tKwsQ-h~2Vo!OoAms)qp(_9;qH#r<hY5$^PFs!IO|%WmPSqzWp}FX z^cglz93~nRpL_(vr72yI7%2yg%k4(0FK?ryp20;dS8$aSy5=!$9jmF{+LtRi9tKIY zLfV#MN!hDSsZGNI$6&@J#q{IH0^#;TJOf)`z5w0={&i8@-?r^-k!bpGrOBT~!H zE#IUZV6|Uj3T?PlIceIR%n4xJO*{b?=U4cOkZ7j^!_f_^VPHbj%u>YiZNrcChI<4k zK%=-KnN%XEzvh3=0B-KDFikyEjj|TOO{ppjYpwK6bW0@0EP3aXyfT>$vt}`ocJ731x6_SXod4y#&FoL z^=ABu5oD;^LP*;3oU?A8r&9hfzV;sT-6>4p)%SHb(B4R&8tZ#IUTK%XDqB(hDTI6@ z=~^AS1GHH03kHd#1+jQDGZ-XJI26H_k-+@4T8{S)FJ;DjSEPB)6gH&C7;VxGkYqMl z8LOI_o6EYF$%)q)=F8;iTx-U3NrcOeQ-uBI7i_9^b9^CSl4o>Fc4R^31o+VI(W&Gm z=zkz4Huj}?TA920MY>^y2wxFS1Ic#fpPU(kZdsr?Di;qsbN!ob6UsCZK@~3$HsOuV z9SC&1T(v+oZVlNRAJ{}_9)|h+2wbV-#hX%rQWJEZJ?g@MyIu!e0V0DuXruA`rWH<2HC!|y%|$A1BMYy z@@&)@$8wlb#UkYnt)G+=AcXwdBxXhO6I-!InsE{W<)24)Eg%T*S9|pI$L-QkvzNY1 zm5;ua*uHhQh*L}%tI|xBZ4tD0adV5gfFqb9Y+D^N&u^n2t3bk@JAe;cxAp`}yvKuF z;m00GJ@|&$SK+-!KiE#P`ZK1{^mYjF3ipjf}T!EU^dQ@PuqDC9wXzR7)cs#_Q0MP>t*sU}8) zecB8esJ5pAt7Tuu0$Z@TU9NHym(^UC4AVC?_~g68SKXdQvu&v88AGZd;ZVE{UPZ6i z{3eNr5&HR*6`&!J5$oU%t(KqX)~w7vgg5Y=x$=DT%(~>i7%oAdF!Z&0{BG~Pb#aiA zk(u6o@*xl%_tLr+t^T<{VG5C+Zu{Ljqthgb)~-f0W?JS{@mSgor?Fr&TKV>%f)r`} zWffgM0;k0wy>#}>Tf(5c7Qvu^hbRgIyN5W6P;{iYpe1ma$_c_B*JWt@rdEwvvwJg@ zFPq$l-N|R*SkcVsJNxqUeP78gyVZc6+quT9TN9(z_bYbyIyH?h8jWLdmt7FVQ|*yf zDXSJ0En3P-F<$t}`R_PbL%*yT!WQ|T>KPgSv9TS=*A#)*PNdWWe5Yq_jOQ<*1a9A? zfjDEjb1BF!ZCCfv>TRs7|QxDy45R*{h3)C8UGMh=2x2-3v?1>x{B^_En;?+jaBg4iJ{58uKY!`_rss{ z8&w|x!-Y2r*QT@1$z7{r`jNBc*u9)n%7Tf88z0T?N@EdQ7D{hR8_lMlnTEbwphV?# zIGqoK0(G2g1FVed*^yA0pP%W%R_JP|epS!+OF~3A5gKeJO7ZHsx>Cun0#ii41bxb@ zCoC1;FbHLOVDmxaLsDC`0H0)lcFCa&vFKaE+ud;-PX7~R^UH?8>a-Z52Yyr2rMnByKXv$DqbtcU4>>5jf0 z{1tNVI3k+lt}GgMOn+5Ds>c-J7q4Pwe+XWGP66HLo=XHJm=VhZ`436j5?K3lbk#%5 z0HW}kt4{(}eAII7PrIP&e6+o3on3~m&37imeV-+=Wf6}}v}+aR@r=P3zedZBYf>R> zsoLD`!aXT+$&FvLw8YNIX}{x;(kr*#h)h$=?-wQPl&E?t8_5){nyd2=D_qzLGKyq5 zVZwy&o0q*;rwT8#60h-jrcUk!aj-k)13+gW_r^2Nx@uR{LeA0R#?xz)c#k2=yWMw-Hk&j z7r{5s1urJ%mJ;Z%Y*{sOzaFc@dhj2dK+i6WvaI*Mq9_gGMCUzxZd93CmDN#RxIp?k zviTVXtJF0L=9g%ABtb8*8x*yCNTvr5!2xB{!%y@AKdtou1i>U^(vq4rkUu?weJN}=1&r)VBJP>(=iD_ZmVY3?adEFj5^wQ zk^5Wyx~9UEg)!M(@`X)F4+*T!q2leaXjf5R2xq^CQ*O1EL=iru9Ny~8 z{EN5hDZOkVq+6kmc)67?ceH7<+sahwRaGz?c(9+tSg!E`M7{wVJJSyl}SqaPc2 zV!l5BT3y|LU_>AAYOTUB?8d{N~TNz$Uj*O)Q8BiHt}(0QzWDSGpB zJe(Yxy!Ia~omhX5R?HZ@RXm1(7i1!z6phZkbWmI)N+QBZ!(+nN3lt@@G5X|Bc%c*~ z{F>CTXu$z|%leFWI=V>j#2u73_f#cc8lDH?TpRXdLv%*59{1^u24T|v8%xYZNkWu! z=Q~5XpoYd2=~Hp^Ov=NLd8R~x{;`?w{2QDB;m6cjSD#o8m&koyTB`<(P!ibrFKmn| z{C#FWDy~mIa66oMJT=oNQvG@&qAz<`y&}P&1Q_u~+=;1qIQ-(1lcx9mf#vuht&e4e zal{3^+K>uGox)>oGx>|cw*yE)-LbmrhF7=aDpA)u03DO=&MZ|s4&H!&@*jIl_;2zFhIQ%zuWOP@dHB_1USdz$rFIOx((0QN1A7&u*%)S z(FxZ;YsV&K_vR(9YKUUd%9)Bd1rqT)%#8v3#9a{#PW=4J7zYHHzXo66Ewnu-Xom7- zYlH3v&;RxoNJDzrstn?K8LDb>j$P4h@l+@Gd|&X2?7YGu%M`~QkgPL8g>*d6ZG=ZkezB7GZyG~l4w6VJQ2}%^u1%bqD{;!iW$><sab_4cVuYfiu z`4?L$M+VsFiz{*M(aCtIIQ<~D1}TQ!NS)Fxf^+RRJvu1I z{m-DuI+7%vX$L;v)`P)+urnwnh*e>W;i+Ez;-9FTD)X_QYW?xb*c9vT{?FS_IAWhq zc%azKt&kC;ExIbHS5>=6`i9cHAU@|cN8O;{sHZ7XmyH#XDi`%V?Y+~F1`KA3?N-*| zJGs9Wd~NfaqMaCp(d}42;-|f{%nM(QnT_;x znn^VaXXknf)EF^iR-dJ>>;08xQF#7BFH3n(I+nESO7NNi6`?)Pvb{XR2C}k@m{+1r zsqg3U+O9PSA@m@|OO3Xm69vkcTEzI9YlvY$FwQa`8u1KF$<^x0$VdyU3eK1wEtk2p zHs7lSr2h|%c1;1~spV`_je&H~?YT4J46Xhog%cc z6eCLLv%Q~bjfikP*a5Qp8xF4gXVmAEUBG#wv5xF0fu5*Rw3}l(EU4q$s;uX|Z)pGdQA>5w_&MT>I)Ovv>2^HswQ(dG0g=rOFHzveEc8C@$eEA)$?ym%2!ZaO6G z{e0f?w9sGQgQt%O#5|6HmG8wc1#DHePrP!W9=3Qwj;^3rt5XRJMAP;J##+DSSXsSl z^76GOjRD5HLyB3skbTE=I)DYC<{7+BTJ+dmb{H1}Be9A}a5lHMzt@|g5AJMjxzb(Q z`PrSU53GL8pEm(k)_lGSrMYs3A|>->&>_ebSHpyoc1nKRSy?G)3L+eU&+EW7FxcHU z3jQ0Z1DUz;k6x{$CKcfCRVmb!p#JLkz5GJ<0 z<5GC}1*()*FggRvX?OoyiNbh@gOb{&7`aIbZZu4TzHhD&OyN0uPTV>TxIL24u)g!= zX4P6qB@rs(3#3N{`jl^9?W`s)_55yfHK^lIo6jr!;R4ut0ojlFEv2Kj$Wp*nuAHUOO7+9T z!=gHJF}dk^F;V#*U`>geKpQ{l=JA{FFcxzS)Jy=p*|_{+nEdn1WD(T~S{=G8A0Vsz z?#Cl&i@)Wn)DuByzdQqpa1nL$ml>b(* z8(Vre3j4P-5dbK;*2QvmTSPh;lRlN8XI*^{ROklIc%=s1E?&Rj+p`a6dVY!6NjUhKY5l zkctRPtQ7;2ma#)q-wNpX2g(J&S{>-nI>9$k8Y*eP>h3Cq5bC^d9wY_Ut#Cy&XiAJ% z5jTI>ZvZe zyWxcFjhDeUtGSH7E)I_bS??p)|B)n`+|7mV%DY>ra=eW2KRPPdV=rNp-G%?msrAuD z`8N_HBD}i|k><+h+ixSF`G*+fP~W-GD2!$x@Oa%p z2t>%6Oj`>Wou`3Q$0+2lu#yynEYAThlW&sH=BLcajA2k?Ae4B2DkrBvaX|XKyJWy) z4cP9R4D z;gY5tK(@OkYy9`sUOSboh7T7RQ-LxArUP_Hg8u3M@~3=uKPjNB4uz9bra-^dpdOuL zJ`2r?8wVXd7+~R+Q!CjRc$2{%SO|mnFbx>I@$}69F6RvPpgJ`numSM!DHu00AcI?5 zz448D9$=L9PL=_e4-ths9c*V8T3nbDNA31Q?x?gkE`G47H(C6zBmFfm64oD`^r`D2 zWS!}MG|k9!G^Cl3_jv+K%lVRxPB&uwGUf37fBBB_LPG^4mV?if1~fFE{=T=7c`J|* zKUfv%V5~U3qmGJR_F+foqQ4Ut&UgCD~ljG;!t$B+4W zW4I+LR3(K8Sla}bFW;YIrO*<^YI*uC$j?n!oL<1x zfB)0Naj-i@q-(%$4}&$1?DyYmq3kN$ZPO|UbEl$v{(cbLUdX1p3Ba2s(EjLvnHdTq z!2ipiy2GGygbKR{(wrF>(#Hi4@gD&>7d)82aWH*oVo&{hDOsT*&tEYfcovQ>F0C-X zBn5K#f1-oW6QFH=0FTO|T${V@$Ir_h*OF%a5n9=;1Y~8A0>%2jtn+nfm!-h4_5zz3 zX+)Iy|1OHmcQ6l4Wvs?^F>CmfQ;1N}4J2GF+oc1Kj=hXkw*zOG-rcxo|J!#&v)@B}PoWVU3Pk1T-!a&M_Zq0z{(W7oY_@U6lmESU zg}$AtI$fqo%=$7TklY9+;vwq_!odG+s{rSS39_n7jwS#AHvPVj39ILj{Yyb$C}Kr_ zS+9<0#$U6JIg;yj_V+5#?|J%JHRv}mJm>`0y6yTWuS)x-O9%p!*)>E*r za<)B4|LO#1R~ZGe%Pg<__g!`Zwyz*eC*E6iKVdw39{ZnogLE^A7*r(@Ei-h-9ljf1 zVPi!vfxI*dEHNa@ZDz3ZrKsS76A^a!;V7`f|J&8#IU>F}dY#~1CJU!jTtuC&`w&wfTFcQC>FoMq5HsPYYFhL~qT-VE^}_nT~7_@8w-+yHAEX;MS3!963Xzbkvw%&%ETQiJaz*=Oy?cM1AXW@W zs2m7_W`Hy}Tckqrh*(s;J>3h zc~nH!0eCMazfB0=2IPE*2>nxkH!uN{#$o`Qjs!I-5kKEaSwt#|csptly}FH((EHBYaei@;5hk^x6R_T%N3u}pX3zi?Z+(Bu6ayO@Nb@<( z@O-?!tENce8-pJ|@DRu5`$@;pShdS2c2(XBp-9DYTRV>Kxe`!#-a?#+%JYmfRZ04V~de^xOupx23J!iHereLvIw zP93T+D#(FDAt#rCL)ly&%ca`1pJlMfIBd4>IiwfP>Xc-kF6lnUCzi=~X#-(%rbaZq z3Tz8zw_;QU5>b_gA}2}=^}SfHSihl4DIRT3HW;K~AeF*SsA152i`ynxSP1!gw8 ze3LnZcD&;1FNC}a6Btq*R>xRmKkt5+;(66+%Kt{B1b8d_wblahTmP9{cR(6F>#}sN zLDriXmyDNVKzJUC(JKbnmeJ6~Js0TlLJ1DUrx0*#jt`wM1y(1jHfg;Fa00 zD_;ty;lU~+E}+ox3Rb7v^S@j9iwSGbi7W;Xz3cH$9OKtEVjS@PO69q)!)sp6r;B`n zCtJ7i*+$Xp=Q&({Z(`(JtG>;AfogMWeZ5DN;GHR+5P2mu8e72NV|v)KR;`<|=zRW_ zq-VhRF~^LFJRR10<0bfM*nv6&Z0MJYZc!NAs3K=(dQ2#YTh_^ZV-^@tX3k@E;~cJ~#_snX?r5uFf@ zq%eR3KhUxAJICcesN%?;GhjA*Ma0&f3q#sAJM5a;r(0NV&LAF`8;>0>cJX8CbY3_PL5sObT$nBuCy!U~ zSIdx0FS*KQN0CqH#wHS5oyQPuUv+Z53HbXdb!wx(Z}u@{oaUe2LYKQYW9xW7(IYtZ zm(K>?t^@6NotW>oqBVlkD_Da+k@!p>OLwsKScn=&_pQ#gwd~f5k{TKz%H~g&qcH;8R4+r1p}VzD?Py-sNyg2 znDfB<$m*a{#wS=O=CH~>DsGPV-!qCNB&R*Ulu@k{L*$I@u`EOw`9ps4rMbv2ru$iM zXQRbtY)5eM@i{1M5VO`RU|4;_r?8}}ibJ#`YAm{=;zev!A)bd1O-~4%inML?l-G^` z;MgxXd$ALfDE4#z@yTbtwndor@&w9tUudhgKZqos*~sgc2^))6s!@ymcL^n!=t)5$ zA1M}@(i_2o7mRV^cvn-9 zzgb=$G~c#1L~U;MPjGl{Fu_ilb)J&Y6$;(NQDj=>nTt`a&tDva%aOU=tIJ3K%vUkiNYfS{Wxyry}*q3kkRX9 z$0KGJh!NiCl$iLJyebZe6E}Aeb5)@MG@$ilHaah@p*F|_L9owMr(HJ(rkWO#KC$Sv zb~5S$g4cU@+NR7_=5iM|4x9UOQCFXW#6>=K3~Jaz9j z@CF{Y`~>JJ0zzI3k|TlCiCrI?D)1HUNH1TnWJ?io9L9u`S3)wdZkpkAkb3}N-a~ze zh?;{}$$iW|VR+A`!loFb|NBP~+~Z1>LdogPV-I@H!c{Z!*v^K7q*>8+kkY%idG;TN zH^73L0hbBnFppu@su!qcm-{UDxjS44l=Q_d<)o~rOnPLQvA~<*iy?S^qCdC}4}12& zHuc=!j~gka&a2?iZ-dW|;0V9*c?Hi|Y}!1d?i*VjNG^cr;-Ihev>BZQmw{+ypvJY5 zl9NoII>pNJ)y=7lVykNeuQ5?2pD3ltk}^|pg5>ejM&0Z4Zxp77WMjGWmxhXgn%{2f zJ!oW1l@di;&7M5NX{+?_cikP16p@_YHJ1{*o}fO^eg!}+ zCCw2$R!%Exh}5Ah4|(^%mE+DFt=0fqRyD_b+}Fy#eUP+$kgM5lxv{PI^EQeFQ4Ch{%Au3&)&~5)vt5kNWYFm>HMv?wy((50%rU zsqQfL@RcvjQODaad~W|l|=d?D2XwNnBMnw zDY*_H{@9d9a8EY#Jv30Fgadh=)5y4t?n=KoZNk&9p;-p4NL~Y?)@*pByY(hS;~ zCvyvi_I76CnQY5Kb|dOJV6R%oj~cm=u(et;8)#57)76pxUH8q;+V+Iv#lX5wt^Q&5 zj)5)Yv^T^x+|(iY@X&4l(6_;V3jc$tQ-~?v?!h`ekM&AMZ25Y+c9j#0DkYP(S4t?D zKhxYxs|podOb`9_x%p)#9~JS~Tt^vWY#1Y9nf*b*H9;4#;|e=vmN9-t;58JN$Cg?D zkZMwWK;!aiB$U;ZQYwtUnvs=Hdd_W5*n?W;?G5RQp@T6z>hYpzvB5_ z)@1db&^zKk5s!UWsq?8*%VE?hXP6X`RyC{!k%d zqfQbGa64Q$*KS3he-_CQt7Je`wto1f@-sQ=VO6mY!9lPG55z6yE{?g5B(*=`#q%ar z-P!x$iF0-E9W2Ow`CGn!k>1&Zxs74;)=(Dj{@I~MvEC0dLfmH$!s%BvjBV(Eqc z+*JZB#HANbntLqTWq1qE5z9`EUV!eYiqgP0qZPC=)G&4dzhcFePY|rAq{DAgg2O=n zk04?O)nEX}k*!H}=X$Ff&{@^Mw;MEl3hv|d5)1o~&Mn@3+S0(GIf(*YMsiBG@t8A$ zmQI4fr>KdPF#E#F-5}XNp!6e56!N?=+(jrcXHMLu+yq7OtM(!D=&5qD9fW^qxka^= zA*SpqT}AHVOgbmgcFEe{C;gjI-vXp5dqa- zS($47S2#?=n?PZ-(ExA~!OcOvL0x;e?vKEEkT){{a|HaJdYZxFnFg`uK_EK{duY0; z=`{t=|Du(J&YGXD?QC23CoH0y?@y3U7u0_`%ZQ5oJa|DvaEt^{pcxG5)VN;;LdQi4 zN)@{{L|_e~PcIT6J2T^@Ky8v{|7#Uc=VmZsn}Ja~Q4mU{AzRjmf2bkd-yJFYBdMhb zici2D2vNiX5-bh}E$zvdv2qWsJK65Le8f^8BtwGzLKSa_aFr${g=0!_%}day`j3dD zXfr5DQh&G^Dw$|UcOx!^Kx9GEM(Q5*$sAW~`N_x8q3l*2)7vDrzI0h&o3jlxJx!oU#SW-&&Z?k^LybJW+3yCCy>4-Z3K0B8xH?3Avn|$$lMRtzQZUkzPVUiGpJ(aBh_+sMlEXg z3>X1u&`9I~n<2Vz>KW>&qF5(z?Uf&ql9!5S)-LP9;r&TL$JKWp<5O2?SXcPKFo^8K zj0pUpaL*6CeEY`IhR6YlW~taL02gu1;Z|x|wFfJFE{oZvS=DP!KdVHX#_0j?h0@&vDe z5?4(1hQG&oducxMxy=0q@-6j~+^iPcPJwsa{DW%KBN!y8lE*2UyWVudqq=2>bw>PN0_$4JU|&^ec}rUWdOrb%)@6rYWQn z^7j7u4ORg0jBxi3ba98-RsE{R8ntoAm1Hw?FZn^1Qv=vg`Q@7ue)&H#OJvIK@sXQ; z%fKJURgM{d*(JMiX4ZPcX?*s!75N2EJme+O3gVrtgC5mZrLwxkIAsb)WhhnO{O@x{8I=^FH>krehs`Azj6$2wC5fuXE zK_6z#BC8)Vq|ZVhO~&tZiQ?*SF>^##+ArHY@DB|oh+2qhs)sNfC;NP@@zUrF!HbqI z)wJY&_aWo-@+_$>0aYW0C(76~-fGm}!i_VSzF6@$3Ps1ZP^jKtf@*2RR=_i3P6~Cw zNv9k(YHVA0ms-fp`>&otidUGURI&g*qYEp~8YnYyiZfC@|PFGrn7zxMzjtth#x zn%@i>RrNFv%>5#$9qbdqL0LO9%LUIMo97D_dZ??tW4@4Brad)b^YF?Iz z$|Qq`#Fg%V$VGqKe7t59LvBkSCaA91V8|WSHl)yTl$s6x7z%x8WD;GmpxAx%Hm6j5ni?Q_qSG%_Jbra9mt7SL_eb($fLTFyA0wu9JaAVjYa z`xrGPmyk&I-$-ORUL%@yUsJ?d5kPOBXo`AE`sftnCceXgNi(1Wclk+1-p8s zV&7t}j5t(OYKmdoqfQR@%#gjMANN7>r_sq>=JY-rQ;aG&$tqT*I6jc;(;MhvL`V|c z(B#dok?Ep;v65BTQ9u>MUH?@;zr>jdjD;)#N-Df?{dw1NaYlgf?Y=uLBPt{*DX|5s zkR;3==cKl@psCHK6`U_NOisxci@rqnCSUA5n_;oo!v`K*V~)wKIeqI-nD_2?hi(Iu zIRL=n+?z3|AG`m7+g{PL+aP;9Z%Hh*8Fnj4UDM z)X1BCp&M5GG%Pf#jpBNWGWmVv3Pyej4dIc8(48M`6rEC7?jvw~$jGvAZ~KTgBA;F= zL-vWU4#v3E4n28%a}YI0v6)tD6^(9jxEAXDHTAR)3*&i@Ukrx|$xIZhj`^pTPHR7D z(7KV6z~ieENN4>9{$QH_ppfR)=f3orML$orr^Vj7w?oj>XN`)cly1xPgSVFobk`H| zH5zF*YV87@O@;!yWG_0zW1`>-8QtL6lR}OQWbspcM)#J_qt+~1P4x4Q77K6CW)hc4 ziTFzAnR>&CIwZoFrgBq-_Ty9aM3I-V-&9GG*{1E{&?2bQwsTb_bikv1G}KVP(RjX; zjh_ZRVl6|zH#BxAS-G|H+3R*-#KumCee1a!%)l zBH|?pznvM-3!U!@SLO({1^x3rQy*L+;uwPB#WuXts8b8H8x=Yu>%w+b?9&=@2aIcF zEdzW?MES#o8P_sI->KG*UXpn=YyE|64lT2QfySix+d~oM{FBAp+L5=xzlbmCk3&Y; z->4FBhn(XTkxQeZb?+7=2W6&O)QKNV3k0XPUulf&&8Ks$iQ|YzvHZUpMP(^4Qg+B_p8LItNC!GFf`Hex^wHv-P`6M}2F!F07d)z>kUMEt$Xc&uh*@P9=EZ=4wzv&ry|>qccso#=`9dx@S&^zFj#FiKDVead-y;9cf=o^+9X zyq|fePsd-AmMrI|3!w9MG6rfoj+5s~57oSE-Nko|bVZiwGF)+sxo!Y&*u!HS-P^!e zf{_zH#?3#ecKAitro(fihX#bml4W=C?&bR(N`%f}6q)vp&1Szf2kBTo;7}5BLZmT6 zJBlt(B^Y?k#9TIwEi3Hq@>44d?%hSB2(CdBDdZ4!$*&E7glWHtkC^$MQJNQTr#C!9 zycl#jH@68(i!W=HM+7PghkzKE4~` zfiu}dDL*OAoxfKvU^(#k9!Y88)%e*jD_yh+u+8?FC^+a8ksoLmy6DJ@b$Zm`p`&_E zWnR-SHtF{(vm1Gm7*-nbHKq5%GfW%!W^L%?+!|^v$3dP(E(Q^+&In7E{fCF$KN-B! za;at#nkYGr2(dH?V2B;u$}b54fmgpb@>6cl^>p$lhOyQaknl0-DOgr--FGnAlVPo% z#zFH-1)qUlkn^wLU?d^o_!pHYP=MYZi!u0Ivw4jSV)V1%YibD>z)U2?xIFFvSB;H^ zo)UCMgfKC>qWeo2@I^TL1$m|q@IyH)nLI6Vh1op9{g>8p0 ziEY$o0}|%^X?}X86lI7qrD$&Z^K^VGWm6-yFL`J9wb#UE<+w(lwsbwkZ68s_{eFbr z{;2WY*uFpVi}ShoQl|fUC3if@A36^PybLo3%iZrFoVvEyODP06N)hTg%%=}kAayvH z^tpbc5ZVgH>hYI6PL$~^9I;BFC5*)Wa=7p@H)y^_O#3+ub{{P4yt|lU2Q?3~`|+VY3`uS3 zBRA)=Y9v-4fN`b5~U*R~C;h9D1k z{%r@%T>zZ*xyGLt9-Q;_yZmz>o)+5*C-u6-SJ^;}I8Aa>VWTE#rh$Gcju&L^&Iy{R z=Vvj9h<~ie~WA`+*iqC-B5l$$2#2 zu#+4L+;O+7WVv0k#J*j3ksPywdkT9Px@9Bn-A9S*j4OUNPEQzK&cYbz2{B!OKBh=w zx&w->Uc2p1UN8l>jOw+ftL4sskZQPyWr(>4CA?X_P|qr!1oO7UCex0tz&rZ=y;I{@kpI- zKd2uSoiYFiy9M+Ri=b9;NSn!oY<@ibYfDqrLgvRMD1nM$Ncm{I%L-3aVmZ)`qrRri zNqU>OE9o;W9KeL*q>qNv%^};sMkrh2WKC8i%>{FC&PF{5Hfh)`&=xK?#pRwj(|@0N zi&Z1<(S0qDRjn*!E2sJqf@g4KF;w}3e5+N3ovyHswsl;UYa{y7kLPQV%9+ez#QMQ< z_jlA5Gr~*yr5_YpjuF5jOEFO-M~?TA34jJHe5=5McGz-^&SbowWwrB@_v8F-6n;JKVcL6~u0W^)z-Y;?JDoYOV0I>MZ z*9U%q-qq*6WNdwXHqh1f4V7B1?o)d*I(?`n-P!>w^ansm%7Phxoc2o^kl?+0X!YqF zi@%bf!hWNt9&rw?-@Afj1>SjT2c%DTO*HT$IL?))xx%y|1X}o_crcz>d*$;flG5<) zleS;^kb1g2*(D#tcqiJW4OQKg%MA=1YKBk~l3hK@+GQ4H=QCO!q;TW|UGU)Q&(pQ`AO=)HYOO-yNeA!rzEMDGZ{r z_*yF#H60jR=!*OdPZXsbaaq zvm&1Mqo{^9w-BhdeY2NRaRpZ;9r=ux{)?ak*(N<3>O1KtmNt~deX>(~@@y@90*8)+ zSiM->`WG8VF`90_v_0%vA5O<#6M3${)qZi65COEVDhw9N(r^=F1=8F`PEPG=g38b_ zKt-&+Uw#W7>|r~8A5$E;4V-mOUmT4#@dYWfBUH4+Xv34L4T=PO^r3_AhP%e{b*D)u z$_f7agMaeCHY&U#jQjE;DuXtm(k%w3ScG!rDKfwWq<*Zb=@7OF>gUu%G;1~tp&oF^ z+Q^vK0!l}>g7~k{jqppQE%9M=Lzip->hQ&exSX)ALx2Gbrz6lZ8i^XDy#5=)aKr(`n(&HWw0?mSb%@@&PEIU2-MJPIMhg=nAh2!|Sz2OB9#!izxn( zB_mJ?wD6$E_KEbL)C-cw;imz(KpPJxv8&~FR5@riBv7+;o1VIKRw^`w7Ey zn^LL8Rrh_Je+L`(RHMwDX%uK;hcy_ug)fS3|Ot+{%b@)mGzmrXn95!)`=KZWT zeyO|q%qH$VC$4!N6}r*3q1gl$1kYQnHm4(|{ZsrSEnLLwCHsxsdyj~}sxZr`rzLv_ z9eY=@x+L7dXum-2NFhVv+!=+gQc3-)=$a;J4jdsE63exj*fNd6RjSmJnq#9rbhiKV(-I|bfaWz7Y%7LehiNQ zt2Q=AQ1Mfp=qR1;&cLUc1$KQ7mbh~I*$alkS=1$J>&iwZvh)%yq4!4$FX^wm8osU} zGX?Hm%WmCywe^Rrq$kpb7IEdS;|46G&gVU+A+?lv)P}{p_ad0?9}t8hSOawp3U0RI zZ|Vg19yrY_hx_h9M!>t46dBTUl5b~euov?Z%TmD(^)XBJrgHCxo)+F-w@22JHW+TDcFNX^zzdWk;WgHLq&%Cm0Z{socpLq zGxUe!t3q!QnJ&}O(P70YV%!}u=5t!RdAqG(g&23wINzjTIFl5`z|#pFtFzbK7) z3eB9RW=^#rO-E5}n)lmn<%ou`i7+9xw0H6D1CM=plrpMms|7gv?tkUQ&>mg%t(Fu~ zk?ANb>^d4-H`XsOnXjUb8G3&LHd@~?WcA05jcfGCS#pM6upe9w9*HFCbqh}vpAvc^ zvSox(N<9X-(NDc}Chb6CeQ5^v`-jC2>E1>10k{G)Ba5X!F6H26asgtds7jmLl(wy) zwpI{Od_D7N)vhVuS>}!Jg*0x}G;{l;uVtTeDN*NKRt*sc=ZjXEqqkq0A(Qbk4J|A3nJM;bf3!$86dJ5z_4G(dnFvGO!o}$^ZqAdiUQdJj-w2LL1$OE8BYHN0ruFgZ`>i@a-e)LjVur;c28?6VZ&(@^3tA?9}FY-KoDzE z=x|e!qZ5mcV~d}8_`-}I?ha=s$3NZo&X(kK#P`owiy1RE7k@*0Y_0a|2L z7XjBm2F9OISVf7qjK)~mthyLpJgqM@BWHTFLxdsVdr!Ygi7m|IX?6oi;|UryC5>i; zG5B0b(&ZLj`s(Vdsee&sn~_~Tx_;8H#68$C3hlmqaY4$K3~uhNlZ}(Nzf`c2UB~x7 zv_sI17C}&ww(xve!9R7|?scRWuyjM^Iy%joY7a9XJE<^C_9*0gL`_E-8ephwIELS) z(I5X&lTCh&3xzBvRez?%sL!pa1+!hJ=ulVi<7(AL(6Er zT=coAIF1BJ4u>ivSG8(O-q{ON5Tc)ALbrnE&;Eg*DA8Pp75)FBJl2)il3&wxqN$jG}9{Y5GdA7@ivK8>8*k zGNa3#e0fb`z=GmUE5dGjk4xaiZ&(5?Txc8PK6S|a;~RYJ@6W!n5Uuk;E26G~j>d{& zx)CltXe`E;pgOlZ@Lb4CkrY38X`-N}@8*0ka)X0PY3d*msSYpv&($GRy=ps|y`z(y zV~+(BWm*db-!8SKcza1xp7G?y(1z)_+n_3|_#6!xIe9)woN}%p@7f3-2g46JgYVGW zeFqaM_y(je$Wd}6CpO(7hl_uLg8G39$vo36HrJm1DXBt5CQ`o`4z9Ca8o26jP^Qwl zL&p}@bB5t1Qc6>x!YBmmlUL#@cFuc9rs=z*`AP={_P#D1wI4RO?-ba1%4~YiNJ6aX z93_IQ{I|Q2%floZf8sBgUYi<@0(2p+XU~V~2^=wI2@=mO<#jzwUxm)JPwRUtqIK6h8*v*BV76?DiQH zcRU2kJGxB(arB>UfI)@F@`U~t|cOMXN5 zr#~7@gnIA}7Q9(#YIL}jB0U-f9-SwMzbekUL8bl#kcObb$X}(9f9qQK!W5iVxd-v! zXna?MJ<zm1Dis z7Z{K-7U$?T`mCR38bk1CGwdN$$QC>Y(4}78`Rod-g?;2*sPz zEZT{G+VR?-C-jvTuN9)x0JRMJV0WS$AVVamNDZoQatumD78%>-U`l&9lpB>Bb^1;A zDsWIknW`sWT{Zibq2%IvJ&}}aNNDVG>j7u5$4?HlP)Q^Z$^Q72Wb)_wm4zwdG45v| zKR~QB6%AO8JQ_gYhJc3H%6tYzq8TJ&`Osn4fHFMU3$e0rSl9#2#0ZKI1A@!IL|@Z{ z1N;~z%9*4S{MU1?Sp0C6w-G3*y&-_w1bM9pfLOb|!9L*DoztXOLsSV&`~|8=+0YDj&OPKB6>ivivRO^7)xNczI& z^RCC~=zunw5k{Ya?ZKEl&kRviIVr$`5;A^^VjerpQ)m9?7~_F)UpY@1S(Il0*Exg6 zz?UTbA{6<*Uuw`zlYf1B1}-I)AB_WWWl0sdbdHPKelhI_ zoZ=HsOlWP`u-c|Cp!_*%v8n3N7(b&*J3s}(523b={Tt~T1zZZg1aC;%>6demRd8JW z^Z8ixTc7mIknH*O8@TYh7Q)s3JLZa13bjW<$U@%y%$;)acM=Bo7JP;<3#~o{b|wXa zynA9g763yA3^^I+4K;^xb4lExUNV!tm`<@a(g6gp-u(baxz|+ASy?hn9 zv>))$1KwyUuKsYn`mMRwQt)?3zJ+7_ue<<*8s3rTtq+)}c<4lsFTJmiTkL=vy(7Zl zAvALOj><0Ja=|V;z1Il^`0_*2OPc4PE0S1r0KPr6&cLX+%95QU&yJD#bmbb~Ry3ym6aBggA9)cAFy?r}%EyyF zllax~^~*D>!x~1`%xDBMr?G75brR>lVwpTV^%hZ~v#`E6Xgs=Pd+Sm&2CFPp(Bi*{ z)WKqgb`Rd4XAbQixE7}Ob{A0bn%QCL@{7jd$Istc-yFt8?o&=MKy>JzPKk#Q>Yj1Otv_4;rIblfg3z6+4p{O`S)o$-Fv@eUWKY8te45*Th!6 zg7F8?--Hb--s#7P+}7`;K##KSfRhA9pnEo)#!W)Lq>PJywqQ8Cfrxdyt~0Ri+uv>O+i)E}-IFSEn`z&&wy|`1soc}7Gh2VV=poe9` z3lM_+d7fdUe1&OsSeq&Z6}>?Yd0zo8zpSYKtes<6$gK(1iE0q1PvFXru2TsX2k&Gj zBnfMINgyA`9(tQz`}?VT2GGH>pAwb50Qn;|d^;_M*{k~yJw(sSl=gU?w!%oxMo9cw z_ILuYVY>@|NF%p$ny32w|6vlA$p0TE|34Ha+4;&1XbvDDHruU!qv4;Hz3%kHLLIZt;eH`@LF@YNLUIJ7eY}*Zo10%9OpAD?^R8W2MQh z*9iMu%d2mW7=9c19<9E!QPa-Y!++u56TH))j!6;x7~-0~hR{3?6cL7}=Z?nttN+7M zkwWkJ-oWU%WP@vXcT1hcBto9Mdws4mp$EOg@koEMyY!vFZJVk)WfMQbOJ4ejQp6z_ zJ@>QQ9#(e*8k@M0#nGh8@c!rfd>IqM_iMCYRtMvh73Ru0TmQKG(YCoOz;$}9CNPfF zL5M=>^fJT(X%;+%Gzrl&yEe7DRxYzO$2+-Q6X~_|OcXG_2M5;B%YgGxrQD?qrWdhw z=f0}#R^wV?aZ!XFnt<#95X|?t6Z)5NnEoX1@yhT zc?!qX;M_f9$f#VF7pIt*^w37>iTa%I1+t?4>K#G5T!FhQN*~n^o?kr~u-#rtNncJS zRayR+TU!7F<39%pRAk*Tb}WBp(yyQ3B9OS(eDp^11r}?5w?aTs|6E&<+&iuZo~Rcz zA_zYYY%Ny~iO<~`Sd6x;)u#1x*0gbs#wQB=AKksUXcCJZC=o4`zb5$#9U((-SB2KYx#J= zNr`QG1aDPJoK|7Uv+9-e9~D=2t1W!z6yF1?JW4Ac_o#3z*7$0T#%jD$m4@|4XPY~w zgG~YvoNptgIe?wCHF~=i;_t%ej|2XZ~6Js@cwp;BwiM)(0D{ zngh?w`3#w@YiG9KnYR>49c<>eDcu_mEpOekhE!S)$+;vRY2&~lJ>iaac$0(6Z6hM^ z`Qc-&A~R3(1M7DYOiDd084e+%1ADYPJXsReCOKRCZ7H4d43jwLGHure(<%z07-Ncx zF8BoGb3WIa3K0byIPx()R7b#KeuX+ldT**_xceU2las;y>j#Ja zTPrCmgpBt~d==zNLVcoID%9zC7CsNp_nBzCv>{%Kc+k6i$a_gMI=N-Q;azQE>1<~{ ztg_w?-sCj7D_niP(z)ISMt6@!!b&WTH2EfXrMGk4qqE2JI!4pi2SgP#lFvsOj7Fg+ z4(2?&M@QW#Q=mW>D04i|^^@oJ{_5&UanR^+g-VZF5W~YJ92UDg*TQ+z-V04sU~8u}NPA4_ZP>}m3WweZsJI&pTxW16K!OU|MGaSOZ7>a52qS8v-Dfj*Me z-*<@SJJ-0Qzw5zhsk~|=qt+M>z0K%Xl#V7e31V=Np?;u*m2zjqi2x3;U>9<9^h7 zTx}3R%&0j+e);fy@p8|Qr*6JgL7&hu!v(=`HzyGE%$vr9;qH?xTgNeere>f?(_0bqv~Z=_zpG|q35bX(G#6^-XEAv zzx6Rc*uMUd;B)QzQL8KM_))(FV{om90FOykobFD7sQ;m|a8tY)xt&s4=eqOoNc25>=Nj`czG%|-{bbA}_9y*p2{y*)#cTkk+);%hSiU|-D zL=;exNK!zMjDTR170F2@HCaGFLW3xZ1QSs*D3X)pAi)Fz0!_}el2em|(1hQ9nZa|u zGn~5jk6ZWFt@@^Fs(Oa*H#}iKJFK<#*8#g9P1VzFGcg-B_ZTt{^t(qoPGh+y-J)%* zY#sW;@w_t58y_sQJWv`q_erJYTFaKc11-ZdnR#Ex#5gDWbJXQaD;&lSBRNfss# z?btq=dQVENV~l1@UQ|^(-ztM}twdAmNuoHFq1Mc~(&0oeZH`xYT&i`JJc29!OO_(V zZC4CX-q=>NfeUOgR^&K|S~8v+&FW>V5|J604guB=ZZauM*1x7q?r6%qw+NmqS32Pm zbC_sB<50s^Sons1mVp=uWzg$Mk^aU%k-f+Gikn0^)b!6E{6p;?7PB z)^{mn5earLY8u+xt5#?)`$5$pe!5REbeg5>{`1gGoM?6uLC&H609`uv>XY6KUy6uS zvx!`u(jQIOx8(k2hWAR0DA(d|JX-p(kXkz2^HO3x;c`0eK^DPj(>W>HfN;FU(-!Yg zs(T@e(jOaD_|w|5W9byzwb8NyEau@41J;kT0ew!5dse&_%ACc2)IM19PR!I~O%JW~ z357l}-4>Ux)M3WEE;W|^(9eRfX_zg_X)PK8y9&8y%MjRku>4F~jn$YP+rzLD&fddo zjZsp_u@p=m7hgzWzKL#6_DC#S> z@F?8XGOLNYApQNLX=?rVI?g*}nN>_{{QmiQd1j>@pC2rg7kHREV=y$O`*KN zAJ({ah2snFV!1h=L09PtiCrqQ6na7s zzcx#q+1pu|)}yIL`-YFTVw(H)tB^Ns03N{^GAtLi>QE+_4EQh@@G6v7o#Q9;HC5x? z*t^b;pUk|Tu91cDBeYwp;quf{qb4@#BjQ&ZEVnp)8x54NWTbC^qR=w-)$8_;DF1X@ zCjOS+J~4>|KNwlVCy%Qj8Z(!vR9TRi39Hr_&YLL9w{cWhrYs?MIrqeL**f&&s6~Y< zo;)i=#J}I2Crt>J1y@8t&aV}5N~gT28-=OX;N49&e<mZqn$n(~ z@NkXaXZNsqbdlxf!FmO53*+veB6@QqL-F6p^Iy_ZcYRR#v%1)D?9qlV zA(t&B9P84uKEBOAT4q^P%B+X!n$a&Gz?8mKqFNco{ujQeetr4S-RTECx0Zz@#Fhl5;9_{W*#<1b!!#}&xv-aQu4Z$4Q2gHv-kw)`na z_hVFDOa9A5=gJ^9ex7tqT-9?AfhkN|gIJA8%mu*zXi`Kp$p%d_W_v;aeex_4@D4D+ z>cjnvFYGBgs4|_Kb3V_kJ7z3#Q?Q7KN3?uzAT*!L9aIJb*-5g_zqO#jXD+eI-l#}Uck&EHN@L#z-{TQFVng^2+O?m? zhw5LG2NbYAk9dMlavn3rJ4}?6$!z?`u?0Jc;=p-~vLWmwD{z#L77u!5pP4g`CL`0? zYLZAYbyJ8|5g$Juxik>k3w^PB@!d_an(-aYxehu6uWqtzw zeZuYc2%dJ&_De0NrE3qKj-3CIm%SKPUeTFp<@tnQP;$6l&gbO^1)fzi7WL%;OnJJS zjhJ(1vf&{p12Q_7=?b_A!SZY7q<~&k@EF}(=#wzF$wkM zE+y4tIZu`P%%^|(IZK(KW1osXD144tyHxlxX?Y=_;Oja5{JE0lqiwb|OFi>ihotzn zvra>BqJ^B!Y=!)^u{e=L{Z?L{F70aqfn_TI?_c!=$vvdM%{P-U!}%2-l{XCX7x`@G%QcTL7Hh=Xs^4Vik2L7( z6xUge&$sMrnm+ZQQJe|%09e{n^&M80qP$I&S0-Zk8b#_i7dEI%KRBpNw!Evbl|A?8 zkbsqdMOMPk-wAc*im(zUKKIftWDLxOEKk%!^~&ZlPx3U`)A9ZkpFaDRJu4fr9-B2A z%Nn@G@#9}`PMfA-hJBq}G&4Q#V$T@7O(+$-g?>6cesb++fxb@9`jZ*0B@468y2i#I zYZ@P=9NH#S6eHu(yr*l%)jyi{CADX-UMoOws3`P=PV|0*_})3UOU>**@8;pFt>M%7 zfLV-YA9SQ^!Q5{3sldL?nk1Ac>wVLN-Onz2cUb)Xi`CJ*g{f-{I)oz=CSk3%#)7ze z3hh2qGNej)|J02efF6`|hqS=A>gT0M))yH|>3jE2Dl5nGdG>C;Yb!5G#COi6m9h%Q z7q>3fEl+h!Zb`31ymTb5C+o{}^f4L9t2POX%*=(*cWpFxxD%z3)h^*<{nSIYZQ|XN z+^~|eLFu}Q%UcCcHT8zd7kh((G8%*&F?_0!0B1eU&-@VadjID^%&C$0rbpGickH0X z+Oy_Nxzep}>q4?=+aN>N{zF4Nw{7d)J8DC?V%v0WyNYwp&lKZ5TH=Ma2-UUaJs)da z$*0U~CDZlhnB6eldWalQX$Mur8kZBaKkMZyo07qJ!r`tInU;2>V&&Ljv2`|sc=mCv z42=^|MLRS7xU2J+N)LqULosv1RXmCe{Xm$t2H!zk4ji*k9^L+!)0g|X@>fpw2^qg4 zJa@Yy+iyE~m_e_T-fhg6^VjdXtcuRK(gQ{54WFO*cdl#ur{Ah!T3*XOdEv0Mr*64L zmkx*P^&8F^%G|b2^(F-agXF!TdsJSy3Qz7~!5A9Ov=dx=)?@3Pz;93@?&2thqwLOA zeUbj#8*aFWDhVPa#9@IDCx6{V7($$K#MBW0!yA>|yVzG~)3xgTA*gqhH)N)8v$|w? zVkmI0fct7OL5E+8Z!xZJ>h2{$sf8vs+0N`^E2ZkH7G@c_lSKk4TYW-~u@#>D9e?`O-G6qwO+6p%Y+ zlIc_7$zvo_U5hFQAGT|xwxD1wx2?cpNghcFaNHcCSm`mukgCyQ-CoswJpwrL)g)b~ z=?&_Y?@TZ9>`gd2RV1tnm+0Ds&R#&J{}SQ^Ed{xOFFsBYK7^nY_cL-r7hjg)`QcUHyFs-Z?;A&W|~b($H^qc z;6t={`5j*xn?x#Wn%YrWntTBhYwCRam7jR}3(OEZjb_%6EZS+htI4!tsU%H4scoiv zskCf)AW~bqXq-TGweIYzLfBoyT(&Vl7ceFxssPh%AMB+$B9-Uo`Lk!6_!Ou>Fi<^_Fs7~-{!fNH!sb0 zHD9UKK}As@`uB9wXF|J5QykY-q~JwiS5bz2Bp^9%yGA+dZQ7G2QbexvQtWaqK5P~5 zjMLg&-=3F&5+xG~=_RqowJ?63RIbm&EOhQ0E^(2h71ovvI zZquZiUadVsz{a$raK<7R4x#SDd<28={!b#11j&10omtqV@*+>$wjm(mwRop;`0}H& z`EGuKRo|%|5Gx50{X0!Hd4Et`q0{I@#G!!DO_eC&PyKHeA~Fc>8Tj=X&kSSVFBwv+ zROwysHMN4O!e$n9=X*(&P_3It5nc1+OpeSqJBWiKc`=B2{GZjJ*}?UhFE7J-l0Pu@ z9-^)J#QVMu$&l!Tk@ewQ@?&-UEh9^~(>-kU+j+vv42vei2)RiW`I@hATeD&zLImd}KcL z%ud^3&&Ij(yGw(5FdT~!XY6Fm-G)9Ku>Li$THx5DA(HU@tkcN&WqhJdnf}{HIK-E^ z=Dkefit{B?5S7rfD z5gg@g5VqCgL`MIrX-kLGa`>oDzM686kM7IzKC7xpo>I(U68IH`dVKkJps;y4XOZK~ zc42e;qO!TsDagMj-oc`I;ejq88EeJ!~Hxd)ctwx;~L zpviA;PEJPqOE)pR_hNrx_-XyM5$A0@0IvExUg585{%NF)%=-wbF_JPxL+Ef*veH&c zjT91!ZD%A`M|x~KN4RiZSt3qFUoNSn*r(=OIE6q)_}%eW1Fz3j5<>0vc(ra<)71pm z9>8n=RUt(oc`SCVl;cok-a{mDsD96Fl^*W-a-|+@)9jJ$db(-l#l*uqHBoQM+mF6I zfZH=tFhJSZz4wtT_#JVwF&+f3ZBCSr-F3R1%I z9=l0IGQf%y>#M{b6aiKTxgY?O;NruLMOZ)%RBpkEz4`KWtj_m;84wxte(N7ChJRHp zC99B%=-k=F$8hyPxOxzYfOOh0CNkdZy{`Gy?Xt1Imps!>+4vb!pB>Y-V?heXsxZ7& z(wwl^s-*la&jKD8;1{NM<&Nh=N?`kGUfDyCc9Wj?6pRy6%QubK&wZ_a1QU5~R+O69 z^eH_1{NumI5bpo<7(B}zqW2>3IR-{~gmxm}0mV!YGFJ#311=sxrXO*1r}(Q3OYV{Y zGEQIRAgE<1jO+hK5J-NVg!9-Q^R+vtoA>WjXDP2kaf0~HO#nzxoQKUI`9!268eY`W z`_>+>qHR=)9XY)XQkzE}s4^BVzRJg)2gre02|%T684V>-&zCQ$@uL;6+gso&-NGWx z-|snxtbX@rBOoM# z<|qyWMCSu`K!=n$bh|#=dF-0?&U#6Rf>96ldAJ`*2tX}AJkd+xMy?AkkyP~MlZWAF zfg!uj_@}b`pS2^Gr@~D1UkL(CpRcnaa17u2cC0?+@sOgz|QDLK?>o7T{3`g6bh6CGmquO!ZfTqZ!CWoH~0#lF?>+ zzIq?~(Cw_9SvI)QX29VLKn|>{K-p*Ok#wU?yp;j%~OGG+E3(GhrzTw${x$^pF7n^+%Xa=Ax$Fc9bcD_F(^Y9?G(O$%6 zyTg;t*&UvM%);YSE^GJ$$a^L-RGqc{Yq!Gud%@aapqP2&ahh9kF%9Qw5T3yWp1 z@1uAI2yHkOR(}4H-B~;9C@|j=gHmZQN?QN~T9c6VusA!TD{<{{0m<|2ujgG-@%($# zX%Xm7mCqY87#9>=U6Hc$NCsGsr~5-~-tXInUvTZe@O)?4GrGcteQOrNt{G!jMZ4Ur zz|-~arMLwGVS}a$+;=6aq|SdY>BWgRcj6N3?+u;OSB zexUe?p*zf#`9$*W_Qv!*bEEYShqD$bVY-mceieHzfyK<`m z>qs+K@__rhb)R7Gy~p|v^_Mk32-BOip26eyC(yPtvi&$GE81tba-N>SefDbaikyQH zy27DEoTvMkl5WfRDQ{EZYaC0~U>nv>Vjun7t}#9qSsNGnYwa*fBM-1uxjfNU=dn6W zLjblY9{w!^R;n)Ok^>CSx;>vn7>N$;qxXu*snOOIElpDl4`0=FJ0$?;XOn+QZ zPBX0VXx{+*q66=(jtg~ao}kxh)oF$(!iecok)xU}8C7+HCV&~j$7;*e?*ZMmvF_;; z8x?D*lWlVO>Kkfj*0P3%fk-&CDGsz&%ci$a>Mph|08hx2M(>^%!s0A|R_V|{BM?#5 z0bHpmliZ8Ey<}G^UkFc}@2CLH+#V_w(L2zjB?plW2Yuhc2@sqKK~!rpd4N%CXYDo; zZZ-wf#X>oHm#^PsXtOO|@?M!K8YJVmmHaBr4SphEHy~3As{@bF0#_~=Y1lz~iYKLV z9m9>WI5FMR!Jk|J9$RuH3n(5-(wGHA#4w+{qeWT7I?F?$yc`&V8i1%2S!z|&mrW^h z!HU!xedwV9%f$OFa#e0ufavY}xTn9CAn@>NQeF8rFK^BC zVl&0g)}oY#&2sljVooRm69(KU9!PNc2v8aIA**7udu?TAhNPFPu=g&`ec?IPqd+^# zAmC$AW`Qp6a=WgrkT#1p0EipQN)m*ZFsO?M^AO79S)bDbL*p=mHc&JxoXjk3013T8 zZ+-MZHh#Cov17~OfsYVP?B*K-N}pBiRqMV05yu)(rG07BAjl+g5Mi;hvBL7b!>05F zI8&9N9o+-my&NFJ-O}rjSKcPWfi^!IO}-2Pn9_NmE!YK_WkgfN0kbic+ZbgGN>qV} z{E?jo2Axx1?wP5cixwPVoG?Ko;1#_HN4%(BTFOz4>}#dYok*Y5Is5kz1zsqRVLXXm{F4 z7yN93gHyZ^X6-&(LmLjT*6qG{P&r7nA4^*ABCQYhVazxMc&&`j*Npf8Vg*Oj9qEYa zoudp)^hWhVpq)kXPTtC6OWL_Mn)B2oLE!HB82SoUi4Go(j$@oyw^L z2&<{G(-UeHw7gcT@6! zBb%u6hgUqQzAkQ^^~*g#T7Tu!zD0rlLZYLheam3YO}#J9N-cc5^8VP)QS?<3mh_Qq zch?l}L|*26Z=GucH@o~a^&Q&`q|}<#XrF`8=^j;O#4E8Evox{aNSu=5a$J@N#vW~O zYOc$|h>b4@^#rZNC$Kb9#_8B+u7%VmTjolr)uoe*OkE7y_eMW>`2NF}yd)t+DJFf8 z!x7}F2*b8|ve@U#=ybj8=Ht_Qnh1%*we$+hER=}~SV`wBj|aGwwb6Vz?T}yIBT#>S z#eymGlMmy@cPCD@0DM|#_0)6hErFqHH2On1hSEaDwm~aV_dZxb|0_!~P(bS=lI_k_ zYt}Kk3iRGYne>5S6ZC~;zwm)V==32do_EBsZkomWvEG}leL`y=R-#v|r+yUeCH92_ z5c~iO&Ku-Aet>n3U`rWuPaaJ|=w(aa;^nei?fSTKOka6Gpa#N7F{>%g zp$B!ppE6;7L&>3r46bAG1YnBOxGFUGA7>v34OhrPIf3j;e)=jH@B8>>N*g>rMe+N+ z##u71KYQp08IEQgt7KtSUpEBH0EBg_cp3&G_8{HhBm|b#b zIF2Mb)Z5Zio~uJsBRSOElA!^+>4Ja#Qw;+I=pM_O;K7LJokK&I)B@2<)AN76+}GOwJ}`k2ca@v8o>5L>xqRWDSfCw$nT_Oa z_-#-L@9{lcbMYV_SFZZ&i^B8YnWB@Ma_-`3uGhE`Q@T*9bjzuoQ)^bOtIv;343$tQ zL4~r@sSfZE;w5$NW^S;DTuL_S*ExiNGjFEt7>~5tTzmiXZrlxp(0oOtR_5$8G6X|> zMBhzqqF6_YTPXQ`2V|qAy@JhCKP1>C312P;T6K5F4IhdW{lHMlHv{o3-KmCn@$rNp zJ-7THx^=@#&TVM{-+<2=_*wY)^|v>yLt2Bcw|cw=!X4SsoBLD0#*vX85<7m~O>Glc z(%cHSv|su8UojVQfd-?BglEvhqiwok%Y%)Sg`!QR#%$xGc^A?n=*;0WhY|w519`A3 z<5!XEMrd~1!f~^2$`XY-_T0xf?b4mj)2TTEtM&64zl8LgOA2-^1^X>p;Bfi1#&q@c z&?D>-`$eM}H~yKPPRn63@C#3EyTts1-j>@D3HY_wnTUmyvYBO>GxOGO)!DL0E^FqA zYS-Nj$!7^qe4i2g@hqbkn;qNPYY1W$6i@hOw9GySQAC7OTw8mUx$QIGKU13b2a$f7 z)HwDwd*lr8Hyp#QYA4j1<~7f>z8y}~(V41|N2zc*w)cB##BIq`|dPkElrKR%8BA>$7Wkrk8fkt)kH~LM0fU@FDr%c zCs%xaM{oaAVIiPD_}OTuhsI1?3_{nA)JOKEbO>LRahpB^o}vx8RCKl?yawY>IpXe{ zaX^GsM)!kLbIBKRpP43b0-zs5N2e3Iy8T)|7={-Cg}oh8UnK4)u~aB;?Av^B0W=Kr zb#01G7$}9&*Xpq9V7>3cY34{ELQ0LQZc`~}TsfGe0|A_U5O_2{sYJw-mdVw9uUVbr zZ(;eoiDr{(VX%uk@o5{hw%=i>#quwAL2^G8mc(+9$I^N%$hmlU2n2lW4nzSWD_h1V znG{NpBzDy07XN`hHUNHN30YMN|oRv*x!ISBy}nD`R&WjY4i;n6jhD^jEFX7ZFn_`Ob&%w&RD2yzHL zC1;4qw>4GP$}3DH83!4eX=M}-^ap)2_dKC%g^EzOR+uK5;iQ?MC#ZgYK~ zS5ywa17##>{Vf@GOe|pAah*-`ivTZ&NNnqT;(?vxZ|Yjy=alNsakD5J;N0}^go7$X z7s!R;L3qQ?ejXNnKCrMlMjE2&PBejhT|{Us_}n!&^c$}smza;J1XU?~q;0wH#D{L9 z+~M(#p1;MBsN2N!j$1*ff4e%iKk2WSz5CermYrz#`W3H1W${`>Y!f(Oowj|#*d(y( z?eFBsouijq5O)kQX#KL2gt7`SDh2m`Nfqiv6C=5Ymn4V>0va2-x0G5cd@>rw07^8&Uln;u}Bc}!M-AKphKS&QY=%8mn7%g=eEy`a^3T)h-HNhaV` z7CKJ0Uvm_rK{7QkYfYR&Vc%OTd_cvh#>GJH{Qt$&=|?UL*nYE$)IkP$d0z^RoOD}klFNX59ZNwBCaB7ofKp^0gaXV1OL|Cxg)!@(jPUXEw~Sq_ zrO_jf!Oa90()z_03>hl6+jh_O!~8(+c#dDC}AaaAO}zo+BL!j;2*MKF3dWRrG-)BmZL>2iGZv?daQ6 zmWc~7E!&TA_TS{hN`DXd=Y|Hey|u^7b%5|(B(SQE((-!QpZT1-!6RKhQG3MeQ79 z>F;OaA7rVbASgQCFfkyTvSs6TM+RzMo0UbBG zKpnXEftr{hW+`L?zB4^jBD*rd$kCT&hja$e1*wz1g#ufg4I+WG0rmt)t%Y4Ew&`&t>T(q*F-=Y< zB}fnfal0vH_yaZBKEDvH>?}F-ev!>Z@qAbl$9^wJDd6`EkM9_yugZ)`2irkmR4;TG z8hJ&<9pVa9x&XY}uE|M|AbWM$w!hcJ8M;JBELYu=>!y759lo1L6;9~|dePZP3ZUP8 zL5YnOT5p=Laps4m7BHxeWEC)-SB-mk*{TzZseql|2| zb4jdg+`pmenJ81--Z;oSjq$RF92A0FlAtoQ4Hpk<>VXc<=>?$vk2R5=IF*HtG8b)2 zzVQWvn$H2@p=FT8(gAH?h-vK+0VRkG>!(1W!f9X;T6BJ(=F+)x%%$~)9KKBxqD2k) z`xh+LOdu+A0>z~sP{WVLib6Yw43N}UE=fJFrWPLeDkMAukqosL7f;^MQE`huTs9#j z_2*-WwNNh@00C>;?w6|eIgkWVJ|~ZsmxP(F*S^lo&+y184($A_;i4he--%+021X1(a=V8wMF4fP~XK47kL|8s#+MS)t zu-*^c&*0O9`gNWRADI}zm=)_7rcp17@7wXz{2*dTw~fNnW7ofuC}_I`DuVb-{YByX zY&yh&4{ICRVuC1NA_8F>zXxBkx(GoBKsVqf_iaWX+O7%(Ax&vV6l#>YXfz83LF>r| z8i(Cez=BeZK4HF&r;k39PQWUgBeUo;^iW@&R~DCN?YFPvnzq!T?+0nlvMQW3x!90* z2w8FS{4%V6A=G=5Dat^~EQo))x74>J+?K66JWL|xi{J!q+tyQkfemnC{yig%`th1c zuB6EA+F&a5-B7*@N@lGXZC?Ez=JReHJ%_an!-l4z(QHs;*I}s^&$(p>e@dXHI&7io zNY)h**J*L#cfy}U{nx9Ef>fNoioTpRj5Ta$@}v&35?RY(K@^YU##?WF`x=&IaLh_L zW~2hpzT%D$1TLgZ6YOUx-Ab9q9W#*V*HNJ3zS~XHW&n>+#zpN`eP7M z&E1KL;depP`$g%I_;DX}k_z1b)HzgU(Cj4Z+pM_771H;PJBH+|B4g-54X4-RVgXY> z-h6NX-Y1k_x;fMi1aE-Fw@LG&vnje0%f@c0#@20Vx%bTeR-o926?YZd;!f+nFVjJC z!OqWu$>dx5f#A)&?~-)dbENu3Iz~-$bDLJD*!6Sp)6V^veGqZCD49eZ87EWw0jUf) z2;E8Y#44e6l{K`$X@_)9b_k-^-~`!j;gpnl$W9mI`9QC-9OrX^O5d$u0(;)t1}59IGSk55@M@w#JGj>ilDx1~W>XLa zRH6+L3W30-6mu(|Ams_azN$g}K=+QXIxbL7P9%-yCH?nG z>mMt`PV!kP`|YuCiz8j(sDlSXpj2Q-iH*Mj8vVWxu7@vwnF7Ie&_!(I5}Eg1r7qJs8o4My*9V_tS=Sx`R}iej??zE-V8nBuqd1QFB>-M+;b#;HA^EL5>Jk(Qw`UIpWo4CD1GzUIn9$%*Y4G=&Z$O1l#rCQv-% z|K!Ikke{b^HwbYB8QVx8cA6xcH1Eug2T?^1_ImTynH3l265JVKY8Q_b!8F?2+GUEKoA}@QW;>N zw1_R%w=O_43(M`?>d}`B68%@C&&EICE6+jW3Teq7kx4P`+fD==KO#c^haJp&@T>5r zLu>*y&5EYp$&mJV_M{S)&m9{5`>(mQvMZSd;lJ!SKQU|3FW)(DQeZh z2VFsS7s#}| z%!7^|+K=}i?Tr_QPQRpe=1_^6R*eT0&KBsNXanxI9c>KU^QCSTy$;B?@(YbAcY8pv)Zb&Q3c!Me(`a0J5(eT9Fxj%+*5vNIz))Po6uh)JKN+QB(B9NpO z*qF=V3TZ2LSukCx=BTE3OV=&v&(IYF<%m}C^Ar)|;==O<8@{f%5$iOUQzX2Px7RQ& zQ@Fvl+W?{1t7}j(eGFA*(nDV0WJToEO8ox0SGUwD!@4`K#%ZeaSBW90<8px39^`Op z5N*#eI|B$8Hr`%E?>8A@PK^C%OZDZo4kHEmc4!o1_hno20ol;MflkQDaKI-Kf?X6_ zHlQ?D0$>k(Tj%sP{qda`vv;OP(kHedX}7Hfgjy~ z$-s62sfy302srnDRdX2tO))(96Uz@fumjd(IoZpZQ`Lla=d1%RjgUqy!h*Ay9$BJ}@rDh#Hnh~Ww7k>)VTXAn5tp}BerP9+MAnO%@$+!3{~VIx8OJv5m)wL4CF{&xf4SN z!rB+9s4a5EPO_DH{kP&%OoNMo_dIn=2{rv{bMy6%c zJsbPOS3vAf5=fP#_SThisvGa2U*aATdj$w#Z4T~V@aQl66d+Qj%mu%)SY7xdOuUnG z+1{7PDYzeY@5$SCpm?+64F8>8LZTwXydnC=t@ERQ(f~VlZ|CchN5EQKsvH1D#NR*J z`ODuIVnnhDDZ`C_@{2q7-ub%MNqBFghu=WR=YQUN=R%SeU{ciTVv}}&KRbW;^Yu%R z<*C}^udw4-{O5(dS`na3sDuj8vUh*cyKuY*5K)Ki2uu9q7AXLhQRMQBLAb#?PyRnX zeg!EU+P$3If1`l@`K6m6loGD9DSBqdEN)rOOh3lQ%wW7KG`|kzy?vTD_WZoBPwk1D z$BPeMXqw+$D3Mn(RtTOJIBoI8rV>`M%%gi=B42eldDHtUm?jb@x zBuh)iX&(rE|L^~A=a2jvl5t&Ho^1#J_4S=U{qOJocU=Cgg`E%oyI%fxAN_4sc9z8d z+KK<)+lkvtk_&h!I8@a2Ipg%JheIj zXx}5=rhazkxcRqVJd`{`3`&-!Q1jEsEGi?3$bxncF_EIPm3tkn?yzs4PXGROJoHP(oeJ1lj-d5^l%-iP=Rw$E_PnV zd3p-(`gQaLFMpo$D>McV?u?HwuwX^GYSf`Qm(dIHwXy$tkCQ_7#(=TZVBEAk$X^jP zBmw=qf&s*d-F?c{4xR?=Q8B)tM_wf|L^3zZd6NK1ZbJ{x#6pEW-*> z$Pw_UtLI-k$SzQft zNFq8|h0Fm_(*-cZ$hS9?c3qP{Q`0hQ0aIzf_(FmA9Sk%_O`%ysR5%m})z0m@Q`AVK zoVv}6A=9P6-;v2vM^|6v^Eow;#{`d+hBc3sJZpt zK%p+gH;GMXZo_NjhyBzlsKPcwlOW}!_>u>8`moF7R6BcTd9q?UD1?BH#p#wp@1`_qZTZq~~Op>tseYMo8G-tVAfso0eNj*tWn1kaKg?gv8vfwRQNf`%MlZ}0 zH3$vE3L;~@wwEiMGTQ06tU-dk@Jr04nT z3C+k`f9?qqMOo%u_FEh+2{c-bLz49GL-2u|20fAs3^1Y`faK~!+MU#2^r7F)^o;!^ zZWLWmEP&R8yu;C`r|#Gy2B$g~ZNfX|>OQDm(!zc`ki<*B+gm=Tesjg{E@*InY_9sw z+%j>Lzan_30NNQ{9xiv}`NwD?m&kix38my10JK*G8T&KybkLzgms9|mfhh$DMoK%d z9tx(~`gdD4_wRa@PM2^%`-j`dio|Q?7-Z*GY=n3^)*G2|zx!9p!;Ue@-oLB-j_O}# z_|HILLy3_NOOTc|kUt$Yq&ayCy{tSx1Y*h#Gi56UCKb!9f4=*#4{j)n8`#9> ze0ecK@gH$GBdr!e{>MfW^euc ziAKZIVANNeUe1}X**>2Ow zh&@F-tPpItF`-$fp~);{spOVf&oyfDGK*z(bFAqdpUeEvW&Aj1*GWkasVpZRjUb*~4%n@i z`CYhEyFT+eG;{Y@&V?#f-umVUvs?z(jWRD&m-C~A027JyrDDspK03MUNX^oF0keV; zs%`8QJ%TlZe;S^K0&uqkE~oG64OANLvnd%*Ji;LUaeXU*xw=g{ zm)mOug~}8g4py@VL)fZTr?cw=CqQ307bs2nf3-nTw@T-5dzp4sh{ic&;Wn2GSEtM* zbXCMGcUwqfUx@(>V$yKsU6e#j3aZ?I=Ud7Ch+)p9*wgmk&$5%xEbev?Jgkf$F#yT% zMd&ZB13kj&)YYwHhn`&4U)WsfxQyi_tY4}5wc{$ePU@)aYTv0&in-p067kJM`bgvE zl7I?vuaV5*&1GbT0-8=Gj9b6`eYxds<2*(}47p8Dz;dawa*;PqRb(<{sl;hp7z7%x zcYZM9vm5y8i2vlc+a9=o&kJhSW&qMLspVl?9WQxN3R@tB;KN+n3;rP2!O4>40pZ<7 zSval|)Z||S(IQQ8`7twFdgHTl{iv(~Ep-{*V@!e#vahw#;oU*+JNp%+9HwA&!^cdD4G-_R9U|?m=e&GvFz_?@0@a zYLBokycSn17l^aYsbOscwSO(bVvvEeZOM{jB?t-&j1)W8KMUwRibSP^QE3G56 z*)xMWU`BUd{ccMV&Fuv(f=waFbAcMLf)P`odj*%URh?w+qMKacCur!d4S@crf-zjo zZp)J2OR~8BW3NrjkS?m5^N7`?WUgkQeejJe2)2X(YH3V`1-<%z9+Y0~$4^RlxU(w0 zQ7woHG1ju2-qm$L!#%JxHyC1vd}7yHlXwAWW(5dVYoVaB-HZtOfEO@rE<^vY2jCSbsH@{+jqm7& zS!YS+{&b38<9_9*;SH?T-)a%w28;&J>uJ@z$@4?}3Sanwjz|nVV*;R4Vfw-z z?p?hs{62bsLj;#*)>WXY*ut^P>RSa;Ond8_>nqrVETGHiYK@9V^Rv~q-F|)hC^3Zn ziZ!z_FpoL&kC<^*$93NAt>;}?8vYb-`1>=BMxO@+G%K>&$qKt|JOT+o_rkuAiKOem z5Z6#PD)4@IeZ8In9lE;3RU?512RO|}0%sa&eaif;z;5r4@>s1i9C^m{BH6$VpC8Bs z4(9u&i}jRfY9Cvc)lmpIFF`-KAM|chO=!BMT^7=P9WM}65eM+7d)+#+e`qg8uwyyt zN5cp+02npsdocJsAJAcWv;m9%W;1qnMV#6}#Rz_OpadvoXy|IIw{2Z-=0EV0UpG#B z^P+q=p=|yOG(rJ>X0q~9^qs zNUKjyO|1?$iwS2}JLO^N!G|$*-Q^(1P7nh=y$1M$oHg66{95 zQ;fP|x8xIMgN}XdvCBfu^R48_`WOfijG(5We#}VG#N|shF4i~G&j|Fr+FMT@lhtOC zkAi1%hvQ&%M6e?TyCqF?l`?4;3+C0pa<5TrWe7sKT7WBQ@Anj`Sw(T~`{yPeUfY^6U;EjzDtK5=3`+d)zeSs*MtGC2gjp2BCHQtg` z7$D44BN!;!cEfZA?PXdwYA>ImL6B8f-x>~zT{m`wUP?>|-x zLMKkca?v0*74EC1OCyoCq3JVo&g2`blbZY@rP7QFXsbxhj?J~99`VimKe+OvzO=TX>l_kYMjzC5871L9< zvibH!aD8P^v3JeT0OwhKRR}S9%A#{$-KQO5EwR;coV)Bua+){Lb261;p`&8cvF@!; zkY30TPB>E6>oSlz7VedYve;UeI!>Qr*rCVSz2^JB`|Jzp#$^! ztdB%`D7Suh;9%df&9ha8=i{k!>LLzt#U-0pQOeitt?GDy9-4jHvGs-!oMAb0Rm1LQ zcZ-(Yjjr>1(mN;tikn{6$E-t5~KF|!dMK4+8cq8M0K zrwjbVG%~n+clMnx5OVk!wp&K{BuC*0uXHGLQCu>KcK&B@rAf@SDb``y>uX* z;F~+$@i)9ie3)iphQ2fVu#NZRAn!k1>guspU2o-H(zgMlNxui4PV>ygJO~?2e9kx}S3KhQr}rsv-3x%; zY{M60!3Vp7T47`fk z)D6T0PUWqIA}QiFfYNp&DVGbqQ^1i%V%5coHV$C>-2aJPBlP^k0mWt#BP{M1uqC3f zWnDlfU>^3wh%Z~rccYhLp%UxA$Wu}$Ha0}x7#jrI*EW`+Vk|qJifCQ(GM)5{V^GPF z3*1E2N((q=v{{rw!VdpfKE&412GXqgu*r;pY|>aQzWm|-Ourbz#+Y24m&`bT`2qac zyr$p%`XAHz)0U(kFx4Mn54#LL7o3NbcanD)ATRBK1OFN*`({8$o2k%W&vI+E0K!G; z1zb5N+v`S^c>;5V!hCG9t_qa+T|+IqQ=9|EQMDDZ@;7btXTpusbTSHHpXxHd*2_dS z5&m;+dhI8L>j?&NW~EL%VO9td-I^BUqG5BnP(y_Zy&RXXta&r8OOIjI6eS*-;1>9j znH@JV6=D2t#6nH*AJ-ydSDilL$%Z<}Q&)JQE|<$tY2H4tLwM*b-A~mm24xDhV|Qnp R1NXo`sB6kHnNqhO{U3f9Ml%2a literal 0 HcmV?d00001 diff --git a/static/pephubclient_pull.png b/static/pephubclient_pull.png new file mode 100644 index 0000000000000000000000000000000000000000..64a795c8a9d29f7e2d1853c1aee57ac4aaa9fd1e GIT binary patch literal 88093 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 O|4_23=hLN){Qe)%n^@)m literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py index 6d64ea0e..39053220 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ import pytest +from pephubclient.models import ClientData +import json @pytest.fixture @@ -6,5 +8,30 @@ def requests_get_mock(mocker): return mocker.patch("requests.get") -"https://pephub.databio.org/pep/test_geo_project/test_name/123?DATA=test" -"https://pephub.databio.org/pep/test_geo_project/test_name/convert?filter=csv?DATA=test" +@pytest.fixture +def input_return_mock(monkeypatch): + return monkeypatch.setattr("builtins.input", lambda: None) + + +@pytest.fixture +def test_access_token(): + return "test_access_token" + + +@pytest.fixture +def test_jwt(): + return ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJsb2dpbiI6InJhZmFsc3RlcGllbiIsImlkIjo0MzkyNjUyMiwib3JnYW5pemF0aW9ucyI6bnVsbH0." + "mgBP-7x5l9cqufhzdVi0OFA78pkYDEymwPFwud02BAc" + ) + + +@pytest.fixture +def test_jwt_response(test_jwt): + return json.dumps({"jwt_token": test_jwt}).encode("utf-8") + + +@pytest.fixture +def test_client_data(): + return ClientData(client_id="test_id") diff --git a/tests/test_github_oauth_client.py b/tests/test_github_oauth_client.py new file mode 100644 index 00000000..d7a2829d --- /dev/null +++ b/tests/test_github_oauth_client.py @@ -0,0 +1,20 @@ +from github_oauth_client.github_oauth_client import GitHubOAuthClient +from unittest.mock import Mock + + +def test_get_access_token(mocker, test_client_data, input_return_mock): + post_request_mock = mocker.patch( + "requests.request", + side_effect=[ + Mock( + content=b"{" + b'"device_code": "test_device_code", ' + b'"user_code": "test_user_code", ' + b'"verification_uri": "test_verification_uri"}' + ), + Mock(content=b'{"access_token": "test_access_token"}'), + ], + ) + GitHubOAuthClient().get_access_token(test_client_data) + + assert post_request_mock.called diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 03e724a4..9be7080d 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -1,110 +1,80 @@ import pytest from unittest.mock import Mock, patch, mock_open -from pephubclient import PEPHubClient -from pephubclient.constants import RegistryPath -from pephubclient.exceptions import IncorrectQueryStringError +from pephubclient.pephubclient import PEPHubClient +from error_handling.exceptions import ResponseError -@pytest.mark.parametrize("query_string", [""]) -def test_get_request_data_from_string_raises_error_for_incorrect_query_string( - query_string, -): - with pytest.raises(IncorrectQueryStringError) as e: - PEPHubClient().set_registry_data(query_string) - - assert e.value.query_string == query_string - - -@pytest.mark.parametrize( - "query_string, expected_output", - [("geo/GSE124224", RegistryPath(namespace="geo", item="GSE124224"))], -) -def test_get_request_data_from_string_parses_data_correctly( - query_string, expected_output -): - pep_hub_client = PEPHubClient() - pep_hub_client.set_registry_data(query_string) - assert pep_hub_client.registry_path_data == expected_output - - -@pytest.mark.parametrize( - "variables, expected_url", - [ - ( - {"DATA": "test"}, - "https://pephub.databio.org/pep/test_geo_project/test_name/convert?filter=csv?DATA=test", - ), - ( - {"DATA": "test", "VARIABLE": "value"}, - "https://pephub.databio.org/pep/test_geo_project/test_name/convert?filter=csv?DATA=test&VARIABLE=value", - ), - ( - {}, - "https://pephub.databio.org/pep/test_geo_project/test_name/convert?filter=csv", - ), - ], -) -def test_request_pephub_creates_correct_url(variables, expected_url, requests_get_mock): - pep_hub_client = PEPHubClient() - pep_hub_client.registry_path_data = RegistryPath( - namespace="test_geo_project", item="test_name" +def test_login(mocker, test_jwt_response, test_client_data, test_access_token): + mocker.patch( + "github_oauth_client.github_oauth_client.GitHubOAuthClient.get_access_token", + return_value=test_access_token, ) - pep_hub_client.request_pephub(variables) - - requests_get_mock.assert_called_with(expected_url, verify=False) - - -def test_load_pep(mocker, requests_get_mock): - save_response_mock = mocker.patch( - "pephubclient.pephubclient.PEPHubClient._save_response" - ) - delete_file_mock = mocker.patch( - "pephubclient.pephubclient.PEPHubClient._delete_file" + pephub_request_mock = mocker.patch( + "requests.request", return_value=Mock(content=test_jwt_response) ) + pathlib_mock = mocker.patch("pathlib.Path.mkdir") - PEPHubClient(filename_to_save=None).load_pep("test/querystring") + with patch("builtins.open", mock_open()) as open_mock: + PEPHubClient().login(client_data=test_client_data) - assert save_response_mock.called - assert delete_file_mock.called_with(None) + assert open_mock.called + assert pephub_request_mock.called + assert pathlib_mock.called -def test_delete_file(mocker): +def test_logout(mocker): os_remove_mock = mocker.patch("os.remove") - PEPHubClient()._delete_file("test-filename.csv") - assert os_remove_mock.called + PEPHubClient().logout() - -def test_save_response(): - with patch("builtins.open", mock_open()) as open_mock: - PEPHubClient()._save_response(Mock()) - assert open_mock.called + assert os_remove_mock.called -def test_save_pep_locally(mocker, requests_get_mock): - save_response_mock = mocker.patch( - "pephubclient.pephubclient.PEPHubClient._save_response" +def test_pull(mocker, test_jwt): + mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + mocker.patch( + "requests.request", return_value=Mock(content=b"some_data", status_code=200) ) - PEPHubClient().save_pep_locally("test/project") + save_project_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.save_pep_project" + ) + + PEPHubClient().pull("some/project") - assert save_response_mock.called - assert requests_get_mock.called + assert save_project_mock.called @pytest.mark.parametrize( - "registry_path, expected_filename", + "status_code, expected_error_message", [ ( - RegistryPath(namespace="test", item="project", tag="2022"), - "test_project:2022.csv", + 404, + "Some error message", ), - (RegistryPath(namespace="test", item="project", tag=""), "test_project.csv"), + ( + 403, + "Some error message", + ), + (501, "Some error message"), ], ) -def test_create_filename_to_save_downloaded_project(registry_path, expected_filename): - pep_hub_client = PEPHubClient() - pep_hub_client.registry_path_data = registry_path - - assert ( - pep_hub_client._create_filename_to_save_downloaded_project() - == expected_filename +def test_pull_with_pephub_error_response( + mocker, test_jwt, status_code, expected_error_message +): + mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + mocker.patch( + "requests.request", + return_value=Mock( + content=b'{"detail": "Some error message"}', status_code=status_code + ), ) + + with pytest.raises(ResponseError) as e: + PEPHubClient().pull("some/project") + + assert e.value.message == expected_error_message From f572c2e67eeab385bd4acd5d2d3692b6b62bfcd7 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 16 Mar 2023 18:31:55 -0400 Subject: [PATCH 009/165] some work on auth --- pephubclient/pephub_oauth/__init__.py | 0 pephubclient/pephub_oauth/const.py | 7 ++ pephubclient/pephub_oauth/exceptions.py | 25 ++++++ pephubclient/pephub_oauth/models.py | 11 +++ pephubclient/pephub_oauth/pephub_oauth.py | 99 +++++++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 pephubclient/pephub_oauth/__init__.py create mode 100644 pephubclient/pephub_oauth/const.py create mode 100644 pephubclient/pephub_oauth/exceptions.py create mode 100644 pephubclient/pephub_oauth/models.py create mode 100644 pephubclient/pephub_oauth/pephub_oauth.py diff --git a/pephubclient/pephub_oauth/__init__.py b/pephubclient/pephub_oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pephubclient/pephub_oauth/const.py b/pephubclient/pephub_oauth/const.py new file mode 100644 index 00000000..e94c2207 --- /dev/null +++ b/pephubclient/pephub_oauth/const.py @@ -0,0 +1,7 @@ +# constants of pephub_auth + +PEPHUB_BASE_API_URL = "http://127.0.0.1:8000" +PEPHUB_DEVICE_INIT_URI = f"{PEPHUB_BASE_API_URL}/auth/device/init" +PEPHUB_DEVICE_TOKEN_URI = f"{PEPHUB_BASE_API_URL}/auth/device/token" + + diff --git a/pephubclient/pephub_oauth/exceptions.py b/pephubclient/pephub_oauth/exceptions.py new file mode 100644 index 00000000..e8d41e38 --- /dev/null +++ b/pephubclient/pephub_oauth/exceptions.py @@ -0,0 +1,25 @@ +"""auth exceptions""" + + +class PEPHubResponseException(Exception): + """Request response exception. Used when response != 200""" + + def __init__(self, reason: str = ""): + """ + Optionally provide explanation for exceptional condition. + :param str reason: some context or perhaps just a value that + could not be interpreted as an accession + """ + super(PEPHubResponseException, self).__init__(reason) + + +class PEPHubTokenExchangeException(Exception): + """Request response exception. Used when response != 200""" + + def __init__(self, reason: str = ""): + """ + Optionally provide explanation for exceptional condition. + :param str reason: some context or perhaps just a value that + could not be interpreted as an accession + """ + super(PEPHubTokenExchangeException, self).__init__(reason) diff --git a/pephubclient/pephub_oauth/models.py b/pephubclient/pephub_oauth/models.py new file mode 100644 index 00000000..db454f80 --- /dev/null +++ b/pephubclient/pephub_oauth/models.py @@ -0,0 +1,11 @@ +from typing import Optional +from pydantic import BaseModel + + +class InitializeDeviceCodeResponse(BaseModel): + device_code: str + auth_url: str + + +class PEPHubDeviceTokenResponse(BaseModel): + jwt_token: str diff --git a/pephubclient/pephub_oauth/pephub_oauth.py b/pephubclient/pephub_oauth/pephub_oauth.py new file mode 100644 index 00000000..e149224a --- /dev/null +++ b/pephubclient/pephub_oauth/pephub_oauth.py @@ -0,0 +1,99 @@ +import json +from typing import Type, Union +import requests +import time +from pydantic import BaseModel + +from pephubclient.helpers import RequestManager +from pephubclient.pephub_oauth.const import PEPHUB_DEVICE_INIT_URI, PEPHUB_DEVICE_TOKEN_URI +from pephubclient.pephub_oauth.models import InitializeDeviceCodeResponse, PEPHubDeviceTokenResponse +from pephubclient.pephub_oauth.exceptions import PEPHubResponseException, PEPHubTokenExchangeException + + + +class PEPHubAuth(RequestManager): + """ + Class responsible for authorization to PEPhub. + """ + + def login_to_pephub(self): + pephub_response = self._request_pephub_for_device_code() + print(f"User verification code: {pephub_response.device_code}, please go to the website: " + f"{pephub_response.auth_url} to authenticate.") + + time.sleep(2) + + while True: + try: + user_token = self._exchange_device_code_on_token(pephub_response.device_code) + except PEPHubTokenExchangeException: + time.sleep(2) + else: + return user_token + + + def _request_pephub_for_device_code(self) -> InitializeDeviceCodeResponse: + """ + Requests device code from pephub + """ + response = PEPHubAuth.send_request( + method="POST", + url=PEPHUB_DEVICE_INIT_URI, + params=None, + headers=None, + ) + return self._handle_pephub_response( + response, InitializeDeviceCodeResponse + ) + # return "device code" + + def _exchange_device_code_on_token(self, device_code: str) -> str: + """ + Send request with device dode to pephub in order to exchange it on JWT + :param device_code: device code that was generated by pephub + """ + response = PEPHubAuth.send_request( + method="POST", + url=PEPHUB_DEVICE_TOKEN_URI, + params=None, + headers={"device-code": device_code}, + ) + pephub_token_response = self._handle_pephub_response( + response, PEPHubDeviceTokenResponse + ) + return pephub_token_response.jwt_token + + @staticmethod + def _handle_pephub_response( + response: requests.Response, + model: Type[BaseModel] + ) -> Union[BaseModel, InitializeDeviceCodeResponse, PEPHubDeviceTokenResponse]: + """ + Decode the response from GitHub and pack the returned data into appropriate model. + + Args: + response: Response from GitHub. + model: Model that the data will be packed to. + + Returns: + Response data as an instance of correct model. + """ + if response.status_code == 401: + raise PEPHubTokenExchangeException + if response.status_code != 200: + raise PEPHubResponseException + try: + content = json.loads(PEPHubAuth.decode_response(response)) + except json.JSONDecodeError: + raise Exception("Something went wrong with GitHub response") + + try: + return model(**content) + except Exception: + raise Exception() + + + + + + From fdb657b0f62969559d191f403a10b9a7abf998a7 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sun, 19 Mar 2023 12:32:49 -0400 Subject: [PATCH 010/165] Fixed login loop --- pephubclient/pephub_oauth/pephub_oauth.py | 14 +++++++-- pephubclient/pephubclient.py | 38 +++++++++++++---------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/pephubclient/pephub_oauth/pephub_oauth.py b/pephubclient/pephub_oauth/pephub_oauth.py index e149224a..83dc3eca 100644 --- a/pephubclient/pephub_oauth/pephub_oauth.py +++ b/pephubclient/pephub_oauth/pephub_oauth.py @@ -23,14 +23,22 @@ def login_to_pephub(self): time.sleep(2) - while True: + for i in range(3): try: user_token = self._exchange_device_code_on_token(pephub_response.device_code) except PEPHubTokenExchangeException: time.sleep(2) else: + print("Successfully logged in!") return user_token - + input("If you logged in, press enter to continue...") + try: + user_token = self._exchange_device_code_on_token(pephub_response.device_code) + except PEPHubTokenExchangeException: + print("You are not logged in") + else: + print("Successfully logged in!") + return user_token def _request_pephub_for_device_code(self) -> InitializeDeviceCodeResponse: """ @@ -72,7 +80,7 @@ def _handle_pephub_response( Decode the response from GitHub and pack the returned data into appropriate model. Args: - response: Response from GitHub. + response: Response from PEPhub model: Model that the data will be packed to. Returns: diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 672d93b4..fa2f3814 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -14,11 +14,13 @@ ) from pephubclient.models import JWTDataResponse from pephubclient.models import ClientData -from error_handling.exceptions import ResponseError, IncorrectQueryStringError -from error_handling.constants import ResponseStatusCodes -from github_oauth_client.github_oauth_client import GitHubOAuthClient +# from error_handling.exceptions import ResponseError, IncorrectQueryStringError +# from error_handling.constants import ResponseStatusCodes +# from github_oauth_client.github_oauth_client import GitHubOAuthClient from pephubclient.files_manager import FilesManager -from helpers import RequestManager +from pephubclient.helpers import RequestManager + +from pephubclient.pephub_oauth.pephub_oauth import PEPHubAuth urllib3.disable_warnings() @@ -34,11 +36,12 @@ class PEPHubClient(RequestManager): def __init__(self): self.registry_path = None - self.github_client = GitHubOAuthClient() + # self.github_client = GitHubOAuthClient() - def login(self, client_data: ClientData) -> None: - jwt = self._request_jwt_from_pephub(client_data) - FilesManager.save_jwt_data_to_file(self.PATH_TO_FILE_WITH_JWT, jwt) + @staticmethod + def login(self) -> None: + user_token = PEPHubAuth().login_to_pephub() + FilesManager.save_jwt_data_to_file(self.PATH_TO_FILE_WITH_JWT, user_token) def logout(self) -> None: FilesManager.delete_file_if_exists(self.PATH_TO_FILE_WITH_JWT) @@ -98,14 +101,14 @@ def _load_pep( parsed_response = self._handle_pephub_response(pephub_response) return self._load_pep_project(parsed_response) - @staticmethod - def _handle_pephub_response(pephub_response: requests.Response): - decoded_response = PEPHubClient.decode_response(pephub_response) - - if pephub_response.status_code != ResponseStatusCodes.OK_200: - raise ResponseError(message=json.loads(decoded_response).get("detail")) - else: - return decoded_response + # @staticmethod + # def _handle_pephub_response(pephub_response: requests.Response): + # decoded_response = PEPHubClient.decode_response(pephub_response) + # + # if pephub_response.status_code != ResponseStatusCodes.OK_200: + # raise ResponseError(message=json.loads(decoded_response).get("detail")) + # else: + # return decoded_response def _request_jwt_from_pephub(self, client_data: ClientData) -> str: pephub_response = self.send_request( @@ -130,7 +133,8 @@ def _set_registry_data(self, query_string: str) -> None: try: self.registry_path = RegistryPath(**parse_registry_path(query_string)) except (ValidationError, TypeError): - raise IncorrectQueryStringError(query_string=query_string) + # raise IncorrectQueryStringError(query_string=query_string) + pass @staticmethod def _get_cookies(jwt_data: Optional[str] = None) -> dict: From 5907fb66e5e7565fbd27151893abfa935a2e1fb7 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sun, 19 Mar 2023 12:47:01 -0400 Subject: [PATCH 011/165] Fixed downloading file --- pephubclient/constants.py | 3 +-- pephubclient/pephubclient.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pephubclient/constants.py b/pephubclient/constants.py index 269106b2..dc4a727d 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -6,8 +6,7 @@ # PEPHUB_PEP_API_BASE_URL = "https://pephub.databio.org/pep/" # PEPHUB_LOGIN_URL = "https://pephub.databio.org/auth/login" PEPHUB_BASE_URL = "http://0.0.0.0:8000/" -PEPHUB_PEP_API_BASE_URL = "http://0.0.0.0:8000/pep/" -PEPHUB_LOGIN_URL = "http://127.0.0.1:8000/auth/login" +PEPHUB_PEP_API_BASE_URL = "http://0.0.0.0:8000/api/v1/projects/" class RegistryPath(BaseModel): diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index fa2f3814..afadeeae 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -65,6 +65,7 @@ def _save_pep_locally( """ self._set_registry_data(query_string) + urlf = self._build_request_url(variables), pephub_response = self.send_request( method="GET", url=self._build_request_url(variables), @@ -101,14 +102,14 @@ def _load_pep( parsed_response = self._handle_pephub_response(pephub_response) return self._load_pep_project(parsed_response) - # @staticmethod - # def _handle_pephub_response(pephub_response: requests.Response): - # decoded_response = PEPHubClient.decode_response(pephub_response) - # - # if pephub_response.status_code != ResponseStatusCodes.OK_200: - # raise ResponseError(message=json.loads(decoded_response).get("detail")) - # else: - # return decoded_response + @staticmethod + def _handle_pephub_response(pephub_response: requests.Response): + decoded_response = PEPHubClient.decode_response(pephub_response) + + # if pephub_response.status_code != ResponseStatusCodes.OK_200: + # raise ResponseError(message=json.loads(decoded_response).get("detail")) + # else: + return decoded_response def _request_jwt_from_pephub(self, client_data: ClientData) -> str: pephub_response = self.send_request( @@ -158,6 +159,7 @@ def _build_request_url(self, variables: dict) -> str: + self.registry_path.item + "/" + PEPHubClient.CONVERT_ENDPOINT + + f"&tag={self.registry_path.tag}" ) if variables: variables_string = PEPHubClient._parse_variables(variables) From 2600c098c480624fa4a3ac39f18deca84f41a062 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 20 Mar 2023 00:28:36 -0400 Subject: [PATCH 012/165] Fixed pulling file 2 --- github_oauth_client/__init__.py | 0 github_oauth_client/constants.py | 8 -- github_oauth_client/github_oauth_client.py | 106 --------------------- github_oauth_client/models.py | 16 ---- pephubclient/cli.py | 12 +-- helpers.py => pephubclient/helpers.py | 3 +- pephubclient/pephubclient.py | 27 +++--- 7 files changed, 18 insertions(+), 154 deletions(-) delete mode 100644 github_oauth_client/__init__.py delete mode 100644 github_oauth_client/constants.py delete mode 100644 github_oauth_client/github_oauth_client.py delete mode 100644 github_oauth_client/models.py rename helpers.py => pephubclient/helpers.py (90%) diff --git a/github_oauth_client/__init__.py b/github_oauth_client/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/github_oauth_client/constants.py b/github_oauth_client/constants.py deleted file mode 100644 index 7a631a95..00000000 --- a/github_oauth_client/constants.py +++ /dev/null @@ -1,8 +0,0 @@ -GITHUB_BASE_API_URL = "https://api.github.com" -GITHUB_BASE_LOGIN_URL = "https://github.com/login" -GITHUB_VERIFICATION_CODES_ENDPOINT = "/device/code" -GITHUB_OAUTH_ENDPOINT = "/oauth/access_token" - -HEADERS = {"Content-Type": "application/json", "Accept": "application/json"} -ENCODING = "utf-8" -GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" diff --git a/github_oauth_client/github_oauth_client.py b/github_oauth_client/github_oauth_client.py deleted file mode 100644 index 53ff9475..00000000 --- a/github_oauth_client/github_oauth_client.py +++ /dev/null @@ -1,106 +0,0 @@ -import json -from typing import Type -from error_handling.error_handler import ErrorHandler -import requests -from github_oauth_client.constants import ( - GITHUB_BASE_LOGIN_URL, - GITHUB_OAUTH_ENDPOINT, - GITHUB_VERIFICATION_CODES_ENDPOINT, - GRANT_TYPE, - HEADERS, -) -from github_oauth_client.models import ( - AccessTokenResponseModel, - VerificationCodesResponseModel, -) -from pephubclient.models import ClientData -from pydantic import BaseModel, ValidationError -from error_handling.exceptions import ResponseError -from helpers import RequestManager - - -class GitHubOAuthClient(RequestManager): - """ - Class responsible for authorization with GitHub. - """ - - def get_access_token(self, client_data: ClientData): - """ - Requests user with specified ClientData.client_id to enter the verification code, and then - responds with GitHub access token. - """ - device_code = self._get_device_verification_code(client_data) - return self._request_github_for_access_token(device_code, client_data) - - def _get_device_verification_code(self, client_data: ClientData) -> str: - """ - Send the request for verification codes, parse the response and return device code. - - Returns: - Device code which is needed later to obtain the access code. - """ - resp = GitHubOAuthClient.send_request( - method="POST", - url=f"{GITHUB_BASE_LOGIN_URL}{GITHUB_VERIFICATION_CODES_ENDPOINT}", - params={"client_id": client_data.client_id}, - headers=HEADERS, - ) - verification_codes_response = self._handle_github_response( - resp, VerificationCodesResponseModel - ) - print( - f"User verification code: {verification_codes_response.user_code}, " - f"please enter the code here: {verification_codes_response.verification_uri} and" - f"hit enter when you are done with authentication on the website" - ) - input() - - return verification_codes_response.device_code - - def _request_github_for_access_token( - self, device_code: str, client_data: ClientData - ) -> str: - """ - Send the request for access token, parse the response and return access token. - - Args: - device_code: Device code from verification codes request. - - Returns: - Access token. - """ - response = GitHubOAuthClient.send_request( - method="POST", - url=f"{GITHUB_BASE_LOGIN_URL}{GITHUB_OAUTH_ENDPOINT}", - params={ - "client_id": client_data.client_id, - "device_code": device_code, - "grant_type": GRANT_TYPE, - }, - headers=HEADERS, - ) - return self._handle_github_response( - response, AccessTokenResponseModel - ).access_token - - @staticmethod - def _handle_github_response(response: requests.Response, model: Type[BaseModel]): - """ - Decode the response from GitHub and pack the returned data into appropriate model. - - Args: - response: Response from GitHub. - model: Model that the data will be packed to. - - Returns: - Response data as an instance of correct model. - """ - try: - content = json.loads(GitHubOAuthClient.decode_response(response)) - except json.JSONDecodeError: - raise ResponseError("Something went wrong with GitHub response") - - try: - return model(**content) - except ValidationError: - raise ErrorHandler.parse_github_response_error(content) or ResponseError() diff --git a/github_oauth_client/models.py b/github_oauth_client/models.py deleted file mode 100644 index e0849088..00000000 --- a/github_oauth_client/models.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Optional -from pydantic import BaseModel - - -class VerificationCodesResponseModel(BaseModel): - device_code: str - user_code: str - verification_uri: str - expires_in: Optional[int] - interval: Optional[int] - - -class AccessTokenResponseModel(BaseModel): - access_token: str - scope: Optional[str] - token_type: Optional[str] diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 96155378..cb643bac 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -1,22 +1,14 @@ import typer -from github_oauth_client.github_oauth_client import GitHubOAuthClient from pephubclient import __app_name__, __version__ from pephubclient.pephubclient import PEPHubClient -from pephubclient.models import ClientData - - -GITHUB_CLIENT_ID = "20a452cc59b908235e50" - pep_hub_client = PEPHubClient() -github_client = GitHubOAuthClient() -app = typer.Typer() -client_data = ClientData(client_id=GITHUB_CLIENT_ID) +app = typer.Typer() @app.command() def login(): - pep_hub_client.login(client_data) + pep_hub_client.login() @app.command() diff --git a/helpers.py b/pephubclient/helpers.py similarity index 90% rename from helpers.py rename to pephubclient/helpers.py index 1468c33f..8c01c70c 100644 --- a/helpers.py +++ b/pephubclient/helpers.py @@ -4,7 +4,6 @@ import requests from error_handling.exceptions import ResponseError -from github_oauth_client.constants import ENCODING class RequestManager: @@ -38,6 +37,6 @@ def decode_response(response: requests.Response) -> str: Response data as an instance of correct model. """ try: - return response.content.decode(ENCODING) + return response.content.decode("utf-8") except json.JSONDecodeError: raise ResponseError() diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index afadeeae..e1f7d3e4 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -16,7 +16,6 @@ from pephubclient.models import ClientData # from error_handling.exceptions import ResponseError, IncorrectQueryStringError # from error_handling.constants import ResponseStatusCodes -# from github_oauth_client.github_oauth_client import GitHubOAuthClient from pephubclient.files_manager import FilesManager from pephubclient.helpers import RequestManager @@ -36,9 +35,7 @@ class PEPHubClient(RequestManager): def __init__(self): self.registry_path = None - # self.github_client = GitHubOAuthClient() - @staticmethod def login(self) -> None: user_token = PEPHubAuth().login_to_pephub() FilesManager.save_jwt_data_to_file(self.PATH_TO_FILE_WITH_JWT, user_token) @@ -65,16 +62,21 @@ def _save_pep_locally( """ self._set_registry_data(query_string) - urlf = self._build_request_url(variables), pephub_response = self.send_request( method="GET", url=self._build_request_url(variables), - cookies=self._get_cookies(jwt_data), - ) - decoded_response = self._handle_pephub_response(pephub_response) - FilesManager.save_pep_project( - decoded_response, registry_path=self.registry_path + headers=self._get_header(jwt_data), + cookies=None, ) + if pephub_response.status_code == 200: + decoded_response = self._handle_pephub_response(pephub_response) + FilesManager.save_pep_project( + decoded_response, registry_path=self.registry_path + ) + elif pephub_response.status_code == 404: + print("File doesn't exist, or are unauthorized.") + else: + print("Unknown error occurred.") def _load_pep( self, @@ -97,7 +99,8 @@ def _load_pep( pephub_response = self.send_request( method="GET", url=self._build_request_url(variables), - cookies=self._get_cookies(jwt_data), + headers=self._get_header(jwt_data), + cookies=None, ) parsed_response = self._handle_pephub_response(pephub_response) return self._load_pep_project(parsed_response) @@ -138,9 +141,9 @@ def _set_registry_data(self, query_string: str) -> None: pass @staticmethod - def _get_cookies(jwt_data: Optional[str] = None) -> dict: + def _get_header(jwt_data: Optional[str] = None) -> dict: if jwt_data: - return {"pephub_session": jwt_data} + return {"Authorization": jwt_data} else: return {} From 6ece7160cd9e4acf5a33ac42eaf7bcc05448990f Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 20 Mar 2023 16:47:07 -0400 Subject: [PATCH 013/165] added setup, license, requirements --- LICENSE.txt | 9 +++ MANIFEST.in | 3 + error_handling/constants.py | 7 -- error_handling/error_handler.py | 16 ----- error_handling/models.py | 6 -- pephubclient/__init__.py | 4 +- pephubclient/constants.py | 8 ++- pephubclient/error_handler.py | 2 + .../exceptions.py | 0 pephubclient/helpers.py | 2 +- pephubclient/pephubclient.py | 11 ++- rafalstepien_public_project.csv | 3 - requirements/requirements-all.txt | 0 requirements/requirements-dev.txt | 0 setup.py | 64 ++++++++++++++++++ static/pephubclient_login.png | Bin 86674 -> 0 bytes static/pephubclient_pull.png | Bin 88093 -> 0 bytes tests/test_pephubclient.py | 2 +- 18 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 LICENSE.txt create mode 100644 MANIFEST.in delete mode 100644 error_handling/constants.py delete mode 100644 error_handling/error_handler.py delete mode 100644 error_handling/models.py create mode 100644 pephubclient/error_handler.py rename {error_handling => pephubclient}/exceptions.py (100%) delete mode 100644 rafalstepien_public_project.csv create mode 100644 requirements/requirements-all.txt create mode 100644 requirements/requirements-dev.txt create mode 100644 setup.py delete mode 100644 static/pephubclient_login.png delete mode 100644 static/pephubclient_pull.png diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..26c0003c --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,9 @@ +Copyright 2023 Nathan Sheffield + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..4797e8e0 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include requirements/* +include README.md +include pephubclient/pephub_oauth/* \ No newline at end of file diff --git a/error_handling/constants.py b/error_handling/constants.py deleted file mode 100644 index 3528d0f2..00000000 --- a/error_handling/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -from enum import Enum - - -class ResponseStatusCodes(int, Enum): - FORBIDDEN_403 = 403 - NOT_EXIST_404 = 404 - OK_200 = 200 diff --git a/error_handling/error_handler.py b/error_handling/error_handler.py deleted file mode 100644 index cb815b97..00000000 --- a/error_handling/error_handler.py +++ /dev/null @@ -1,16 +0,0 @@ -from error_handling.exceptions import AuthorizationPendingError -from typing import Union -import pydantic -from error_handling.models import GithubErrorModel -from contextlib import suppress - - -class ErrorHandler: - @staticmethod - def parse_github_response_error(github_response) -> Union[Exception, None]: - with suppress(pydantic.ValidationError): - GithubErrorModel(**github_response) - return AuthorizationPendingError( - message="You must first authorize with GitHub by using " - "provided code." - ) diff --git a/error_handling/models.py b/error_handling/models.py deleted file mode 100644 index 044f8c6f..00000000 --- a/error_handling/models.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class GithubErrorModel(BaseModel): - error: str - error_description: str diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 1367e951..97b8c9c5 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,5 +1,5 @@ __app_name__ = "pephubclient" __version__ = "0.1.0" +__author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" - -__all__ = ["__app_name__", "__version__"] +__all__ = ["__app_name__", "__version__", "__author__"] diff --git a/pephubclient/constants.py b/pephubclient/constants.py index dc4a727d..d2e0a094 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -1,5 +1,5 @@ from typing import Optional - +from enum import Enum from pydantic import BaseModel # PEPHUB_BASE_URL = "https://pephub.databio.org/" @@ -15,3 +15,9 @@ class RegistryPath(BaseModel): item: str subitem: Optional[str] tag: Optional[str] + + +class ResponseStatusCodes(int, Enum): + FORBIDDEN_403 = 403 + NOT_EXIST_404 = 404 + OK_200 = 200 diff --git a/pephubclient/error_handler.py b/pephubclient/error_handler.py new file mode 100644 index 00000000..c8e08cd9 --- /dev/null +++ b/pephubclient/error_handler.py @@ -0,0 +1,2 @@ +# Here should be error handler + diff --git a/error_handling/exceptions.py b/pephubclient/exceptions.py similarity index 100% rename from error_handling/exceptions.py rename to pephubclient/exceptions.py diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 8c01c70c..bd2878af 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -3,7 +3,7 @@ import requests -from error_handling.exceptions import ResponseError +from pephubclient.exceptions import ResponseError class RequestManager: diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index e1f7d3e4..63966966 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -11,11 +11,10 @@ PEPHUB_BASE_URL, PEPHUB_PEP_API_BASE_URL, RegistryPath, + ResponseStatusCodes ) from pephubclient.models import JWTDataResponse from pephubclient.models import ClientData -# from error_handling.exceptions import ResponseError, IncorrectQueryStringError -# from error_handling.constants import ResponseStatusCodes from pephubclient.files_manager import FilesManager from pephubclient.helpers import RequestManager @@ -109,10 +108,10 @@ def _load_pep( def _handle_pephub_response(pephub_response: requests.Response): decoded_response = PEPHubClient.decode_response(pephub_response) - # if pephub_response.status_code != ResponseStatusCodes.OK_200: - # raise ResponseError(message=json.loads(decoded_response).get("detail")) - # else: - return decoded_response + if pephub_response.status_code != ResponseStatusCodes.OK_200: + raise ResponseError(message=json.loads(decoded_response).get("detail")) + else: + return decoded_response def _request_jwt_from_pephub(self, client_data: ClientData) -> str: pephub_response = self.send_request( diff --git a/rafalstepien_public_project.csv b/rafalstepien_public_project.csv deleted file mode 100644 index 2d4483a6..00000000 --- a/rafalstepien_public_project.csv +++ /dev/null @@ -1,3 +0,0 @@ -file,protocol,sample_name -data/frog1_data.txt,anySampleType,frog_1 -data/frog2_data.txt,anySampleType,frog_2 diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt new file mode 100644 index 00000000..e69de29b diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt new file mode 100644 index 00000000..e69de29b diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..17aca4d1 --- /dev/null +++ b/setup.py @@ -0,0 +1,64 @@ +import sys +import os +from setuptools import find_packages, setup +from pephubclient import __app_name__, __version__, __author__ + +PACKAGE = __app_name__ +REQDIR = "requirements" + +# Additional keyword arguments for setup(). +extra = {} + +# Ordinary dependencies +def read_reqs(reqs_name): + deps = [] + with open(os.path.join(REQDIR, f"requirements-{reqs_name}.txt"), "r") as f: + for l in f: + if not l.strip(): + continue + deps.append(l) + return deps + + +DEPENDENCIES = read_reqs("all") +extra["install_requires"] = DEPENDENCIES + +scripts = None + +with open("README.md") as f: + long_description = f.read() + +setup( + name=PACKAGE, + packages=[PACKAGE], + version=__version__, + description="PEPhub command line interface.", + long_description=long_description, + long_description_content_type="text/markdown", + classifiers=[ + "Development Status :: 1 - Planing", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Scientific/Engineering :: Bio-Informatics", + ], + keywords="project, bioinformatics, metadata", + url=f"https://github.com/databio/{PACKAGE}/", + author=__author__, + license="BSD2", + entry_points={ + "console_scripts": [ + "pephubclient = pephubclient.__main__:main", + ], + }, + package_data={PACKAGE: ["templates/*"]}, + scripts=scripts, + include_package_data=True, + test_suite="tests", + tests_require=read_reqs("dev"), + setup_requires=( + ["pytest-runner"] if {"test", "pytest", "ptr"} & set(sys.argv) else [] + ), + **extra, +) \ No newline at end of file diff --git a/static/pephubclient_login.png b/static/pephubclient_login.png deleted file mode 100644 index 61be8b043e40ceed761e88c6abd62923b28232c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86674 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%0A-+!+;{+YQi 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 R1NXo`sB6kHnNqhO{U3f9Ml%2a diff --git a/static/pephubclient_pull.png b/static/pephubclient_pull.png deleted file mode 100644 index 64a795c8a9d29f7e2d1853c1aee57ac4aaa9fd1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 88093 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 O|4_23=hLN){Qe)%n^@)m diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 9be7080d..82f77aa2 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import Mock, patch, mock_open from pephubclient.pephubclient import PEPHubClient -from error_handling.exceptions import ResponseError +from pephubclient.exceptions import ResponseError def test_login(mocker, test_jwt_response, test_client_data, test_access_token): From 032963826b55aed1ff0a5105895030bbd82c6612 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 20 Mar 2023 16:50:58 -0400 Subject: [PATCH 014/165] fixed requirements --- requirements/requirements-all.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index e69de29b..4999d3cc 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -0,0 +1,4 @@ +typer>=0.7.0 +peppy>=0.35.4 +requests>=2.28.2 +pydantic>=1.10.6 From 9c739eeb5f3354662641829ab72ae194b7c0c25b Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 29 Mar 2023 16:44:38 -0400 Subject: [PATCH 015/165] added loading project into peppy_obj --- pephubclient/cli.py | 41 +++- pephubclient/error_handler.py | 1 - pephubclient/helpers.py | 9 +- pephubclient/models.py | 18 +- pephubclient/pephub_oauth/const.py | 2 - pephubclient/pephub_oauth/pephub_oauth.py | 43 ++-- pephubclient/pephubclient.py | 233 ++++++++++++++++------ setup.py | 5 +- 8 files changed, 252 insertions(+), 100 deletions(-) diff --git a/pephubclient/cli.py b/pephubclient/cli.py index cb643bac..2509f5ce 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -6,19 +6,56 @@ app = typer.Typer() + @app.command() def login(): + """ + Login to Pephub + """ pep_hub_client.login() @app.command() def logout(): + """ + Logout + """ pep_hub_client.logout() @app.command() -def pull(project_query_string: str): - pep_hub_client.pull(project_query_string) +def pull( + project_registry_path: str, + # output_dir: str = typer.Option(None, help="Specify the location where the file should be saved"), + project_format: str = typer.Option("default", help="Project format in which project should be saved" + "Options: [default, basic, csv, yaml, zip]."), + force: bool = typer.Option(False, help="Last name of person to greet."), +): + """ + Download and save project locally. + """ + pep_hub_client.pull(project_registry_path, project_format, force) + + + +@app.command() +def push( + cfg: str = typer.Option( + ..., + help="Project config file (YAML) or sample table (CSV/TSV)" + "with one row per sample to constitute project", + ), + namespace: str = typer.Option(..., help="Project name"), + name: str = typer.Option(..., help="Project name"), + tag: str = typer.Option(None, help="Project tag"), + force: bool = typer.Option( + False, help="Force push to the database. Use it to update, or upload project." + ), +): + """ + Upload/update project in PEPhub + """ + ... @app.command() diff --git a/pephubclient/error_handler.py b/pephubclient/error_handler.py index c8e08cd9..8619d940 100644 --- a/pephubclient/error_handler.py +++ b/pephubclient/error_handler.py @@ -1,2 +1 @@ # Here should be error handler - diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index bd2878af..fa1ae2b0 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,6 +1,5 @@ import json from typing import Optional - import requests from pephubclient.exceptions import ResponseError @@ -29,12 +28,8 @@ def decode_response(response: requests.Response) -> str: """ Decode the response from GitHub and pack the returned data into appropriate model. - Args: - response: Response from GitHub. - model: Model that the data will be packed to. - - Returns: - Response data as an instance of correct model. + :param response: Response from GitHub. + :return: Response data as an instance of correct model. """ try: return response.content.decode("utf-8") diff --git a/pephubclient/models.py b/pephubclient/models.py index 0b0686f0..aa0bea88 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -1,4 +1,5 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field, Extra +from typing import Optional class JWTDataResponse(BaseModel): @@ -7,3 +8,18 @@ class JWTDataResponse(BaseModel): class ClientData(BaseModel): client_id: str + + +class ProjectDict(BaseModel): + """ + Project dict (raw) model + """ + description: Optional[str] = "" + config: dict = Field(alias="_config") + subsample_dict: Optional[dict] = Field(alias="_subsample_dict") + name: str + sample_dict: dict = Field(alias="_sample_dict") + + class Config: + allow_population_by_field_name = True + extra = Extra.allow diff --git a/pephubclient/pephub_oauth/const.py b/pephubclient/pephub_oauth/const.py index e94c2207..4c63023a 100644 --- a/pephubclient/pephub_oauth/const.py +++ b/pephubclient/pephub_oauth/const.py @@ -3,5 +3,3 @@ PEPHUB_BASE_API_URL = "http://127.0.0.1:8000" PEPHUB_DEVICE_INIT_URI = f"{PEPHUB_BASE_API_URL}/auth/device/init" PEPHUB_DEVICE_TOKEN_URI = f"{PEPHUB_BASE_API_URL}/auth/device/token" - - diff --git a/pephubclient/pephub_oauth/pephub_oauth.py b/pephubclient/pephub_oauth/pephub_oauth.py index 83dc3eca..e3e77d59 100644 --- a/pephubclient/pephub_oauth/pephub_oauth.py +++ b/pephubclient/pephub_oauth/pephub_oauth.py @@ -5,10 +5,18 @@ from pydantic import BaseModel from pephubclient.helpers import RequestManager -from pephubclient.pephub_oauth.const import PEPHUB_DEVICE_INIT_URI, PEPHUB_DEVICE_TOKEN_URI -from pephubclient.pephub_oauth.models import InitializeDeviceCodeResponse, PEPHubDeviceTokenResponse -from pephubclient.pephub_oauth.exceptions import PEPHubResponseException, PEPHubTokenExchangeException - +from pephubclient.pephub_oauth.const import ( + PEPHUB_DEVICE_INIT_URI, + PEPHUB_DEVICE_TOKEN_URI, +) +from pephubclient.pephub_oauth.models import ( + InitializeDeviceCodeResponse, + PEPHubDeviceTokenResponse, +) +from pephubclient.pephub_oauth.exceptions import ( + PEPHubResponseException, + PEPHubTokenExchangeException, +) class PEPHubAuth(RequestManager): @@ -18,14 +26,18 @@ class PEPHubAuth(RequestManager): def login_to_pephub(self): pephub_response = self._request_pephub_for_device_code() - print(f"User verification code: {pephub_response.device_code}, please go to the website: " - f"{pephub_response.auth_url} to authenticate.") + print( + f"User verification code: {pephub_response.device_code}, please go to the website: " + f"{pephub_response.auth_url} to authenticate." + ) time.sleep(2) for i in range(3): try: - user_token = self._exchange_device_code_on_token(pephub_response.device_code) + user_token = self._exchange_device_code_on_token( + pephub_response.device_code + ) except PEPHubTokenExchangeException: time.sleep(2) else: @@ -33,7 +45,9 @@ def login_to_pephub(self): return user_token input("If you logged in, press enter to continue...") try: - user_token = self._exchange_device_code_on_token(pephub_response.device_code) + user_token = self._exchange_device_code_on_token( + pephub_response.device_code + ) except PEPHubTokenExchangeException: print("You are not logged in") else: @@ -50,9 +64,7 @@ def _request_pephub_for_device_code(self) -> InitializeDeviceCodeResponse: params=None, headers=None, ) - return self._handle_pephub_response( - response, InitializeDeviceCodeResponse - ) + return self._handle_pephub_response(response, InitializeDeviceCodeResponse) # return "device code" def _exchange_device_code_on_token(self, device_code: str) -> str: @@ -73,8 +85,7 @@ def _exchange_device_code_on_token(self, device_code: str) -> str: @staticmethod def _handle_pephub_response( - response: requests.Response, - model: Type[BaseModel] + response: requests.Response, model: Type[BaseModel] ) -> Union[BaseModel, InitializeDeviceCodeResponse, PEPHubDeviceTokenResponse]: """ Decode the response from GitHub and pack the returned data into appropriate model. @@ -99,9 +110,3 @@ def _handle_pephub_response( return model(**content) except Exception: raise Exception() - - - - - - diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 63966966..31f553b2 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,6 +1,6 @@ import os import json -from typing import Optional +from typing import Optional, NoReturn import peppy import requests import urllib3 @@ -11,10 +11,9 @@ PEPHUB_BASE_URL, PEPHUB_PEP_API_BASE_URL, RegistryPath, - ResponseStatusCodes + ResponseStatusCodes, ) -from pephubclient.models import JWTDataResponse -from pephubclient.models import ClientData +from pephubclient.models import ProjectDict from pephubclient.files_manager import FilesManager from pephubclient.helpers import RequestManager @@ -35,35 +34,121 @@ class PEPHubClient(RequestManager): def __init__(self): self.registry_path = None - def login(self) -> None: + def login(self) -> NoReturn: + """ + Log in to PEPhub + :return: None + """ user_token = PEPHubAuth().login_to_pephub() FilesManager.save_jwt_data_to_file(self.PATH_TO_FILE_WITH_JWT, user_token) - def logout(self) -> None: + def logout(self) -> NoReturn: + """ + Log out from PEPhub + :return: NoReturn + """ FilesManager.delete_file_if_exists(self.PATH_TO_FILE_WITH_JWT) - def pull(self, project_query_string: str): + def pull( + self, + project_registry_path: str, + project_format: Optional[str] = "default", + force: Optional[bool] = False + ) -> None: + """ + Downl + :param str project_registry_path: Project registry path in PEPhub (e.g. databio/base:default) + :param str project_format: project format to be saved. Options: [default, zip] + :param bool force: if project exists, overwrite it. + :return: None + """ + jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) + self._save_pep_locally(project_registry_path, jwt_data, project_format) + + def load_project( + self, + project_registry_path: str, + query_param: Optional[dict] = None, + ) -> peppy.Project: + """ + Load peppy project from PEPhub in peppy.Project object + :param project_registry_path: registry path of the 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) - self._save_pep_locally(project_query_string, jwt) + raw_pep = self._load_raw_pep(project_registry_path, jwt, query_param) + peppy_project = peppy.Project().from_dict(raw_pep) + return peppy_project + + def push( + self, + cfg: str, + namespace: str, + name: Optional[str] = None, + tag: Optional[str] = None, + is_private: Optional[bool] = False, + force: Optional[bool] = False, + ) -> None: + """ + Push (upload/update) project to Pephub using config/csv path + :param str cfg: Project config file (YAML) or sample table (CSV/TSV) + with one row per sample to constitute project + :param str namespace: namespace + :param str name: project name + :param str tag: project tag + :param bool is_private: Specifies whether project should be private [Default= False] + :param bool force: Force push to the database. Use it to update, or upload project. [Default= False] + :return: None + """ + peppy_project = peppy.Project(cfg=cfg) + + self.upload(project=peppy_project, + namespace=namespace, + name=name, + tag=tag, + is_private=is_private, + force=force,) + + def upload( + self, + project: peppy.Project, + namespace: str, + name: str = None, + tag: str = None, + is_private: bool = False, + force: bool = True, + ) -> None: + """ + Upload peppy project to the PEPhub. + :param peppy.Project project: Project object that has to be uploaded to the DB + :param namespace: namespace + :param name: project name + :param tag: project tag + :param force: Force push to the database. Use it to update, or upload project. + :param is_private: + :param force: + :return: None + """ + ... + def _save_pep_locally( self, - query_string: str, + registry_path: str, jwt_data: Optional[str] = None, - variables: Optional[dict] = None, + query_param: dict = None ) -> None: """ Request PEPhub and save the requested project on the disk. - Args: - query_string: Project namespace, eg. "geo/GSE124224" - variables: Optional variables to be passed to PEPhub - + :param registry_path: Project namespace, eg. "geo/GSE124224" """ - self._set_registry_data(query_string) + query_param = {"raw": "true"} + self._set_registry_data(registry_path) pephub_response = self.send_request( method="GET", - url=self._build_request_url(variables), + url=self._build_pull_request_url(query_param=query_param), headers=self._get_header(jwt_data), cookies=None, ) @@ -73,65 +158,58 @@ def _save_pep_locally( decoded_response, registry_path=self.registry_path ) elif pephub_response.status_code == 404: - print("File doesn't exist, or are unauthorized.") + print("File does not exist, or you are unauthorized.") + elif pephub_response.status_code == 500: + print("Internal server error.") else: - print("Unknown error occurred.") + print(f"Unknown error occurred. Status: {pephub_response.status_code}") - def _load_pep( + def _load_raw_pep( self, - query_string: str, - variables: Optional[dict] = None, + registry_path: str, jwt_data: Optional[str] = None, - ) -> Project: + query_param: Optional[dict] = None, + ) -> dict: """ Request PEPhub and return the requested project as peppy.Project object. - Args: - query_string: Project namespace, eg. "geo/GSE124224" - variables: Optional variables to be passed to PEPhub - jwt_data: JWT token. + :param registry_path: Project namespace, eg. "geo/GSE124224:tag" + :param query_param: Optional variables to be passed to PEPhub + :param jwt_data: JWT token. - Returns: - Downloaded project as object. + :return: Raw project in dict. """ - self._set_registry_data(query_string) + if not query_param: + query_param = {} + query_param["raw"] = "true" + + self._set_registry_data(registry_path) pephub_response = self.send_request( method="GET", - url=self._build_request_url(variables), + url=self._build_pull_request_url(query_param=query_param), headers=self._get_header(jwt_data), cookies=None, ) - parsed_response = self._handle_pephub_response(pephub_response) - return self._load_pep_project(parsed_response) + if pephub_response.status_code == 200: + decoded_response = self._handle_pephub_response(pephub_response) + correct_proj_dict = ProjectDict(**json.loads(decoded_response)) - @staticmethod - def _handle_pephub_response(pephub_response: requests.Response): - decoded_response = PEPHubClient.decode_response(pephub_response) + # This step is necessary because of this issue: https://github.com/pepkit/pephub/issues/124 + return correct_proj_dict.dict(by_alias=True) - if pephub_response.status_code != ResponseStatusCodes.OK_200: - raise ResponseError(message=json.loads(decoded_response).get("detail")) + elif pephub_response.status_code == 404: + print("File does not exist, or you are unauthorized.") + elif pephub_response.status_code == 500: + print("Internal server error.") else: - return decoded_response - - def _request_jwt_from_pephub(self, client_data: ClientData) -> str: - pephub_response = self.send_request( - method="POST", - url=PEPHUB_BASE_URL + self.CLI_LOGIN_ENDPOINT, - headers={"access-token": self.github_client.get_access_token(client_data)}, - ) - return JWTDataResponse( - **json.loads(PEPHubClient.decode_response(pephub_response)) - ).jwt_token + print(f"Unknown error occurred. Status: {pephub_response.status_code}") def _set_registry_data(self, query_string: str) -> None: """ Parse provided query string to extract project name, sample name, etc. - Args: - query_string: Passed by user. Contain information needed to locate the project. - - Returns: - Parsed query string. + :param query_string: Passed by user. Contain information needed to locate the project. + :return: Parsed query string. """ try: self.registry_path = RegistryPath(**parse_registry_path(query_string)) @@ -154,32 +232,55 @@ def _load_pep_project(self, pep_project: str) -> peppy.Project: FilesManager.delete_file_if_exists(self.DEFAULT_PROJECT_FILENAME) return project - def _build_request_url(self, variables: dict) -> str: + def _build_pull_request_url(self, query_param: dict = None) -> str: + if not query_param: + query_param = {} + query_param["tag"] = self.registry_path.tag endpoint = ( - self.registry_path.namespace - + "/" - + self.registry_path.item - + "/" - + PEPHubClient.CONVERT_ENDPOINT - + f"&tag={self.registry_path.tag}" + self.registry_path.namespace + + "/" + + self.registry_path.item ) - if variables: - variables_string = PEPHubClient._parse_variables(variables) + if query_param: + variables_string = PEPHubClient._parse_query_param(query_param) + endpoint += variables_string + return PEPHUB_PEP_API_BASE_URL + endpoint + + def _build_zip_request_url(self, query_param: dict = None) -> str: + if not query_param: + query_param = {} + query_param["tag"] = self.registry_path.tag + endpoint = ( + self.registry_path.namespace + + "/" + + self.registry_path.item + + "/" + + "zip" + ) + if query_param: + variables_string = PEPHubClient._parse_query_param(query_param) endpoint += variables_string return PEPHUB_PEP_API_BASE_URL + endpoint @staticmethod - def _parse_variables(pep_variables: dict) -> str: + 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. - Returns: - PEPHubClient variables transformed into string in correct format. + :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): + decoded_response = PEPHubClient.decode_response(pephub_response) + + # if pephub_response.status_code != ResponseStatusCodes.OK_200: + # raise ResponseError(message=json.loads(decoded_response).get("detail")) + # else: + return decoded_response \ No newline at end of file diff --git a/setup.py b/setup.py index 17aca4d1..6bc2e526 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,7 @@ # Additional keyword arguments for setup(). extra = {} + # Ordinary dependencies def read_reqs(reqs_name): deps = [] @@ -49,7 +50,7 @@ def read_reqs(reqs_name): license="BSD2", entry_points={ "console_scripts": [ - "pephubclient = pephubclient.__main__:main", + "phc = pephubclient.__main__:main", ], }, package_data={PACKAGE: ["templates/*"]}, @@ -61,4 +62,4 @@ def read_reqs(reqs_name): ["pytest-runner"] if {"test", "pytest", "ptr"} & set(sys.argv) else [] ), **extra, -) \ No newline at end of file +) From 35d9e43a3306ff349bb06f33f8f6f9e62d259f78 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 29 Mar 2023 19:06:40 -0400 Subject: [PATCH 016/165] added pulling functionality --- pephubclient/files_manager.py | 32 +++++++++++++++- pephubclient/models.py | 2 +- pephubclient/pephubclient.py | 70 ++++++++++++++++++++++++++++++++--- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 6cf11193..016ec866 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -2,6 +2,9 @@ from contextlib import suppress import os from typing import Optional +import yaml +import pandas + from pephubclient.constants import RegistryPath @@ -21,6 +24,31 @@ def load_jwt_data_from_file(path: str) -> str: with open(path, "r") as f: return f.read() + @staticmethod + def crete_registry_folder(registry_path: RegistryPath) -> str: + """ + Create new project folder + :param name: folder name + :return: folder_path + """ + folder_name = FilesManager._create_filename_to_save_downloaded_project(registry_path) + folder_path = os.path.join(os.path.join(os.getcwd(), folder_name)) + pathlib.Path(folder_path).mkdir(parents=True, exist_ok=True) + return folder_path + + @staticmethod + def save_yaml(config: dict, full_path: str, force: bool = True): + with open(full_path, 'w') as outfile: + yaml.dump(config, outfile, default_flow_style=False) + + @staticmethod + def save_pandas(df: pandas.DataFrame, full_path: str, force: bool = True): + df.to_csv(full_path, index=False) + + @staticmethod + def file_exists(full_path: str) -> bool: + return os.path.isfile(full_path) + @staticmethod def delete_file_if_exists(filename: str) -> None: with suppress(FileNotFoundError): @@ -60,4 +88,6 @@ def _create_filename_to_save_downloaded_project(registry_path: RegistryPath) -> if registry_path.tag: filename = filename + ":" + registry_path.tag - return filename + ".csv" + return filename + + diff --git a/pephubclient/models.py b/pephubclient/models.py index aa0bea88..395fec50 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -16,7 +16,7 @@ class ProjectDict(BaseModel): """ description: Optional[str] = "" config: dict = Field(alias="_config") - subsample_dict: Optional[dict] = Field(alias="_subsample_dict") + subsample_dict: Optional[list] = Field(alias="_subsample_dict") name: str sample_dict: dict = Field(alias="_sample_dict") diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 31f553b2..0c5516a0 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,7 +1,10 @@ import os import json -from typing import Optional, NoReturn +from typing import Optional, NoReturn, List + +import pandas import peppy +import pandas as pd import requests import urllib3 from peppy import Project @@ -23,10 +26,7 @@ class PEPHubClient(RequestManager): - CONVERT_ENDPOINT = "convert?filter=csv" - CLI_LOGIN_ENDPOINT = "auth/login_cli" USER_DATA_FILE_NAME = "jwt.txt" - DEFAULT_PROJECT_FILENAME = "pep_project.csv" PATH_TO_FILE_WITH_JWT = ( os.path.join(os.getenv("HOME"), ".pephubclient/") + USER_DATA_FILE_NAME ) @@ -63,7 +63,10 @@ def pull( :return: None """ jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) - self._save_pep_locally(project_registry_path, jwt_data, project_format) + project_dict = self._load_raw_pep(registry_path=project_registry_path, jwt_data=jwt_data) + + self._save_raw_pep(reg_path=project_registry_path, project_dict=project_dict, force=force) + def load_project( self, @@ -132,6 +135,63 @@ def upload( """ ... + @staticmethod + def _save_raw_pep( + reg_path: str, + project_dict: dict, + force: bool = False, + ) -> None: + """ + + :param project_dict: + :param force: + :return: + """ + project_name = project_dict["name"] + + config_dict = project_dict.get("_config") + config_dict["name"] = project_name + config_dict["description"] = project_dict["description"] + config_dict['sample_table'] = "sample_table.csv" + + sample_dict = project_dict.get("_sample_dict") + sample_pandas = pd.DataFrame(sample_dict) + + if project_dict.get("_subsample_dict"): + subsample_list = [ + pd.DataFrame(sub_a) + for sub_a in project_dict["_subsample_dict"] + ] + config_dict['subsample_table'] = "subsample_table.csv" + else: + subsample_list = None + reg_path_model = RegistryPath(**parse_registry_path(reg_path)) + folder_path = FilesManager.crete_registry_folder(registry_path=reg_path_model) + + yaml_full_path = os.path.join(folder_path, f"{project_name}_config.yaml") + sample_full_path = os.path.join(folder_path, config_dict['sample_table']) + + if FilesManager.file_exists(yaml_full_path) or FilesManager.file_exists(sample_full_path): + if not force: + print(f"Project {folder_path}") + # TODO: raise exception + + FilesManager.save_yaml(config_dict, yaml_full_path, force=True) + FilesManager.save_pandas(sample_pandas, sample_full_path, force=True) + + + + + if config_dict.get('subsample_table'): + subsample_full_path = os.path.join(folder_path, config_dict['subsample_table']) + + for subsample in subsample_list: + FilesManager.save_pandas(subsample, subsample_full_path, force=True) + + break + + return 0 + def _save_pep_locally( self, From 2243abd53bb205f7d73791fe449ac93427207f3e Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 30 Mar 2023 13:13:55 -0400 Subject: [PATCH 017/165] Added handling projects with multiple subtables --- pephubclient/exceptions.py | 9 +++ pephubclient/files_manager.py | 11 ---- pephubclient/helpers.py | 20 +++++- pephubclient/pephubclient.py | 114 +++++++++++----------------------- 4 files changed, 65 insertions(+), 89 deletions(-) diff --git a/pephubclient/exceptions.py b/pephubclient/exceptions.py index c507df0c..05d442d6 100644 --- a/pephubclient/exceptions.py +++ b/pephubclient/exceptions.py @@ -21,3 +21,12 @@ def __init__(self, message: str = None): class AuthorizationPendingError(BasePephubclientException): ... + + +class PEPExistsError(BasePephubclientException): + default_message = "PEP already exists. Change location, delete previous PEP or set force argument " \ + "to overwrite previous PEP" + + def __init__(self, message: str = None): + self.message = message + super().__init__(self.message or self.default_message) diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 016ec866..9878296a 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -54,17 +54,6 @@ def delete_file_if_exists(filename: str) -> None: with suppress(FileNotFoundError): os.remove(filename) - @staticmethod - def save_pep_project( - pep_project: str, registry_path: RegistryPath, filename: Optional[str] = None - ) -> None: - filename = filename or FilesManager._create_filename_to_save_downloaded_project( - registry_path - ) - with open(filename, "w") as f: - f.write(pep_project) - print(f"File downloaded -> {os.path.join(os.getcwd(), filename)}") - @staticmethod def _create_filename_to_save_downloaded_project(registry_path: RegistryPath) -> str: """ diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index fa1ae2b0..a3bdf390 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,5 +1,5 @@ import json -from typing import Optional +from typing import Optional, NoReturn import requests from pephubclient.exceptions import ResponseError @@ -35,3 +35,21 @@ def decode_response(response: requests.Response) -> str: return response.content.decode("utf-8") except json.JSONDecodeError: raise ResponseError() + + +class MessageHandler: + RED = 9 + YELLOW = 11 + GREEN = 40 + + @staticmethod + def print_error(text: str) -> NoReturn: + print(f"\033[38;5;9m{text}\033[0m") + + @staticmethod + def print_success(text: str) -> NoReturn: + print(f"\033[38;5;40m{text}\033[0m") + + @staticmethod + def print_warning(text: str) -> NoReturn: + print(f"\033[38;5;11m{text}\033[0m") diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 0c5516a0..fb30db19 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -6,6 +6,7 @@ import peppy import pandas as pd import requests +from requests.exceptions import ConnectionError import urllib3 from peppy import Project from pydantic.error_wrappers import ValidationError @@ -19,8 +20,14 @@ from pephubclient.models import ProjectDict from pephubclient.files_manager import FilesManager from pephubclient.helpers import RequestManager +from pephubclient.exceptions import ( + PEPExistsError, + IncorrectQueryStringError, + ResponseError, +) from pephubclient.pephub_oauth.pephub_oauth import PEPHubAuth +from pephubclient.helpers import MessageHandler urllib3.disable_warnings() @@ -39,7 +46,12 @@ def login(self) -> NoReturn: Log in to PEPhub :return: None """ - user_token = PEPHubAuth().login_to_pephub() + try: + user_token = PEPHubAuth().login_to_pephub() + except ConnectionError: + MessageHandler.print_error("Failed to log in. Connection Error. Try later.") + return 1 + FilesManager.save_jwt_data_to_file(self.PATH_TO_FILE_WITH_JWT, user_token) def logout(self) -> NoReturn: @@ -63,10 +75,17 @@ def pull( :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) + try: + project_dict = self._load_raw_pep(registry_path=project_registry_path, jwt_data=jwt_data) - self._save_raw_pep(reg_path=project_registry_path, project_dict=project_dict, force=force) + except ConnectionError: + MessageHandler.print_error("Failed to download PEP. Connection Error. Try later.") + return None + try: + self._save_raw_pep(reg_path=project_registry_path, project_dict=project_dict, force=force) + except PEPExistsError as err: + MessageHandler.print_warning(f"PEP '{project_registry_path}' already exists. {err}") def load_project( self, @@ -162,7 +181,9 @@ def _save_raw_pep( pd.DataFrame(sub_a) for sub_a in project_dict["_subsample_dict"] ] - config_dict['subsample_table'] = "subsample_table.csv" + config_dict['subsample_table'] = [] + for number, value in enumerate(subsample_list, start=1): + config_dict['subsample_table'].append(f"subsample_table{number}.csv") else: subsample_list = None reg_path_model = RegistryPath(**parse_registry_path(reg_path)) @@ -173,56 +194,20 @@ def _save_raw_pep( if FilesManager.file_exists(yaml_full_path) or FilesManager.file_exists(sample_full_path): if not force: - print(f"Project {folder_path}") - # TODO: raise exception + raise PEPExistsError FilesManager.save_yaml(config_dict, yaml_full_path, force=True) FilesManager.save_pandas(sample_pandas, sample_full_path, force=True) - - - if config_dict.get('subsample_table'): - subsample_full_path = os.path.join(folder_path, config_dict['subsample_table']) - - for subsample in subsample_list: - FilesManager.save_pandas(subsample, subsample_full_path, force=True) - - break - return 0 + for number, subsample in enumerate(subsample_list): + FilesManager.save_pandas(subsample, + os.path.join(folder_path, config_dict['subsample_table'][number]), + force=True) - - def _save_pep_locally( - self, - registry_path: str, - jwt_data: Optional[str] = None, - query_param: dict = None - ) -> None: - """ - Request PEPhub and save the requested project on the disk. - - :param registry_path: Project namespace, eg. "geo/GSE124224" - """ - query_param = {"raw": "true"} - self._set_registry_data(registry_path) - pephub_response = self.send_request( - method="GET", - url=self._build_pull_request_url(query_param=query_param), - headers=self._get_header(jwt_data), - cookies=None, - ) - if pephub_response.status_code == 200: - decoded_response = self._handle_pephub_response(pephub_response) - FilesManager.save_pep_project( - decoded_response, registry_path=self.registry_path - ) - elif pephub_response.status_code == 404: - print("File does not exist, or you are unauthorized.") - elif pephub_response.status_code == 500: - print("Internal server error.") - else: - print(f"Unknown error occurred. Status: {pephub_response.status_code}") + MessageHandler.print_success(f"Project was downloaded successfully -> {folder_path}") + return None def _load_raw_pep( self, @@ -274,8 +259,7 @@ def _set_registry_data(self, query_string: str) -> None: try: self.registry_path = RegistryPath(**parse_registry_path(query_string)) except (ValidationError, TypeError): - # raise IncorrectQueryStringError(query_string=query_string) - pass + raise IncorrectQueryStringError(query_string=query_string) @staticmethod def _get_header(jwt_data: Optional[str] = None) -> dict: @@ -284,14 +268,6 @@ def _get_header(jwt_data: Optional[str] = None) -> dict: else: return {} - def _load_pep_project(self, pep_project: str) -> peppy.Project: - FilesManager.save_pep_project( - pep_project, self.registry_path, filename=self.DEFAULT_PROJECT_FILENAME - ) - project = Project(self.DEFAULT_PROJECT_FILENAME) - FilesManager.delete_file_if_exists(self.DEFAULT_PROJECT_FILENAME) - return project - def _build_pull_request_url(self, query_param: dict = None) -> str: if not query_param: query_param = {} @@ -306,22 +282,6 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: endpoint += variables_string return PEPHUB_PEP_API_BASE_URL + endpoint - def _build_zip_request_url(self, query_param: dict = None) -> str: - if not query_param: - query_param = {} - query_param["tag"] = self.registry_path.tag - endpoint = ( - self.registry_path.namespace - + "/" - + self.registry_path.item - + "/" - + "zip" - ) - if query_param: - variables_string = PEPHubClient._parse_query_param(query_param) - endpoint += variables_string - return PEPHUB_PEP_API_BASE_URL + endpoint - @staticmethod def _parse_query_param(pep_variables: dict) -> str: """ @@ -340,7 +300,7 @@ def _parse_query_param(pep_variables: dict) -> str: def _handle_pephub_response(pephub_response: requests.Response): decoded_response = PEPHubClient.decode_response(pephub_response) - # if pephub_response.status_code != ResponseStatusCodes.OK_200: - # raise ResponseError(message=json.loads(decoded_response).get("detail")) - # else: - return decoded_response \ No newline at end of file + if pephub_response.status_code != ResponseStatusCodes.OK_200: + raise ResponseError(message=json.loads(decoded_response).get("detail")) + else: + return decoded_response From 9db09bceb664b876b92512d4e68e3cb291505815 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 30 Mar 2023 14:43:12 -0400 Subject: [PATCH 018/165] Added push functionality --- pephubclient/cli.py | 36 ++++++++++++++++-- pephubclient/constants.py | 5 +-- pephubclient/files_manager.py | 5 +++ pephubclient/helpers.py | 2 + pephubclient/models.py | 21 +++++++++++ pephubclient/pephubclient.py | 69 +++++++++++++++++++++-------------- 6 files changed, 104 insertions(+), 34 deletions(-) diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 2509f5ce..46ec99b6 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -1,6 +1,11 @@ import typer +from requests.exceptions import ConnectionError + from pephubclient import __app_name__, __version__ from pephubclient.pephubclient import PEPHubClient +from pephubclient.helpers import MessageHandler +from pephubclient.exceptions import PEPExistsError + pep_hub_client = PEPHubClient() @@ -12,7 +17,11 @@ def login(): """ Login to Pephub """ - pep_hub_client.login() + try: + pep_hub_client.login() + except ConnectionError: + MessageHandler.print_error("Failed to log in. Connection Error. Try later.") + @app.command() @@ -34,13 +43,19 @@ def pull( """ Download and save project locally. """ - pep_hub_client.pull(project_registry_path, project_format, force) + try: + pep_hub_client.pull(project_registry_path, project_format, force) + except ConnectionError: + MessageHandler.print_error("Failed to download project. Connection Error. Try later.") + + except PEPExistsError as err: + MessageHandler.print_warning(f"PEP '{project_registry_path}' already exists. {err}") @app.command() def push( - cfg: str = typer.Option( + cfg: str = typer.Argument( ..., help="Project config file (YAML) or sample table (CSV/TSV)" "with one row per sample to constitute project", @@ -51,11 +66,24 @@ def push( force: bool = typer.Option( False, help="Force push to the database. Use it to update, or upload project." ), + is_private: bool = typer.Option( + False, help="Upload project as private." + ), ): """ Upload/update project in PEPhub """ - ... + try: + pep_hub_client.push( + cfg=cfg, + namespace=namespace, + name=name, + tag=tag, + is_private=is_private, + force=force + ) + except ConnectionError: + MessageHandler.print_error("Failed to upload project. Connection Error. Try later.") @app.command() diff --git a/pephubclient/constants.py b/pephubclient/constants.py index d2e0a094..58145406 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -3,10 +3,9 @@ from pydantic import BaseModel # PEPHUB_BASE_URL = "https://pephub.databio.org/" -# PEPHUB_PEP_API_BASE_URL = "https://pephub.databio.org/pep/" -# PEPHUB_LOGIN_URL = "https://pephub.databio.org/auth/login" PEPHUB_BASE_URL = "http://0.0.0.0:8000/" -PEPHUB_PEP_API_BASE_URL = "http://0.0.0.0:8000/api/v1/projects/" +PEPHUB_PEP_API_BASE_URL = f"{PEPHUB_BASE_URL}api/v1/projects/" +PEPHUB_PUSH_URL = f"{PEPHUB_BASE_URL}api/v1/namespaces/{{namespace}}/projects/json" class RegistryPath(BaseModel): diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 9878296a..ed2ac307 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -6,6 +6,7 @@ import pandas from pephubclient.constants import RegistryPath +from pephubclient.exceptions import PEPExistsError class FilesManager: @@ -38,11 +39,15 @@ def crete_registry_folder(registry_path: RegistryPath) -> str: @staticmethod def save_yaml(config: dict, full_path: str, force: bool = True): + if FilesManager.file_exists(full_path) and not force: + raise PEPExistsError("Yaml file already exists. File won't be updated") with open(full_path, 'w') as outfile: yaml.dump(config, outfile, default_flow_style=False) @staticmethod def save_pandas(df: pandas.DataFrame, full_path: str, force: bool = True): + if FilesManager.file_exists(full_path) and not force: + raise PEPExistsError("Csv file already exists. File won't be updated") df.to_csv(full_path, index=False) @staticmethod diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index a3bdf390..4c6e242a 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -13,6 +13,7 @@ def send_request( headers: Optional[dict] = None, cookies: Optional[dict] = None, params: Optional[dict] = None, + json: Optional[dict] = None, ) -> requests.Response: return requests.request( method=method, @@ -21,6 +22,7 @@ def send_request( cookies=cookies, headers=headers, params=params, + json=json, ) @staticmethod diff --git a/pephubclient/models.py b/pephubclient/models.py index 395fec50..43351e4f 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -23,3 +23,24 @@ class ProjectDict(BaseModel): class Config: allow_population_by_field_name = True extra = Extra.allow + + +class ProjectUploadData(BaseModel): + """ + Model used in post request to upload project + """ + pep_dict: ProjectDict + _tag: Optional[str] = "default" + is_private: Optional[bool] = False + overwrite: Optional[bool] = False + + @property + def tag(self): + return self._tag + + @tag.setter + def tag(self, tag): + if tag: + self._tag = tag + else: + self._tag = "default" diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index fb30db19..3abb87af 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -2,22 +2,20 @@ import json from typing import Optional, NoReturn, List -import pandas import peppy import pandas as pd import requests from requests.exceptions import ConnectionError import urllib3 -from peppy import Project from pydantic.error_wrappers import ValidationError from ubiquerg import parse_registry_path from pephubclient.constants import ( - PEPHUB_BASE_URL, PEPHUB_PEP_API_BASE_URL, + PEPHUB_PUSH_URL, RegistryPath, ResponseStatusCodes, ) -from pephubclient.models import ProjectDict +from pephubclient.models import ProjectDict, ProjectUploadData from pephubclient.files_manager import FilesManager from pephubclient.helpers import RequestManager from pephubclient.exceptions import ( @@ -46,11 +44,7 @@ def login(self) -> NoReturn: Log in to PEPhub :return: None """ - try: - user_token = PEPHubAuth().login_to_pephub() - except ConnectionError: - MessageHandler.print_error("Failed to log in. Connection Error. Try later.") - return 1 + user_token = PEPHubAuth().login_to_pephub() FilesManager.save_jwt_data_to_file(self.PATH_TO_FILE_WITH_JWT, user_token) @@ -75,17 +69,10 @@ def pull( :return: None """ jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) - try: - 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, jwt_data=jwt_data) - except ConnectionError: - MessageHandler.print_error("Failed to download PEP. Connection Error. Try later.") - return None + self._save_raw_pep(reg_path=project_registry_path, project_dict=project_dict, force=force) - try: - self._save_raw_pep(reg_path=project_registry_path, project_dict=project_dict, force=force) - except PEPExistsError as err: - MessageHandler.print_warning(f"PEP '{project_registry_path}' already exists. {err}") def load_project( self, @@ -124,7 +111,6 @@ def push( :return: None """ peppy_project = peppy.Project(cfg=cfg) - self.upload(project=peppy_project, namespace=namespace, name=name, @@ -152,7 +138,32 @@ def upload( :param force: :return: None """ - ... + jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) + if name: + project["name"] = name + + upload_data = ProjectUploadData( + pep_dict=project.to_dict(extended=True), + tag=tag, + is_private=is_private, + overwrite=force, + + ) + pephub_response = self.send_request( + method="POST", + url=self._build_push_request_url(namespace=namespace), + headers=self._get_header(jwt_data), + json=upload_data.dict(), + cookies=None, + ) + if pephub_response.status_code == 202: + MessageHandler.print_success(f"Project '{namespace}/{name}:{tag}' was successfully uploaded") + elif pephub_response.status_code == 409: + MessageHandler.print_error("Project already exists. Set force to overwrite project.") + elif pephub_response.status_code == 401: + MessageHandler.print_error("Unauthorized! Failure in uploading project.") + else: + MessageHandler.print_error("Unexpected Error.") @staticmethod def _save_raw_pep( @@ -161,10 +172,10 @@ def _save_raw_pep( force: bool = False, ) -> None: """ - - :param project_dict: - :param force: - :return: + Save project locally. + :param dict project_dict: PEP dictionary (raw project) + :param bool force: overwrite project if exists + :return: None """ project_name = project_dict["name"] @@ -243,11 +254,11 @@ def _load_raw_pep( return correct_proj_dict.dict(by_alias=True) elif pephub_response.status_code == 404: - print("File does not exist, or you are unauthorized.") + MessageHandler.print_error("File does not exist, or you are unauthorized.") elif pephub_response.status_code == 500: - print("Internal server error.") + MessageHandler.print_error("Internal server error.") else: - print(f"Unknown error occurred. Status: {pephub_response.status_code}") + MessageHandler.print_error(f"Unknown error occurred. Status: {pephub_response.status_code}") def _set_registry_data(self, query_string: str) -> None: """ @@ -282,6 +293,10 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: endpoint += variables_string return PEPHUB_PEP_API_BASE_URL + endpoint + @staticmethod + def _build_push_request_url(namespace: str) -> str: + return PEPHUB_PUSH_URL.format(namespace=namespace) + @staticmethod def _parse_query_param(pep_variables: dict) -> str: """ From 9e4ee86f159077562a3efc1a0eb01bd4d8e102e6 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Fri, 31 Mar 2023 17:04:16 -0400 Subject: [PATCH 019/165] Added comments + deleted unused code --- pephubclient/cli.py | 7 ++++--- pephubclient/error_handler.py | 1 - pephubclient/exceptions.py | 4 ---- pephubclient/files_manager.py | 14 ++++++-------- pephubclient/helpers.py | 4 +++- pephubclient/models.py | 8 -------- pephubclient/pephubclient.py | 34 ++++++++++++++++++++++------------ tests/conftest.py | 4 ---- 8 files changed, 35 insertions(+), 41 deletions(-) delete mode 100644 pephubclient/error_handler.py diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 46ec99b6..910e202d 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -15,7 +15,7 @@ @app.command() def login(): """ - Login to Pephub + Login to PEPhub """ try: pep_hub_client.login() @@ -23,7 +23,6 @@ def login(): MessageHandler.print_error("Failed to log in. Connection Error. Try later.") - @app.command() def logout(): """ @@ -35,7 +34,6 @@ def logout(): @app.command() def pull( project_registry_path: str, - # output_dir: str = typer.Option(None, help="Specify the location where the file should be saved"), project_format: str = typer.Option("default", help="Project format in which project should be saved" "Options: [default, basic, csv, yaml, zip]."), force: bool = typer.Option(False, help="Last name of person to greet."), @@ -88,4 +86,7 @@ def push( @app.command() def version(): + """ + Package version + """ print(f"{__app_name__} v{__version__}") diff --git a/pephubclient/error_handler.py b/pephubclient/error_handler.py deleted file mode 100644 index 8619d940..00000000 --- a/pephubclient/error_handler.py +++ /dev/null @@ -1 +0,0 @@ -# Here should be error handler diff --git a/pephubclient/exceptions.py b/pephubclient/exceptions.py index 05d442d6..29f8558f 100644 --- a/pephubclient/exceptions.py +++ b/pephubclient/exceptions.py @@ -19,10 +19,6 @@ def __init__(self, message: str = None): super().__init__(self.message or self.default_message) -class AuthorizationPendingError(BasePephubclientException): - ... - - class PEPExistsError(BasePephubclientException): default_message = "PEP already exists. Change location, delete previous PEP or set force argument " \ "to overwrite previous PEP" diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index ed2ac307..f6341635 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -1,7 +1,6 @@ import pathlib from contextlib import suppress import os -from typing import Optional import yaml import pandas @@ -12,6 +11,9 @@ class FilesManager: @staticmethod def save_jwt_data_to_file(path: str, jwt_data: str) -> None: + """ + Save jwt to provided path + """ pathlib.Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True) with open(path, "w") as f: f.write(jwt_data) @@ -29,7 +31,7 @@ def load_jwt_data_from_file(path: str) -> str: def crete_registry_folder(registry_path: RegistryPath) -> str: """ Create new project folder - :param name: folder name + :param registry_path: project registry path :return: folder_path """ folder_name = FilesManager._create_filename_to_save_downloaded_project(registry_path) @@ -63,12 +65,8 @@ def delete_file_if_exists(filename: str) -> None: def _create_filename_to_save_downloaded_project(registry_path: RegistryPath) -> str: """ Takes query string and creates output filename to save the project to. - - Args: - query_string: Query string that was used to find the project. - - Returns: - Filename uniquely identifying the project. + :param registry_path: Query string that was used to find the project. + :return: Filename uniquely identifying the project. """ filename = [] diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 4c6e242a..53bcf844 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -29,7 +29,6 @@ def send_request( def decode_response(response: requests.Response) -> str: """ Decode the response from GitHub and pack the returned data into appropriate model. - :param response: Response from GitHub. :return: Response data as an instance of correct model. """ @@ -40,6 +39,9 @@ def decode_response(response: requests.Response) -> str: class MessageHandler: + """ + Class holding print function in different colors + """ RED = 9 YELLOW = 11 GREEN = 40 diff --git a/pephubclient/models.py b/pephubclient/models.py index 43351e4f..820a8fde 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -2,14 +2,6 @@ from typing import Optional -class JWTDataResponse(BaseModel): - jwt_token: str - - -class ClientData(BaseModel): - client_id: str - - class ProjectDict(BaseModel): """ Project dict (raw) model diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 3abb87af..d3f87894 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,11 +1,10 @@ import os import json -from typing import Optional, NoReturn, List +from typing import Optional, NoReturn import peppy import pandas as pd import requests -from requests.exceptions import ConnectionError import urllib3 from pydantic.error_wrappers import ValidationError from ubiquerg import parse_registry_path @@ -42,7 +41,6 @@ def __init__(self): def login(self) -> NoReturn: """ Log in to PEPhub - :return: None """ user_token = PEPHubAuth().login_to_pephub() @@ -51,20 +49,17 @@ def login(self) -> NoReturn: def logout(self) -> NoReturn: """ Log out from PEPhub - :return: NoReturn """ FilesManager.delete_file_if_exists(self.PATH_TO_FILE_WITH_JWT) def pull( self, project_registry_path: str, - project_format: Optional[str] = "default", force: Optional[bool] = False ) -> None: """ - Downl + Download project locally :param str project_registry_path: Project registry path in PEPhub (e.g. databio/base:default) - :param str project_format: project format to be saved. Options: [default, zip] :param bool force: if project exists, overwrite it. :return: None """ @@ -73,7 +68,6 @@ def pull( self._save_raw_pep(reg_path=project_registry_path, project_dict=project_dict, force=force) - def load_project( self, project_registry_path: str, @@ -164,6 +158,7 @@ def upload( MessageHandler.print_error("Unauthorized! Failure in uploading project.") else: MessageHandler.print_error("Unexpected Error.") + return None @staticmethod def _save_raw_pep( @@ -228,11 +223,9 @@ def _load_raw_pep( ) -> dict: """ 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 query_param: @@ -263,7 +256,6 @@ def _load_raw_pep( def _set_registry_data(self, query_string: str) -> None: """ Parse provided query string to extract project name, sample name, etc. - :param query_string: Passed by user. Contain information needed to locate the project. :return: Parsed query string. """ @@ -274,12 +266,22 @@ def _set_registry_data(self, query_string: str) -> None: @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 + :param query_param: dict of parameters used in query string + :return: url string + """ if not query_param: query_param = {} query_param["tag"] = self.registry_path.tag @@ -295,6 +297,11 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: @staticmethod def _build_push_request_url(namespace: str) -> str: + """ + Build project uplaod request used in pephub + :param namespace: namespace where project will be uploaded + :return: url string + """ return PEPHUB_PUSH_URL.format(namespace=namespace) @staticmethod @@ -302,7 +309,7 @@ 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 = [] @@ -313,6 +320,9 @@ def _parse_query_param(pep_variables: dict) -> str: @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_200: diff --git a/tests/conftest.py b/tests/conftest.py index 39053220..4689dab8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ import pytest -from pephubclient.models import ClientData import json @@ -32,6 +31,3 @@ def test_jwt_response(test_jwt): return json.dumps({"jwt_token": test_jwt}).encode("utf-8") -@pytest.fixture -def test_client_data(): - return ClientData(client_id="test_id") From a98c6de68693b17e9b7e2f28cb21df8f9f1e321e Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Fri, 31 Mar 2023 17:09:45 -0400 Subject: [PATCH 020/165] auth polishing --- pephubclient/pephub_oauth/const.py | 6 +++--- pephubclient/pephub_oauth/pephub_oauth.py | 16 ++++++---------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/pephubclient/pephub_oauth/const.py b/pephubclient/pephub_oauth/const.py index 4c63023a..99476847 100644 --- a/pephubclient/pephub_oauth/const.py +++ b/pephubclient/pephub_oauth/const.py @@ -1,5 +1,5 @@ # constants of pephub_auth -PEPHUB_BASE_API_URL = "http://127.0.0.1:8000" -PEPHUB_DEVICE_INIT_URI = f"{PEPHUB_BASE_API_URL}/auth/device/init" -PEPHUB_DEVICE_TOKEN_URI = f"{PEPHUB_BASE_API_URL}/auth/device/token" +from pephubclient.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" diff --git a/pephubclient/pephub_oauth/pephub_oauth.py b/pephubclient/pephub_oauth/pephub_oauth.py index e3e77d59..3bc110c2 100644 --- a/pephubclient/pephub_oauth/pephub_oauth.py +++ b/pephubclient/pephub_oauth/pephub_oauth.py @@ -4,7 +4,7 @@ import time from pydantic import BaseModel -from pephubclient.helpers import RequestManager +from pephubclient.helpers import RequestManager, MessageHandler from pephubclient.pephub_oauth.const import ( PEPHUB_DEVICE_INIT_URI, PEPHUB_DEVICE_TOKEN_URI, @@ -49,9 +49,9 @@ def login_to_pephub(self): pephub_response.device_code ) except PEPHubTokenExchangeException: - print("You are not logged in") + MessageHandler.print_warning("You are not logged in") else: - print("Successfully logged in!") + MessageHandler.print_success("Successfully logged in!") return user_token def _request_pephub_for_device_code(self) -> InitializeDeviceCodeResponse: @@ -65,7 +65,6 @@ def _request_pephub_for_device_code(self) -> InitializeDeviceCodeResponse: headers=None, ) return self._handle_pephub_response(response, InitializeDeviceCodeResponse) - # return "device code" def _exchange_device_code_on_token(self, device_code: str) -> str: """ @@ -89,13 +88,10 @@ def _handle_pephub_response( ) -> Union[BaseModel, InitializeDeviceCodeResponse, PEPHubDeviceTokenResponse]: """ Decode the response from GitHub and pack the returned data into appropriate model. + :param response: Response from pephub + :param model: Model that the data will be packed to. - Args: - response: Response from PEPhub - model: Model that the data will be packed to. - - Returns: - Response data as an instance of correct model. + :return: Response data as an instance of correct model. """ if response.status_code == 401: raise PEPHubTokenExchangeException From 080bdc0a2bb4ea75fbfd5a7b721b16c60da5ae3c Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Fri, 31 Mar 2023 17:40:26 -0400 Subject: [PATCH 021/165] Fixed upload models --- pephubclient/cli.py | 6 +++--- pephubclient/constants.py | 10 ++++++++-- pephubclient/models.py | 19 +++++++------------ pephubclient/pephubclient.py | 9 +++++---- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 910e202d..0b592df4 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -34,15 +34,15 @@ def logout(): @app.command() def pull( project_registry_path: str, - project_format: str = typer.Option("default", help="Project format in which project should be saved" - "Options: [default, basic, csv, yaml, zip]."), + # project_format: str = typer.Option("default", help="Project format in which project should be saved" + # "Options: [default, basic, csv, yaml, zip]."), force: bool = typer.Option(False, help="Last name of person to greet."), ): """ Download and save project locally. """ try: - pep_hub_client.pull(project_registry_path, project_format, force) + pep_hub_client.pull(project_registry_path, force) except ConnectionError: MessageHandler.print_error("Failed to download project. Connection Error. Try later.") diff --git a/pephubclient/constants.py b/pephubclient/constants.py index 58145406..b28baf18 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -1,6 +1,6 @@ from typing import Optional from enum import Enum -from pydantic import BaseModel +from pydantic import BaseModel, validator # PEPHUB_BASE_URL = "https://pephub.databio.org/" PEPHUB_BASE_URL = "http://0.0.0.0:8000/" @@ -13,7 +13,13 @@ class RegistryPath(BaseModel): namespace: str item: str subitem: Optional[str] - tag: Optional[str] + tag: Optional[str] = "default" + + @validator('tag') + def tag_should_not_be_none(cls, v): + if v: + return v + return "default" class ResponseStatusCodes(int, Enum): diff --git a/pephubclient/models.py b/pephubclient/models.py index 820a8fde..25991434 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, Extra +from pydantic import BaseModel, Field, Extra, validator from typing import Optional @@ -22,17 +22,12 @@ class ProjectUploadData(BaseModel): Model used in post request to upload project """ pep_dict: ProjectDict - _tag: Optional[str] = "default" + tag: Optional[str] = "default" is_private: Optional[bool] = False overwrite: Optional[bool] = False - @property - def tag(self): - return self._tag - - @tag.setter - def tag(self, tag): - if tag: - self._tag = tag - else: - self._tag = "default" + @validator('tag') + def tag_should_not_be_none(cls, v): + if v: + return v + return "default" diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index d3f87894..aa46026e 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -128,8 +128,8 @@ def upload( :param name: project name :param tag: project tag :param force: Force push to the database. Use it to update, or upload project. - :param is_private: - :param force: + :param is_private: Make project private + :param force: overwrite project if it exists :return: None """ jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) @@ -151,7 +151,7 @@ def upload( cookies=None, ) if pephub_response.status_code == 202: - MessageHandler.print_success(f"Project '{namespace}/{name}:{tag}' was successfully uploaded") + MessageHandler.print_success(f"Project '{namespace}/{name}:{upload_data.tag}' was successfully uploaded") elif pephub_response.status_code == 409: MessageHandler.print_error("Project already exists. Set force to overwrite project.") elif pephub_response.status_code == 401: @@ -221,7 +221,7 @@ def _load_raw_pep( 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 @@ -285,6 +285,7 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: if not query_param: query_param = {} query_param["tag"] = self.registry_path.tag + endpoint = ( self.registry_path.namespace + "/" From ad7b2609ed54a0f1738e012daa7bc2880f7ab91e Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 3 Apr 2023 00:34:17 -0400 Subject: [PATCH 022/165] Added tests + lint --- pephubclient/cli.py | 25 +-- pephubclient/constants.py | 2 +- pephubclient/exceptions.py | 6 +- pephubclient/files_manager.py | 10 +- pephubclient/helpers.py | 1 + pephubclient/models.py | 4 +- pephubclient/pephub_oauth/const.py | 1 + pephubclient/pephubclient.py | 97 +++++------ tests/conftest.py | 46 ++++-- tests/data/sample_pep/sample_table.csv | 5 + tests/data/sample_pep/subsamp_config.yaml | 16 ++ tests/data/sample_pep/subsample_table.csv | 4 + tests/test_github_oauth_client.py | 20 --- tests/test_pephubclient.py | 187 +++++++++++++++------- 14 files changed, 269 insertions(+), 155 deletions(-) create mode 100644 tests/data/sample_pep/sample_table.csv create mode 100644 tests/data/sample_pep/subsamp_config.yaml create mode 100644 tests/data/sample_pep/subsample_table.csv delete mode 100644 tests/test_github_oauth_client.py diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 0b592df4..16907b5e 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -4,7 +4,7 @@ from pephubclient import __app_name__, __version__ from pephubclient.pephubclient import PEPHubClient from pephubclient.helpers import MessageHandler -from pephubclient.exceptions import PEPExistsError +from pephubclient.exceptions import PEPExistsError, ResponseError pep_hub_client = PEPHubClient() @@ -45,10 +45,15 @@ def pull( pep_hub_client.pull(project_registry_path, force) except ConnectionError: - MessageHandler.print_error("Failed to download project. Connection Error. Try later.") - + MessageHandler.print_error( + "Failed to download project. Connection Error. Try later." + ) except PEPExistsError as err: - MessageHandler.print_warning(f"PEP '{project_registry_path}' already exists. {err}") + MessageHandler.print_warning( + f"PEP '{project_registry_path}' already exists. {err}" + ) + except ResponseError as err: + MessageHandler.print_error(f"{err}") @app.command() @@ -64,9 +69,7 @@ def push( force: bool = typer.Option( False, help="Force push to the database. Use it to update, or upload project." ), - is_private: bool = typer.Option( - False, help="Upload project as private." - ), + is_private: bool = typer.Option(False, help="Upload project as private."), ): """ Upload/update project in PEPhub @@ -78,10 +81,14 @@ def push( name=name, tag=tag, is_private=is_private, - force=force + force=force, ) except ConnectionError: - MessageHandler.print_error("Failed to upload project. Connection Error. Try later.") + MessageHandler.print_error( + "Failed to upload project. Connection Error. Try later." + ) + except ResponseError as err: + MessageHandler.print_error(f"{err}") @app.command() diff --git a/pephubclient/constants.py b/pephubclient/constants.py index b28baf18..358c1911 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -15,7 +15,7 @@ class RegistryPath(BaseModel): subitem: Optional[str] tag: Optional[str] = "default" - @validator('tag') + @validator("tag") def tag_should_not_be_none(cls, v): if v: return v diff --git a/pephubclient/exceptions.py b/pephubclient/exceptions.py index 29f8558f..8e13f1f6 100644 --- a/pephubclient/exceptions.py +++ b/pephubclient/exceptions.py @@ -20,8 +20,10 @@ def __init__(self, message: str = None): class PEPExistsError(BasePephubclientException): - default_message = "PEP already exists. Change location, delete previous PEP or set force argument " \ - "to overwrite previous PEP" + default_message = ( + "PEP already exists. Change location, delete previous PEP or set force argument " + "to overwrite previous PEP" + ) def __init__(self, message: str = None): self.message = message diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index f6341635..32b8cfdf 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -28,13 +28,15 @@ def load_jwt_data_from_file(path: str) -> str: return f.read() @staticmethod - def crete_registry_folder(registry_path: RegistryPath) -> str: + def crete_project_folder(registry_path: RegistryPath) -> str: """ Create new project folder :param registry_path: project registry path :return: folder_path """ - folder_name = FilesManager._create_filename_to_save_downloaded_project(registry_path) + folder_name = FilesManager._create_filename_to_save_downloaded_project( + registry_path + ) folder_path = os.path.join(os.path.join(os.getcwd(), folder_name)) pathlib.Path(folder_path).mkdir(parents=True, exist_ok=True) return folder_path @@ -43,7 +45,7 @@ def crete_registry_folder(registry_path: RegistryPath) -> str: def save_yaml(config: dict, full_path: str, force: bool = True): if FilesManager.file_exists(full_path) and not force: raise PEPExistsError("Yaml file already exists. File won't be updated") - with open(full_path, 'w') as outfile: + with open(full_path, "w") as outfile: yaml.dump(config, outfile, default_flow_style=False) @staticmethod @@ -81,5 +83,3 @@ def _create_filename_to_save_downloaded_project(registry_path: RegistryPath) -> filename = filename + ":" + registry_path.tag return filename - - diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 53bcf844..54934bcd 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -42,6 +42,7 @@ class MessageHandler: """ Class holding print function in different colors """ + RED = 9 YELLOW = 11 GREEN = 40 diff --git a/pephubclient/models.py b/pephubclient/models.py index 25991434..e2f0b9f6 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -6,6 +6,7 @@ class ProjectDict(BaseModel): """ Project dict (raw) model """ + description: Optional[str] = "" config: dict = Field(alias="_config") subsample_dict: Optional[list] = Field(alias="_subsample_dict") @@ -21,12 +22,13 @@ class ProjectUploadData(BaseModel): """ Model used in post request to upload project """ + pep_dict: ProjectDict tag: Optional[str] = "default" is_private: Optional[bool] = False overwrite: Optional[bool] = False - @validator('tag') + @validator("tag") def tag_should_not_be_none(cls, v): if v: return v diff --git a/pephubclient/pephub_oauth/const.py b/pephubclient/pephub_oauth/const.py index 99476847..68d78ed6 100644 --- a/pephubclient/pephub_oauth/const.py +++ b/pephubclient/pephub_oauth/const.py @@ -1,5 +1,6 @@ # constants of pephub_auth from pephubclient.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" diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index aa46026e..e8153576 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -52,11 +52,7 @@ def logout(self) -> NoReturn: """ FilesManager.delete_file_if_exists(self.PATH_TO_FILE_WITH_JWT) - def pull( - self, - project_registry_path: str, - force: Optional[bool] = False - ) -> None: + def pull(self, project_registry_path: str, force: Optional[bool] = False) -> None: """ Download project locally :param str project_registry_path: Project registry path in PEPhub (e.g. databio/base:default) @@ -64,9 +60,13 @@ def pull( :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, jwt_data=jwt_data + ) - self._save_raw_pep(reg_path=project_registry_path, project_dict=project_dict, force=force) + self._save_raw_pep( + reg_path=project_registry_path, project_dict=project_dict, force=force + ) def load_project( self, @@ -105,12 +105,14 @@ def push( :return: None """ peppy_project = peppy.Project(cfg=cfg) - self.upload(project=peppy_project, - namespace=namespace, - name=name, - tag=tag, - is_private=is_private, - force=force,) + self.upload( + project=peppy_project, + namespace=namespace, + name=name, + tag=tag, + is_private=is_private, + force=force, + ) def upload( self, @@ -141,7 +143,6 @@ def upload( tag=tag, is_private=is_private, overwrite=force, - ) pephub_response = self.send_request( method="POST", @@ -151,20 +152,24 @@ def upload( cookies=None, ) if pephub_response.status_code == 202: - MessageHandler.print_success(f"Project '{namespace}/{name}:{upload_data.tag}' was successfully uploaded") + MessageHandler.print_success( + f"Project '{namespace}/{name}:{upload_data.tag}' was successfully uploaded" + ) elif pephub_response.status_code == 409: - MessageHandler.print_error("Project already exists. Set force to overwrite project.") + raise ResponseError( + "Project already exists. Set force to overwrite project." + ) elif pephub_response.status_code == 401: - MessageHandler.print_error("Unauthorized! Failure in uploading project.") + raise ResponseError("Unauthorized! Failure in uploading project.") else: - MessageHandler.print_error("Unexpected Error.") + raise ResponseError("Unexpected Response Error.") return None @staticmethod def _save_raw_pep( - reg_path: str, - project_dict: dict, - force: bool = False, + reg_path: str, + project_dict: dict, + force: bool = False, ) -> None: """ Save project locally. @@ -177,42 +182,46 @@ def _save_raw_pep( config_dict = project_dict.get("_config") config_dict["name"] = project_name config_dict["description"] = project_dict["description"] - config_dict['sample_table'] = "sample_table.csv" + config_dict["sample_table"] = "sample_table.csv" sample_dict = project_dict.get("_sample_dict") sample_pandas = pd.DataFrame(sample_dict) if project_dict.get("_subsample_dict"): subsample_list = [ - pd.DataFrame(sub_a) - for sub_a in project_dict["_subsample_dict"] + pd.DataFrame(sub_a) for sub_a in project_dict["_subsample_dict"] ] - config_dict['subsample_table'] = [] + config_dict["subsample_table"] = [] for number, value in enumerate(subsample_list, start=1): - config_dict['subsample_table'].append(f"subsample_table{number}.csv") + config_dict["subsample_table"].append(f"subsample_table{number}.csv") else: subsample_list = None reg_path_model = RegistryPath(**parse_registry_path(reg_path)) - folder_path = FilesManager.crete_registry_folder(registry_path=reg_path_model) + folder_path = FilesManager.crete_project_folder(registry_path=reg_path_model) yaml_full_path = os.path.join(folder_path, f"{project_name}_config.yaml") - sample_full_path = os.path.join(folder_path, config_dict['sample_table']) + sample_full_path = os.path.join(folder_path, config_dict["sample_table"]) - if FilesManager.file_exists(yaml_full_path) or FilesManager.file_exists(sample_full_path): + if FilesManager.file_exists(yaml_full_path) or FilesManager.file_exists( + sample_full_path + ): if not force: raise PEPExistsError FilesManager.save_yaml(config_dict, yaml_full_path, force=True) FilesManager.save_pandas(sample_pandas, sample_full_path, force=True) - if config_dict.get('subsample_table'): - + 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]), - force=True) - - MessageHandler.print_success(f"Project was downloaded successfully -> {folder_path}") + FilesManager.save_pandas( + subsample, + os.path.join(folder_path, config_dict["subsample_table"][number]), + force=True, + ) + + MessageHandler.print_success( + f"Project was downloaded successfully -> {folder_path}" + ) return None def _load_raw_pep( @@ -221,7 +230,7 @@ def _load_raw_pep( jwt_data: Optional[str] = None, query_param: Optional[dict] = None, ) -> dict: - """ project_name + """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 @@ -247,11 +256,13 @@ def _load_raw_pep( return correct_proj_dict.dict(by_alias=True) elif pephub_response.status_code == 404: - MessageHandler.print_error("File does not exist, or you are unauthorized.") + raise ResponseError("File does not exist, or you are unauthorized.") elif pephub_response.status_code == 500: - MessageHandler.print_error("Internal server error.") + raise ResponseError("Internal server error.") else: - MessageHandler.print_error(f"Unknown error occurred. Status: {pephub_response.status_code}") + raise ResponseError( + f"Unknown error occurred. Status: {pephub_response.status_code}" + ) def _set_registry_data(self, query_string: str) -> None: """ @@ -286,11 +297,7 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: query_param = {} query_param["tag"] = self.registry_path.tag - endpoint = ( - self.registry_path.namespace - + "/" - + self.registry_path.item - ) + endpoint = self.registry_path.namespace + "/" + self.registry_path.item if query_param: variables_string = PEPHubClient._parse_query_param(query_param) endpoint += variables_string diff --git a/tests/conftest.py b/tests/conftest.py index 4689dab8..6888f51a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,39 @@ import pytest import json +from pephubclient.pephub_oauth.models import ( + InitializeDeviceCodeResponse, + PEPHubDeviceTokenResponse, +) +from pephubclient.models import ProjectDict + + +@pytest.fixture() +def device_code_return(): + device_code = "asdf2345" + return InitializeDeviceCodeResponse( + device_code=device_code, + auth_url=f"any_base_url/auth/device/login/{device_code}", + ) + + +@pytest.fixture() +def test_raw_pep_return(): + sample_prj = { + "description": "sample-desc", + "config": {"This": "is config"}, + "subsample_dict": [], + "name": "sample name", + "sample_dict": { + "organism": {"0": "pig", "1": "pig", "2": "frog", "3": "frog"}, + "sample_name": { + "0": "pig_0h", + "1": "pig_1h", + "2": "frog_0h", + "3": "frog_1h", + }, + }, + } + return json.dumps(sample_prj) @pytest.fixture @@ -12,11 +46,6 @@ def input_return_mock(monkeypatch): return monkeypatch.setattr("builtins.input", lambda: None) -@pytest.fixture -def test_access_token(): - return "test_access_token" - - @pytest.fixture def test_jwt(): return ( @@ -24,10 +53,3 @@ def test_jwt(): "eyJsb2dpbiI6InJhZmFsc3RlcGllbiIsImlkIjo0MzkyNjUyMiwib3JnYW5pemF0aW9ucyI6bnVsbH0." "mgBP-7x5l9cqufhzdVi0OFA78pkYDEymwPFwud02BAc" ) - - -@pytest.fixture -def test_jwt_response(test_jwt): - return json.dumps({"jwt_token": test_jwt}).encode("utf-8") - - diff --git a/tests/data/sample_pep/sample_table.csv b/tests/data/sample_pep/sample_table.csv new file mode 100644 index 00000000..00039113 --- /dev/null +++ b/tests/data/sample_pep/sample_table.csv @@ -0,0 +1,5 @@ +file,file_id,protocol,identifier,sample_name +local_files,,anySampleType,frog1,frog_1 +local_files_unmerged,,anySampleType,frog2,frog_2 +local_files_unmerged,,anySampleType,frog3,frog_3 +local_files_unmerged,,anySampleType,frog4,frog_4 diff --git a/tests/data/sample_pep/subsamp_config.yaml b/tests/data/sample_pep/subsamp_config.yaml new file mode 100644 index 00000000..06fae282 --- /dev/null +++ b/tests/data/sample_pep/subsamp_config.yaml @@ -0,0 +1,16 @@ +description: This project contains subsamples! +looper: + output_dir: $HOME/hello_looper_results + pipeline_interfaces: + - ../pipeline/pipeline_interface.yaml +name: subsamp +pep_version: 2.0.0 +sample_modifiers: + derive: + attributes: + - file + sources: + local_files: ../data/{identifier}{file_id}_data.txt + local_files_unmerged: ../data/{identifier}*_data.txt +sample_table: sample_table.csv +subsample_table: subsample_table.csv diff --git a/tests/data/sample_pep/subsample_table.csv b/tests/data/sample_pep/subsample_table.csv new file mode 100644 index 00000000..e7cd6400 --- /dev/null +++ b/tests/data/sample_pep/subsample_table.csv @@ -0,0 +1,4 @@ +file_id,sample_name +a,frog_1 +b,frog_1 +c,frog_1 diff --git a/tests/test_github_oauth_client.py b/tests/test_github_oauth_client.py deleted file mode 100644 index d7a2829d..00000000 --- a/tests/test_github_oauth_client.py +++ /dev/null @@ -1,20 +0,0 @@ -from github_oauth_client.github_oauth_client import GitHubOAuthClient -from unittest.mock import Mock - - -def test_get_access_token(mocker, test_client_data, input_return_mock): - post_request_mock = mocker.patch( - "requests.request", - side_effect=[ - Mock( - content=b"{" - b'"device_code": "test_device_code", ' - b'"user_code": "test_user_code", ' - b'"verification_uri": "test_verification_uri"}' - ), - Mock(content=b'{"access_token": "test_access_token"}'), - ], - ) - GitHubOAuthClient().get_access_token(test_client_data) - - assert post_request_mock.called diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 82f77aa2..6e28cfbf 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -1,80 +1,147 @@ import pytest +import os from unittest.mock import Mock, patch, mock_open from pephubclient.pephubclient import PEPHubClient from pephubclient.exceptions import ResponseError -def test_login(mocker, test_jwt_response, test_client_data, test_access_token): - mocker.patch( - "github_oauth_client.github_oauth_client.GitHubOAuthClient.get_access_token", - return_value=test_access_token, - ) - pephub_request_mock = mocker.patch( - "requests.request", return_value=Mock(content=test_jwt_response) - ) - pathlib_mock = mocker.patch("pathlib.Path.mkdir") +SAMPLE_PEP = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "tests", + "data", + "sample_pep", + "subsamp_config.yaml", +) - with patch("builtins.open", mock_open()) as open_mock: - PEPHubClient().login(client_data=test_client_data) - assert open_mock.called - assert pephub_request_mock.called - assert pathlib_mock.called +class TestSmoke: + def test_login(self, mocker, device_code_return, test_jwt): + """ + Test if device login request was sent to pephub + """ + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content=device_code_return, status_code=200), + ) + pephub_response_mock = mocker.patch( + "pephubclient.pephub_oauth.pephub_oauth.PEPHubAuth._handle_pephub_response", + return_value=device_code_return, + ) + pephub_exchange_code_mock = mocker.patch( + "pephubclient.pephub_oauth.pephub_oauth.PEPHubAuth._exchange_device_code_on_token", + return_value=test_jwt, + ) + pathlib_mock = mocker.patch("pathlib.Path.mkdir") -def test_logout(mocker): - os_remove_mock = mocker.patch("os.remove") - PEPHubClient().logout() + PEPHubClient().login() - assert os_remove_mock.called + assert requests_mock.called + assert pephub_response_mock.called + assert pephub_exchange_code_mock.called + assert pathlib_mock.called + def test_logout(self, mocker): + os_remove_mock = mocker.patch("os.remove") + PEPHubClient().logout() -def test_pull(mocker, test_jwt): - mocker.patch( - "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", - return_value=test_jwt, - ) - mocker.patch( - "requests.request", return_value=Mock(content=b"some_data", status_code=200) - ) - save_project_mock = mocker.patch( - "pephubclient.files_manager.FilesManager.save_pep_project" - ) + assert os_remove_mock.called - PEPHubClient().pull("some/project") + def test_pull(self, mocker, test_jwt, test_raw_pep_return): + 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.pephubclient.PEPHubClient._handle_pephub_response", + return_value=test_raw_pep_return, + ) + save_yaml_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.save_yaml" + ) + save_sample_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.save_pandas" + ) + mocker.patch("pephubclient.files_manager.FilesManager.crete_project_folder") - assert save_project_mock.called + PEPHubClient().pull("some/project") + assert jwt_mock.called + assert requests_mock.called + assert save_yaml_mock.called + assert save_sample_mock.called -@pytest.mark.parametrize( - "status_code, expected_error_message", - [ - ( - 404, - "Some error message", - ), - ( - 403, - "Some error message", - ), - (501, "Some error message"), - ], -) -def test_pull_with_pephub_error_response( - mocker, test_jwt, status_code, expected_error_message -): - mocker.patch( - "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", - return_value=test_jwt, - ) - mocker.patch( - "requests.request", - return_value=Mock( - content=b'{"detail": "Some error message"}', status_code=status_code - ), + @pytest.mark.parametrize( + "status_code, expected_error_message", + [ + ( + 404, + "File does not exist, or you are unauthorized.", + ), + ( + 500, + "Internal server error.", + ), + (501, f"Unknown error occurred. Status: 501"), + ], ) + def test_pull_with_pephub_error_response( + self, mocker, test_jwt, status_code, expected_error_message + ): + mocker.patch( + "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + return_value=test_jwt, + ) + mocker.patch( + "requests.request", + return_value=Mock( + content=b'{"detail": "Some error message"}', status_code=status_code + ), + ) - with pytest.raises(ResponseError) as e: - PEPHubClient().pull("some/project") + with pytest.raises(ResponseError) as e: + PEPHubClient().pull("some/project") + + assert e.value.message == expected_error_message + + def test_push(self, mocker, test_jwt): + requests_mock = mocker.patch( + "requests.request", return_value=Mock(status_code=202) + ) - assert e.value.message == expected_error_message + PEPHubClient().push( + SAMPLE_PEP, + namespace="s_name", + name="name", + ) + + assert requests_mock.called + + @pytest.mark.parametrize( + "status_code, expected_error_message", + [ + ( + 409, + "Project already exists. Set force to overwrite project.", + ), + ( + 401, + "Unauthorized! Failure in uploading project.", + ), + (233, "Unexpected Response Error."), + ], + ) + def test_push_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().push( + SAMPLE_PEP, + namespace="s_name", + name="name", + ) From 33b374b1cb8318f6061596629a7fab653555f0f5 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 3 Apr 2023 09:49:03 -0400 Subject: [PATCH 023/165] added docs --- README.md | 76 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b14b567f..83e4ebd0 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,66 @@ # `PEPHubClient` -`PEPHubClient` is a tool to provide Python and CLI interface for `pephub`. -Authorization is based on `OAuth` with using GitHub. +`PEPHubClient` is a tool to provide Python and CLI interface and Python API for [PEPhub](https://pephub.databio.org). -The authorization process is slightly complex and needs more explanation. -The explanation will be provided based on two commands: +`pephubclient` features: +1) `push` (upload) projects) +2) `pull` (download projects) +Additionally, our client supports pephub authorization. +The authorization process is based on pephub device authorization protocol. +To upload projects or to download privet projects, user must be authorized through pephub. -## 1. `pephubclient login` -To authenticate itself user must execute `pephubclient login` command (1). -Command triggers the process of authenticating with GitHub. -`PEPHubClient` sends the request for user and device verification codes (2), and -GitHub responds with the data (3). Next, if user is not logged in, GitHub -asks for login (4), user logs in (5) and then GitHub asks to input -verification code (6) that is shown to user in the CLI. -After inputting the correct verification code (7), `PEPHubClient` -sends the request to GitHub and asks about access token (8), which is then -provided by GitHub based on data from authentication (9). -![](static/pephubclient_login.png) +To login and logout use `login` and `logout` arguments respectively. +---- +`phc --help` +```text +╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ login Login to PEPhub │ +│ logout Logout │ +│ pull Download and save project locally. │ +│ push Upload/update project in PEPhub │ +│ version Version │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +``` -## 2. `pephubclient pull project/name:tag` -![](static/pephubclient_pull.png) +`phc pull --help` +```text + Usage: pephubclient pull [OPTIONS] PROJECT_REGISTRY_PATH + + Download and save project locally. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * project_registry_path TEXT [default: None] [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ [default: default] │ +│ --force --no-force Last name of person to greet. [default: no-force] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +``` + +`phc push --help` +```text + Usage: pephubclient push [OPTIONS] CFG + + Upload/update project in PEPhub + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * cfg TEXT Project config file (YAML) or sample table (CSV/TSV) with one row per sample to │ +│ constitute project │ +│ [default: None] │ +│ [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * --namespace TEXT Project name [default: None] [required] │ +│ * --name TEXT Project name [default: None] [required] │ +│ --tag TEXT Project tag [default: None] │ +│ --force --no-force Force push to the database. Use it to update, or upload project. │ +│ [default: no-force] │ +│ --is-private --no-is-private Upload project as private. [default: no-is-private] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +``` \ No newline at end of file From b228d024881fe752b037ede5f86e78319ee432ab Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 3 Apr 2023 13:38:07 -0400 Subject: [PATCH 024/165] actions + lint --- .github/workflows/black.yml | 14 +++++++++ .github/workflows/flake.yml | 18 ++++++++++++ .github/workflows/pytest.yml | 35 +++++++++++++++++++++++ Makefile | 2 +- README.md | 5 ++++ pephubclient/cli.py | 5 ++-- pephubclient/constants.py | 3 +- pephubclient/files_manager.py | 5 ++-- pephubclient/helpers.py | 3 +- pephubclient/models.py | 3 +- pephubclient/pephub_oauth/models.py | 1 - pephubclient/pephub_oauth/pephub_oauth.py | 13 +++++---- pephubclient/pephubclient.py | 17 ++++++----- requirements/requirements-dev.txt | 5 ++++ setup.py | 14 +++++---- tests/conftest.py | 10 +++---- tests/test_pephubclient.py | 11 +++---- 17 files changed, 122 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/black.yml create mode 100644 .github/workflows/flake.yml create mode 100644 .github/workflows/pytest.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml new file mode 100644 index 00000000..05ccf402 --- /dev/null +++ b/.github/workflows/black.yml @@ -0,0 +1,14 @@ +name: Lint + +on: + pull_request: + branches: [main] + + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - uses: psf/black@stable diff --git a/.github/workflows/flake.yml b/.github/workflows/flake.yml new file mode 100644 index 00000000..dbe012fc --- /dev/null +++ b/.github/workflows/flake.yml @@ -0,0 +1,18 @@ +name: Lint + +on: + pull_request: + branches: [main] + + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: tonybajan/flake8-check-action@v1.3.0 + with: + select: E3,E4,E5,E7,W6,F,B,G0 + maxlinelength: 120 + repotoken: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 00000000..018e3f42 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,35 @@ +name: Run pytests + +on: + push: + branches: [dev] + pull_request: + branches: [main, dev] + +jobs: + pytest: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ["3.8", "3.9", "3.11"] + os: [ubuntu-20.04] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install all dependencies + run: if [ -f requirements/requirements-all.txt ]; then pip install -r requirements/requirements-all.txt; fi + + - name: Install dev dependencies + run: if [ -f requirements/requirements-dev.txt ]; then pip install -r requirements/requirements-dev.txt; fi + + - name: Install package + run: python -m pip install . + + - name: Run pytest tests + run: pytest tests -x -v \ No newline at end of file diff --git a/Makefile b/Makefile index 2dac6a08..b5baa1c3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ lint: - black . && isort . && flake8 + flake8 && isort . && black . run-coverage: coverage run -m pytest diff --git a/README.md b/README.md index 83e4ebd0..617cf416 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # `PEPHubClient` +[![PEP compatible](https://pepkit.github.io/img/PEP-compatible-green.svg)](https://pepkit.github.io) +![Run pytests](https://github.com/pepkit/geofetch/workflows/Run%20pytests/badge.svg) +[![pypi-badge](https://img.shields.io/pypi/v/pephubclient)](https://pypi.org/project/pephubclient) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + `PEPHubClient` is a tool to provide Python and CLI interface and Python API for [PEPhub](https://pephub.databio.org). `pephubclient` features: diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 16907b5e..911074be 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -2,10 +2,9 @@ from requests.exceptions import ConnectionError from pephubclient import __app_name__, __version__ -from pephubclient.pephubclient import PEPHubClient -from pephubclient.helpers import MessageHandler from pephubclient.exceptions import PEPExistsError, ResponseError - +from pephubclient.helpers import MessageHandler +from pephubclient.pephubclient import PEPHubClient pep_hub_client = PEPHubClient() diff --git a/pephubclient/constants.py b/pephubclient/constants.py index 358c1911..67989917 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -1,5 +1,6 @@ -from typing import Optional from enum import Enum +from typing import Optional + from pydantic import BaseModel, validator # PEPHUB_BASE_URL = "https://pephub.databio.org/" diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 32b8cfdf..cb12aebb 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -1,8 +1,9 @@ +import os import pathlib from contextlib import suppress -import os -import yaml + import pandas +import yaml from pephubclient.constants import RegistryPath from pephubclient.exceptions import PEPExistsError diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 54934bcd..49c3e6c9 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,5 +1,6 @@ import json -from typing import Optional, NoReturn +from typing import NoReturn, Optional + import requests from pephubclient.exceptions import ResponseError diff --git a/pephubclient/models.py b/pephubclient/models.py index e2f0b9f6..872fd18d 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, Field, Extra, validator from typing import Optional +from pydantic import BaseModel, Extra, Field, validator + class ProjectDict(BaseModel): """ diff --git a/pephubclient/pephub_oauth/models.py b/pephubclient/pephub_oauth/models.py index db454f80..a3d64772 100644 --- a/pephubclient/pephub_oauth/models.py +++ b/pephubclient/pephub_oauth/models.py @@ -1,4 +1,3 @@ -from typing import Optional from pydantic import BaseModel diff --git a/pephubclient/pephub_oauth/pephub_oauth.py b/pephubclient/pephub_oauth/pephub_oauth.py index 3bc110c2..4e245a14 100644 --- a/pephubclient/pephub_oauth/pephub_oauth.py +++ b/pephubclient/pephub_oauth/pephub_oauth.py @@ -1,22 +1,23 @@ import json +import time from typing import Type, Union + import requests -import time from pydantic import BaseModel -from pephubclient.helpers import RequestManager, MessageHandler +from pephubclient.helpers import MessageHandler, RequestManager from pephubclient.pephub_oauth.const import ( PEPHUB_DEVICE_INIT_URI, PEPHUB_DEVICE_TOKEN_URI, ) -from pephubclient.pephub_oauth.models import ( - InitializeDeviceCodeResponse, - PEPHubDeviceTokenResponse, -) from pephubclient.pephub_oauth.exceptions import ( PEPHubResponseException, PEPHubTokenExchangeException, ) +from pephubclient.pephub_oauth.models import ( + InitializeDeviceCodeResponse, + PEPHubDeviceTokenResponse, +) class PEPHubAuth(RequestManager): diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index e8153576..ee77df97 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,30 +1,29 @@ -import os import json -from typing import Optional, NoReturn +import os +from typing import NoReturn, Optional -import peppy import pandas as pd +import peppy import requests import urllib3 from pydantic.error_wrappers import ValidationError from ubiquerg import parse_registry_path + from pephubclient.constants import ( PEPHUB_PEP_API_BASE_URL, PEPHUB_PUSH_URL, RegistryPath, ResponseStatusCodes, ) -from pephubclient.models import ProjectDict, ProjectUploadData -from pephubclient.files_manager import FilesManager -from pephubclient.helpers import RequestManager from pephubclient.exceptions import ( - PEPExistsError, IncorrectQueryStringError, + PEPExistsError, ResponseError, ) - +from pephubclient.files_manager import FilesManager +from pephubclient.helpers import MessageHandler, RequestManager +from pephubclient.models import ProjectDict, ProjectUploadData from pephubclient.pephub_oauth.pephub_oauth import PEPHubAuth -from pephubclient.helpers import MessageHandler urllib3.disable_warnings() diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index e69de29b..b2c2b3f8 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -0,0 +1,5 @@ +black +pytest +python-dotenv +pytest-mock +flake8 \ No newline at end of file diff --git a/setup.py b/setup.py index 6bc2e526..5b8c2443 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,9 @@ -import sys import os -from setuptools import find_packages, setup -from pephubclient import __app_name__, __version__, __author__ +import sys + +from setuptools import setup + +from pephubclient import __app_name__, __author__, __version__ PACKAGE = __app_name__ REQDIR = "requirements" @@ -14,10 +16,10 @@ def read_reqs(reqs_name): deps = [] with open(os.path.join(REQDIR, f"requirements-{reqs_name}.txt"), "r") as f: - for l in f: - if not l.strip(): + for line in f: + if not line.strip(): continue - deps.append(l) + deps.append(line) return deps diff --git a/tests/conftest.py b/tests/conftest.py index 6888f51a..140294e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,8 @@ -import pytest import json -from pephubclient.pephub_oauth.models import ( - InitializeDeviceCodeResponse, - PEPHubDeviceTokenResponse, -) -from pephubclient.models import ProjectDict + +import pytest + +from pephubclient.pephub_oauth.models import InitializeDeviceCodeResponse @pytest.fixture() diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 6e28cfbf..fb5b1cac 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -1,9 +1,10 @@ -import pytest import os -from unittest.mock import Mock, patch, mock_open -from pephubclient.pephubclient import PEPHubClient -from pephubclient.exceptions import ResponseError +from unittest.mock import Mock +import pytest + +from pephubclient.exceptions import ResponseError +from pephubclient.pephubclient import PEPHubClient SAMPLE_PEP = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -86,7 +87,7 @@ def test_pull(self, mocker, test_jwt, test_raw_pep_return): 500, "Internal server error.", ), - (501, f"Unknown error occurred. Status: 501"), + (501, "Unknown error occurred. Status: 501"), ], ) def test_pull_with_pephub_error_response( From a45ef06119c1858edaf6f966505dfed960aa7c88 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 3 Apr 2023 13:48:35 -0400 Subject: [PATCH 025/165] fixed tests --- tests/test_pephubclient.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index fb5b1cac..51bf4242 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -34,6 +34,7 @@ def test_login(self, mocker, device_code_return, test_jwt): ) pathlib_mock = mocker.patch("pathlib.Path.mkdir") + pathlib_mock1 = mocker.patch("os.open") PEPHubClient().login() From 83116793803088d97a75c4ce99a9a6d24dbe847d Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 3 Apr 2023 13:53:04 -0400 Subject: [PATCH 026/165] fixed tests 2 --- .github/workflows/flake.yml | 7 ++++--- tests/test_pephubclient.py | 3 +-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/flake.yml b/.github/workflows/flake.yml index dbe012fc..60a6a20a 100644 --- a/.github/workflows/flake.yml +++ b/.github/workflows/flake.yml @@ -1,4 +1,4 @@ -name: Lint +name: Flake on: pull_request: @@ -7,7 +7,7 @@ on: jobs: lint: - name: Lint + name: flake runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -15,4 +15,5 @@ jobs: with: select: E3,E4,E5,E7,W6,F,B,G0 maxlinelength: 120 - repotoken: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + repotoken: ${{ secrets.GITHUB_TOKEN }} + exclude: .git,__pycache__,docs/source/conf.py,old,build,dist \ No newline at end of file diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 51bf4242..820b5fe0 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -33,8 +33,7 @@ def test_login(self, mocker, device_code_return, test_jwt): return_value=test_jwt, ) - pathlib_mock = mocker.patch("pathlib.Path.mkdir") - pathlib_mock1 = mocker.patch("os.open") + pathlib_mock = mocker.patch("pephubclient.files_manager.FilesManager.save_jwt_data_to_file") PEPHubClient().login() From c275c3f93bd31bbb3a637a179de36e56e600ab02 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 3 Apr 2023 13:55:54 -0400 Subject: [PATCH 027/165] fixed tests 3 --- .github/workflows/flake.yml | 4 ++-- tests/test_pephubclient.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/flake.yml b/.github/workflows/flake.yml index 60a6a20a..080f6fb5 100644 --- a/.github/workflows/flake.yml +++ b/.github/workflows/flake.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v2 - uses: tonybajan/flake8-check-action@v1.3.0 with: - select: E3,E4,E5,E7,W6,F,B,G0 + select: E3,E4,E5,E7,W6,F,G0 maxlinelength: 120 repotoken: ${{ secrets.GITHUB_TOKEN }} - exclude: .git,__pycache__,docs/source/conf.py,old,build,dist \ No newline at end of file + ignore: .git,__pycache__,docs/source/conf.py,old,build,dist \ No newline at end of file diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 820b5fe0..c69fce46 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -33,7 +33,9 @@ def test_login(self, mocker, device_code_return, test_jwt): return_value=test_jwt, ) - pathlib_mock = mocker.patch("pephubclient.files_manager.FilesManager.save_jwt_data_to_file") + pathlib_mock = mocker.patch( + "pephubclient.files_manager.FilesManager.save_jwt_data_to_file" + ) PEPHubClient().login() From 7d7bc4df88dd6776bc113e48c7d9cf59eb2564b1 Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Wed, 5 Apr 2023 14:52:47 -0400 Subject: [PATCH 028/165] Update .github/workflows/pytest.yml Co-authored-by: Vince --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 018e3f42..b89f2c9f 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.9", "3.11"] + python-version: ["3.8", "3.11"] os: [ubuntu-20.04] steps: From 39ebf6bb28837028f008b4a427509dd024be252f Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 5 Apr 2023 17:02:43 -0400 Subject: [PATCH 029/165] Added Responsestatus codes + fixed init + config --- pephubclient/__init__.py | 5 ++++- pephubclient/constants.py | 10 +++++++--- pephubclient/pephubclient.py | 14 +++++++------- setup.py | 19 ++++++------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 97b8c9c5..658c3cad 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,5 +1,8 @@ +from pephubclient.pephubclient import PEPHubClient + __app_name__ = "pephubclient" __version__ = "0.1.0" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" -__all__ = ["__app_name__", "__version__", "__author__"] + +__all__ = ["PEPHubClient"] diff --git a/pephubclient/constants.py b/pephubclient/constants.py index 67989917..e4e09734 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -24,6 +24,10 @@ def tag_should_not_be_none(cls, v): class ResponseStatusCodes(int, Enum): - FORBIDDEN_403 = 403 - NOT_EXIST_404 = 404 - OK_200 = 200 + OK = 200 + ACCEPTED = 202 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_EXIST= 404 + CONFLICT = 409 + INTERNAL_ERROR = 500 diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index ee77df97..0e57757c 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -150,15 +150,15 @@ def upload( json=upload_data.dict(), cookies=None, ) - if pephub_response.status_code == 202: + if pephub_response.status_code == ResponseStatusCodes.ACCEPTED: MessageHandler.print_success( f"Project '{namespace}/{name}:{upload_data.tag}' was successfully uploaded" ) - elif pephub_response.status_code == 409: + elif pephub_response.status_code == ResponseStatusCodes.CONFLICT: raise ResponseError( "Project already exists. Set force to overwrite project." ) - elif pephub_response.status_code == 401: + elif pephub_response.status_code == ResponseStatusCodes.UNAUTHORIZED: raise ResponseError("Unauthorized! Failure in uploading project.") else: raise ResponseError("Unexpected Response Error.") @@ -247,16 +247,16 @@ def _load_raw_pep( headers=self._get_header(jwt_data), cookies=None, ) - if pephub_response.status_code == 200: + if pephub_response.status_code == ResponseStatusCodes.OK: decoded_response = self._handle_pephub_response(pephub_response) correct_proj_dict = ProjectDict(**json.loads(decoded_response)) # This step is necessary because of this issue: https://github.com/pepkit/pephub/issues/124 return correct_proj_dict.dict(by_alias=True) - elif pephub_response.status_code == 404: + elif pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: raise ResponseError("File does not exist, or you are unauthorized.") - elif pephub_response.status_code == 500: + elif pephub_response.status_code == ResponseStatusCodes.INTERNAL_ERROR: raise ResponseError("Internal server error.") else: raise ResponseError( @@ -332,7 +332,7 @@ def _handle_pephub_response(pephub_response: requests.Response): """ decoded_response = PEPHubClient.decode_response(pephub_response) - if pephub_response.status_code != ResponseStatusCodes.OK_200: + if pephub_response.status_code != ResponseStatusCodes.OK: raise ResponseError(message=json.loads(decoded_response).get("detail")) else: return decoded_response diff --git a/setup.py b/setup.py index 5b8c2443..5fc9e6d7 100644 --- a/setup.py +++ b/setup.py @@ -14,19 +14,11 @@ # Ordinary dependencies def read_reqs(reqs_name): - deps = [] with open(os.path.join(REQDIR, f"requirements-{reqs_name}.txt"), "r") as f: - for line in f: - if not line.strip(): - continue - deps.append(line) - return deps + return [line.strip() for line in f if line.strip()] -DEPENDENCIES = read_reqs("all") -extra["install_requires"] = DEPENDENCIES - -scripts = None +extra["install_requires"] = read_reqs("all") with open("README.md") as f: long_description = f.read() @@ -39,12 +31,13 @@ def read_reqs(reqs_name): long_description=long_description, long_description_content_type="text/markdown", classifiers=[ - "Development Status :: 1 - Planing", + "Development Status :: 3 - Alpha", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", - "Topic :: Scientific/Engineering :: Bio-Informatics", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: Bioinformatics", ], keywords="project, bioinformatics, metadata", url=f"https://github.com/databio/{PACKAGE}/", @@ -56,7 +49,7 @@ def read_reqs(reqs_name): ], }, package_data={PACKAGE: ["templates/*"]}, - scripts=scripts, + scripts=None, include_package_data=True, test_suite="tests", tests_require=read_reqs("dev"), From ba4013a9e63a4b66bd5efaacdc5528338112429b Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 5 Apr 2023 17:26:54 -0400 Subject: [PATCH 030/165] GitHub tests and requirements --- .github/workflows/flake.yml | 19 ------------------- .github/workflows/pytest.yml | 9 +++------ requirements/requirements-dev.txt | 5 ----- requirements/requirements-test.txt | 5 +++++ 4 files changed, 8 insertions(+), 30 deletions(-) delete mode 100644 .github/workflows/flake.yml create mode 100644 requirements/requirements-test.txt diff --git a/.github/workflows/flake.yml b/.github/workflows/flake.yml deleted file mode 100644 index 080f6fb5..00000000 --- a/.github/workflows/flake.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Flake - -on: - pull_request: - branches: [main] - - -jobs: - lint: - name: flake - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: tonybajan/flake8-check-action@v1.3.0 - with: - select: E3,E4,E5,E7,W6,F,G0 - maxlinelength: 120 - repotoken: ${{ secrets.GITHUB_TOKEN }} - ignore: .git,__pycache__,docs/source/conf.py,old,build,dist \ No newline at end of file diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b89f2c9f..1adcc824 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -22,14 +22,11 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install all dependencies - run: if [ -f requirements/requirements-all.txt ]; then pip install -r requirements/requirements-all.txt; fi - - - name: Install dev dependencies - run: if [ -f requirements/requirements-dev.txt ]; then pip install -r requirements/requirements-dev.txt; fi + - name: Install test dependencies + run: if [ -f requirements/requirements-dev.txt ]; then pip install -r requirements/requirements-test.txt; fi - name: Install package run: python -m pip install . - name: Run pytest tests - run: pytest tests -x -v \ No newline at end of file + run: pytest tests -v \ No newline at end of file diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index b2c2b3f8..e69de29b 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -1,5 +0,0 @@ -black -pytest -python-dotenv -pytest-mock -flake8 \ No newline at end of file diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt new file mode 100644 index 00000000..b2c2b3f8 --- /dev/null +++ b/requirements/requirements-test.txt @@ -0,0 +1,5 @@ +black +pytest +python-dotenv +pytest-mock +flake8 \ No newline at end of file From 8a1fd2c5941ebf792d0d79b7f3aaff3a35d5c98b Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 5 Apr 2023 17:31:03 -0400 Subject: [PATCH 031/165] fixed tests --- .github/workflows/pytest.yml | 5 ++++- pephubclient/constants.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 1adcc824..03c9f431 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -22,8 +22,11 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install all dependencies + run: if [ -f requirements/requirements-all.txt ]; then pip install -r requirements/requirements-all.txt; fi + - name: Install test dependencies - run: if [ -f requirements/requirements-dev.txt ]; then pip install -r requirements/requirements-test.txt; fi + run: if [ -f requirements/requirements-test.txt ]; then pip install -r requirements/requirements-test.txt; fi - name: Install package run: python -m pip install . diff --git a/pephubclient/constants.py b/pephubclient/constants.py index e4e09734..dd1d9bd3 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -28,6 +28,6 @@ class ResponseStatusCodes(int, Enum): ACCEPTED = 202 UNAUTHORIZED = 401 FORBIDDEN = 403 - NOT_EXIST= 404 + NOT_EXIST = 404 CONFLICT = 409 INTERNAL_ERROR = 500 From 83807e28b4cf5dcd49794c4176c6b6ecc6e1c516 Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Wed, 5 Apr 2023 17:44:43 -0400 Subject: [PATCH 032/165] Update README.md Co-authored-by: Vince --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 617cf416..c02b6323 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Additionally, our client supports pephub authorization. The authorization process is based on pephub device authorization protocol. To upload projects or to download privet projects, user must be authorized through pephub. -To login and logout use `login` and `logout` arguments respectively. +To login, use the `login` argument; to logout, use `logout`. ---- `phc --help` From 9855a88f3b04c3f7ce9b83461faeb43db42b08c0 Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Wed, 5 Apr 2023 17:44:49 -0400 Subject: [PATCH 033/165] Update README.md Co-authored-by: Vince --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c02b6323..0ec5d57e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Additionally, our client supports pephub authorization. The authorization process is based on pephub device authorization protocol. -To upload projects or to download privet projects, user must be authorized through pephub. +To upload projects or to download private projects, user must be authorized through pephub. To login, use the `login` argument; to logout, use `logout`. From 0997093de973b06a0934a0cfeeb7416de2a71503 Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Wed, 5 Apr 2023 17:44:56 -0400 Subject: [PATCH 034/165] Update README.md Co-authored-by: Vince --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ec5d57e..53f6757f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![pypi-badge](https://img.shields.io/pypi/v/pephubclient)](https://pypi.org/project/pephubclient) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -`PEPHubClient` is a tool to provide Python and CLI interface and Python API for [PEPhub](https://pephub.databio.org). +`PEPHubClient` is a tool to provide Python API and CLI for [PEPhub](https://pephub.databio.org). `pephubclient` features: 1) `push` (upload) projects) From d25c7411bf199ad2d6f645a08fd0735e34149a99 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 5 Apr 2023 18:12:37 -0400 Subject: [PATCH 035/165] Added catching error function --- Makefile | 1 + pephubclient/cli.py | 65 ++++++++++++++++------------------------- pephubclient/helpers.py | 30 +++++++++++++++++-- 3 files changed, 53 insertions(+), 43 deletions(-) diff --git a/Makefile b/Makefile index b5baa1c3..19130c8e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ lint: + # black should be last in the list, as it lint the code. Tests can fail if order will be different flake8 && isort . && black . run-coverage: diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 911074be..c6c26713 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -1,12 +1,12 @@ import typer -from requests.exceptions import ConnectionError from pephubclient import __app_name__, __version__ -from pephubclient.exceptions import PEPExistsError, ResponseError + from pephubclient.helpers import MessageHandler from pephubclient.pephubclient import PEPHubClient +from pephubclient.helpers import call_client_func -pep_hub_client = PEPHubClient() +_client = PEPHubClient() app = typer.Typer() @@ -16,10 +16,9 @@ def login(): """ Login to PEPhub """ - try: - pep_hub_client.login() - except ConnectionError: - MessageHandler.print_error("Failed to log in. Connection Error. Try later.") + call_client_func( + _client.login + ) @app.command() @@ -27,32 +26,22 @@ def logout(): """ Logout """ - pep_hub_client.logout() + _client.logout() @app.command() def pull( project_registry_path: str, - # project_format: str = typer.Option("default", help="Project format in which project should be saved" - # "Options: [default, basic, csv, yaml, zip]."), - force: bool = typer.Option(False, help="Last name of person to greet."), + force: bool = typer.Option(False, help="Overwrite project if it exists."), ): """ Download and save project locally. """ - try: - pep_hub_client.pull(project_registry_path, force) - - except ConnectionError: - MessageHandler.print_error( - "Failed to download project. Connection Error. Try later." - ) - except PEPExistsError as err: - MessageHandler.print_warning( - f"PEP '{project_registry_path}' already exists. {err}" - ) - except ResponseError as err: - MessageHandler.print_error(f"{err}") + call_client_func( + _client.pull, + project_registry_path = project_registry_path, + force = force, + ) @app.command() @@ -62,7 +51,7 @@ def push( help="Project config file (YAML) or sample table (CSV/TSV)" "with one row per sample to constitute project", ), - namespace: str = typer.Option(..., help="Project name"), + namespace: str = typer.Option(..., help="Project namespace"), name: str = typer.Option(..., help="Project name"), tag: str = typer.Option(None, help="Project tag"), force: bool = typer.Option( @@ -73,21 +62,17 @@ def push( """ Upload/update project in PEPhub """ - try: - pep_hub_client.push( - cfg=cfg, - namespace=namespace, - name=name, - tag=tag, - is_private=is_private, - force=force, - ) - except ConnectionError: - MessageHandler.print_error( - "Failed to upload project. Connection Error. Try later." - ) - except ResponseError as err: - MessageHandler.print_error(f"{err}") + + call_client_func( + _client.push, + cfg=cfg, + namespace=namespace, + name=name, + tag=tag, + is_private=is_private, + force=force, + ) + @app.command() diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 49c3e6c9..c2c2591a 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,9 +1,9 @@ import json -from typing import NoReturn, Optional - +from typing import NoReturn, Optional, Any, Callable import requests -from pephubclient.exceptions import ResponseError +from requests.exceptions import ConnectionError +from pephubclient.exceptions import ResponseError, PEPExistsError class RequestManager: @@ -59,3 +59,27 @@ def print_success(text: str) -> NoReturn: @staticmethod def print_warning(text: str) -> NoReturn: print(f"\033[38;5;11m{text}\033[0m") + + +def call_client_func(func: Callable[..., Any], **kwargs) -> Any: + """ + Catch exceptions in functions called through cli. + + :param func: The function to call. + :param kwargs: The keyword arguments to pass to the function. + :return: The result of the function call. + """ + + try: + func(**kwargs) + except ConnectionError as err: + MessageHandler.print_error( + f"Failed to upload project. Connection Error. Try later. {err}" + ) + except ResponseError as err: + MessageHandler.print_error(f"{err}") + + except PEPExistsError as err: + MessageHandler.print_warning( + f"PEP already exists. {err}" + ) From 04661a3449e292aa50fc9679e7c7a9875efae8ee Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 6 Apr 2023 02:37:45 -0400 Subject: [PATCH 036/165] polishing --- README.md | 4 ++-- pephubclient/cli.py | 4 ++-- pephubclient/constants.py | 9 ++++----- pephubclient/exceptions.py | 11 +++++++---- pephubclient/files_manager.py | 2 +- pephubclient/models.py | 11 +++++------ pephubclient/pephub_oauth/exceptions.py | 2 +- pephubclient/pephub_oauth/pephub_oauth.py | 5 +---- pephubclient/pephubclient.py | 2 +- 9 files changed, 24 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 53f6757f..0db00ca0 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ To login, use the `login` argument; to logout, use `logout`. │ logout Logout │ │ pull Download and save project locally. │ │ push Upload/update project in PEPhub │ -│ version Version │ +│ version Print package version │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` @@ -40,7 +40,7 @@ To login, use the `login` argument; to logout, use `logout`. ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ [default: default] │ -│ --force --no-force Last name of person to greet. [default: no-force] │ +│ --force --no-force Overwrite project if exists [default: no-force] │ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/pephubclient/cli.py b/pephubclient/cli.py index c6c26713..7619de4d 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -39,8 +39,8 @@ def pull( """ call_client_func( _client.pull, - project_registry_path = project_registry_path, - force = force, + project_registry_path=project_registry_path, + force=force, ) diff --git a/pephubclient/constants.py b/pephubclient/constants.py index dd1d9bd3..d01d5f9d 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -1,7 +1,8 @@ from enum import Enum from typing import Optional -from pydantic import BaseModel, validator +import pydantic +from pydantic import BaseModel # PEPHUB_BASE_URL = "https://pephub.databio.org/" PEPHUB_BASE_URL = "http://0.0.0.0:8000/" @@ -16,11 +17,9 @@ class RegistryPath(BaseModel): subitem: Optional[str] tag: Optional[str] = "default" - @validator("tag") + @pydantic.validator("tag") def tag_should_not_be_none(cls, v): - if v: - return v - return "default" + return v or "default" class ResponseStatusCodes(int, Enum): diff --git a/pephubclient/exceptions.py b/pephubclient/exceptions.py index 8e13f1f6..bb8787f4 100644 --- a/pephubclient/exceptions.py +++ b/pephubclient/exceptions.py @@ -1,10 +1,13 @@ +from typing import Optional + + class BasePephubclientException(Exception): def __init__(self, message: str): super().__init__(message) class IncorrectQueryStringError(BasePephubclientException): - def __init__(self, query_string: str = None): + def __init__(self, query_string: Optional[str] = None): self.query_string = query_string super().__init__( f"PEP data with passed namespace and project ({self.query_string}) name not found." @@ -14,17 +17,17 @@ def __init__(self, query_string: str = None): class ResponseError(BasePephubclientException): default_message = "The response looks incorrect and must be verified manually." - def __init__(self, message: str = None): + def __init__(self, message: Optional[str] = None): self.message = message super().__init__(self.message or self.default_message) class PEPExistsError(BasePephubclientException): default_message = ( - "PEP already exists. Change location, delete previous PEP or set force argument " + "PEP already exists. Change location, delete previous PEP, or set force argument " "to overwrite previous PEP" ) - def __init__(self, message: str = None): + def __init__(self, message: Optional[str] = None): self.message = message super().__init__(self.message or self.default_message) diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index cb12aebb..2ff78105 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -29,7 +29,7 @@ def load_jwt_data_from_file(path: str) -> str: return f.read() @staticmethod - def crete_project_folder(registry_path: RegistryPath) -> str: + def create_project_folder(registry_path: RegistryPath) -> str: """ Create new project folder :param registry_path: project registry path diff --git a/pephubclient/models.py b/pephubclient/models.py index 872fd18d..ad89e0c9 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -1,6 +1,7 @@ from typing import Optional -from pydantic import BaseModel, Extra, Field, validator +import pydantic +from pydantic import BaseModel, Extra, Field class ProjectDict(BaseModel): @@ -8,7 +9,7 @@ class ProjectDict(BaseModel): Project dict (raw) model """ - description: Optional[str] = "" + description: str = "" config: dict = Field(alias="_config") subsample_dict: Optional[list] = Field(alias="_subsample_dict") name: str @@ -29,8 +30,6 @@ class ProjectUploadData(BaseModel): is_private: Optional[bool] = False overwrite: Optional[bool] = False - @validator("tag") + @pydantic.validator("tag") def tag_should_not_be_none(cls, v): - if v: - return v - return "default" + return v or "default" diff --git a/pephubclient/pephub_oauth/exceptions.py b/pephubclient/pephub_oauth/exceptions.py index e8d41e38..e0becb51 100644 --- a/pephubclient/pephub_oauth/exceptions.py +++ b/pephubclient/pephub_oauth/exceptions.py @@ -14,7 +14,7 @@ def __init__(self, reason: str = ""): class PEPHubTokenExchangeException(Exception): - """Request response exception. Used when response != 200""" + """Exception in exchanging device code on token == 400""" def __init__(self, reason: str = ""): """ diff --git a/pephubclient/pephub_oauth/pephub_oauth.py b/pephubclient/pephub_oauth/pephub_oauth.py index 4e245a14..d2818b91 100644 --- a/pephubclient/pephub_oauth/pephub_oauth.py +++ b/pephubclient/pephub_oauth/pephub_oauth.py @@ -103,7 +103,4 @@ def _handle_pephub_response( except json.JSONDecodeError: raise Exception("Something went wrong with GitHub response") - try: - return model(**content) - except Exception: - raise Exception() + return model(**content) diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 0e57757c..f9b49478 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -196,7 +196,7 @@ def _save_raw_pep( else: subsample_list = None reg_path_model = RegistryPath(**parse_registry_path(reg_path)) - folder_path = FilesManager.crete_project_folder(registry_path=reg_path_model) + folder_path = FilesManager.create_project_folder(registry_path=reg_path_model) yaml_full_path = os.path.join(folder_path, f"{project_name}_config.yaml") sample_full_path = os.path.join(folder_path, config_dict["sample_table"]) From d8901b5fdee4758f500b11421c0518d74ab5f95c Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 6 Apr 2023 13:36:04 -0400 Subject: [PATCH 037/165] polishing 2 --- pephubclient/files_manager.py | 37 ++++++++++------------- pephubclient/helpers.py | 10 +++--- pephubclient/pephub_oauth/pephub_oauth.py | 10 ++++-- pephubclient/pephubclient.py | 8 ++--- tests/test_pephubclient.py | 2 +- 5 files changed, 34 insertions(+), 33 deletions(-) diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 2ff78105..f5b794ca 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -1,5 +1,5 @@ import os -import pathlib +from pathlib import Path from contextlib import suppress import pandas @@ -10,12 +10,13 @@ class FilesManager: + @staticmethod def save_jwt_data_to_file(path: str, jwt_data: str) -> None: """ Save jwt to provided path """ - pathlib.Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True) + Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True) with open(path, "w") as f: f.write(jwt_data) @@ -38,21 +39,19 @@ def create_project_folder(registry_path: RegistryPath) -> str: folder_name = FilesManager._create_filename_to_save_downloaded_project( registry_path ) - folder_path = os.path.join(os.path.join(os.getcwd(), folder_name)) - pathlib.Path(folder_path).mkdir(parents=True, exist_ok=True) + folder_path = os.path.join(os.getcwd(), folder_name) + Path(folder_path).mkdir(parents=True, exist_ok=True) return folder_path @staticmethod - def save_yaml(config: dict, full_path: str, force: bool = True): - if FilesManager.file_exists(full_path) and not force: - raise PEPExistsError("Yaml file already exists. File won't be updated") + def save_yaml(config: dict, full_path: str, not_force: bool = False): + FilesManager.check_writable(path=full_path, force=not not_force) with open(full_path, "w") as outfile: yaml.dump(config, outfile, default_flow_style=False) @staticmethod - def save_pandas(df: pandas.DataFrame, full_path: str, force: bool = True): - if FilesManager.file_exists(full_path) and not force: - raise PEPExistsError("Csv file already exists. File won't be updated") + def save_pandas(df: pandas.DataFrame, full_path: str, not_force: bool = False): + FilesManager.check_writable(path=full_path, force=not not_force) df.to_csv(full_path, index=False) @staticmethod @@ -71,16 +70,12 @@ def _create_filename_to_save_downloaded_project(registry_path: RegistryPath) -> :param registry_path: Query string that was used to find the project. :return: Filename uniquely identifying the project. """ - filename = [] - - if registry_path.namespace: - filename.append(registry_path.namespace) - if registry_path.item: - filename.append(registry_path.item) - - filename = "_".join(filename) - + filename = "_".join(filter(bool, [registry_path.namespace, registry_path.item])) if registry_path.tag: - filename = filename + ":" + registry_path.tag - + filename += f":{registry_path.tag}" return filename + + @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}") \ No newline at end of file diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index c2c2591a..55ec2aa6 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -27,16 +27,18 @@ def send_request( ) @staticmethod - def decode_response(response: requests.Response) -> str: + def decode_response(response: requests.Response, encoding: str = "utf-8") -> str: """ 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] :return: Response data as an instance of correct model. """ + try: - return response.content.decode("utf-8") - except json.JSONDecodeError: - raise ResponseError() + return response.content.decode(encoding) + except json.JSONDecodeError as err: + raise ResponseError(f"Error in response encoding format: {err}") class MessageHandler: diff --git a/pephubclient/pephub_oauth/pephub_oauth.py b/pephubclient/pephub_oauth/pephub_oauth.py index d2818b91..6023be3f 100644 --- a/pephubclient/pephub_oauth/pephub_oauth.py +++ b/pephubclient/pephub_oauth/pephub_oauth.py @@ -32,9 +32,11 @@ def login_to_pephub(self): f"{pephub_response.auth_url} to authenticate." ) + # Sleep 2 minutes and then try 3 times exchange device code on token time.sleep(2) - for i in range(3): + number_of_token_exchange_attempts = 3 + for i in range(number_of_token_exchange_attempts): try: user_token = self._exchange_device_code_on_token( pephub_response.device_code @@ -44,6 +46,8 @@ def login_to_pephub(self): else: print("Successfully logged in!") return user_token + + # If you didn't log in press enter to try again. input("If you logged in, press enter to continue...") try: user_token = self._exchange_device_code_on_token( @@ -95,9 +99,9 @@ def _handle_pephub_response( :return: Response data as an instance of correct model. """ if response.status_code == 401: - raise PEPHubTokenExchangeException + raise PEPHubTokenExchangeException() if response.status_code != 200: - raise PEPHubResponseException + raise PEPHubResponseException() try: content = json.loads(PEPHubAuth.decode_response(response)) except json.JSONDecodeError: diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index f9b49478..eecd3978 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -205,17 +205,17 @@ def _save_raw_pep( sample_full_path ): if not force: - raise PEPExistsError + raise PEPExistsError(f"PEP already exists locally: '{folder_path}'") - FilesManager.save_yaml(config_dict, yaml_full_path, force=True) - FilesManager.save_pandas(sample_pandas, sample_full_path, force=True) + 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]), - force=True, + not_force=False, ) MessageHandler.print_success( diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index c69fce46..76f7693c 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -69,7 +69,7 @@ def test_pull(self, mocker, test_jwt, test_raw_pep_return): save_sample_mock = mocker.patch( "pephubclient.files_manager.FilesManager.save_pandas" ) - mocker.patch("pephubclient.files_manager.FilesManager.crete_project_folder") + mocker.patch("pephubclient.files_manager.FilesManager.create_project_folder") PEPHubClient().pull("some/project") From 0a373f465ba782732ffe1e3beffa108b96f92603 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 6 Apr 2023 13:36:22 -0400 Subject: [PATCH 038/165] lint --- pephubclient/cli.py | 5 +---- pephubclient/files_manager.py | 3 +-- pephubclient/helpers.py | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 7619de4d..c1eefe08 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -16,9 +16,7 @@ def login(): """ Login to PEPhub """ - call_client_func( - _client.login - ) + call_client_func(_client.login) @app.command() @@ -74,7 +72,6 @@ def push( ) - @app.command() def version(): """ diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index f5b794ca..91366a28 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -10,7 +10,6 @@ class FilesManager: - @staticmethod def save_jwt_data_to_file(path: str, jwt_data: str) -> None: """ @@ -78,4 +77,4 @@ def _create_filename_to_save_downloaded_project(registry_path: RegistryPath) -> @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}") \ No newline at end of file + raise PEPExistsError(f"File already exists and won't be updated: {path}") diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 55ec2aa6..3ed7fbb6 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -82,6 +82,4 @@ def call_client_func(func: Callable[..., Any], **kwargs) -> Any: MessageHandler.print_error(f"{err}") except PEPExistsError as err: - MessageHandler.print_warning( - f"PEP already exists. {err}" - ) + MessageHandler.print_warning(f"PEP already exists. {err}") From 63b6072b608ad604d90616ec1feb7a5aa442824d Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 6 Apr 2023 14:43:21 -0400 Subject: [PATCH 039/165] small change --- pephubclient/__init__.py | 2 +- pephubclient/cli.py | 4 +--- pephubclient/files_manager.py | 2 +- pephubclient/helpers.py | 7 ++++--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 658c3cad..256b5240 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -5,4 +5,4 @@ __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" -__all__ = ["PEPHubClient"] +__all__ = ["PEPHubClient", __app_name__, __author__, __version__] diff --git a/pephubclient/cli.py b/pephubclient/cli.py index c1eefe08..88302241 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -1,10 +1,8 @@ import typer from pephubclient import __app_name__, __version__ - -from pephubclient.helpers import MessageHandler -from pephubclient.pephubclient import PEPHubClient from pephubclient.helpers import call_client_func +from pephubclient.pephubclient import PEPHubClient _client = PEPHubClient() diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 91366a28..c2a0d011 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -1,6 +1,6 @@ import os -from pathlib import Path from contextlib import suppress +from pathlib import Path import pandas import yaml diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 3ed7fbb6..4974c882 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,9 +1,10 @@ import json -from typing import NoReturn, Optional, Any, Callable -import requests +from typing import Any, Callable, NoReturn, Optional +import requests from requests.exceptions import ConnectionError -from pephubclient.exceptions import ResponseError, PEPExistsError + +from pephubclient.exceptions import PEPExistsError, ResponseError class RequestManager: From 4297537e2a22992ff0deeb89ca7f780eb6342c93 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 6 Apr 2023 14:53:51 -0400 Subject: [PATCH 040/165] docstrings --- pephubclient/files_manager.py | 2 ++ pephubclient/helpers.py | 1 + pephubclient/pephub_oauth/exceptions.py | 1 + pephubclient/pephubclient.py | 11 +++++++++++ 4 files changed, 15 insertions(+) diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index c2a0d011..3da0d19d 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -32,6 +32,7 @@ def load_jwt_data_from_file(path: str) -> str: def create_project_folder(registry_path: RegistryPath) -> str: """ Create new project folder + :param registry_path: project registry path :return: folder_path """ @@ -66,6 +67,7 @@ def delete_file_if_exists(filename: str) -> None: 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. """ diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 4974c882..58fc0c56 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -31,6 +31,7 @@ def send_request( def decode_response(response: requests.Response, encoding: str = "utf-8") -> str: """ 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] :return: Response data as an instance of correct model. diff --git a/pephubclient/pephub_oauth/exceptions.py b/pephubclient/pephub_oauth/exceptions.py index e0becb51..d7ef711c 100644 --- a/pephubclient/pephub_oauth/exceptions.py +++ b/pephubclient/pephub_oauth/exceptions.py @@ -19,6 +19,7 @@ class PEPHubTokenExchangeException(Exception): def __init__(self, reason: str = ""): """ Optionally provide explanation for exceptional condition. + :param str reason: some context or perhaps just a value that could not be interpreted as an accession """ diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index eecd3978..1ea14924 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -54,6 +54,7 @@ def logout(self) -> NoReturn: def pull(self, project_registry_path: str, force: Optional[bool] = False) -> 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. :return: None @@ -74,6 +75,7 @@ def load_project( ) -> peppy.Project: """ Load peppy project from PEPhub in peppy.Project object + :param project_registry_path: registry path of the project :param query_param: query parameters used in get request :return Project: peppy project. @@ -94,6 +96,7 @@ def push( ) -> None: """ Push (upload/update) project to Pephub using config/csv path + :param str cfg: Project config file (YAML) or sample table (CSV/TSV) with one row per sample to constitute project :param str namespace: namespace @@ -124,6 +127,7 @@ def upload( ) -> None: """ Upload peppy project to the PEPhub. + :param peppy.Project project: Project object that has to be uploaded to the DB :param namespace: namespace :param name: project name @@ -172,6 +176,7 @@ def _save_raw_pep( ) -> None: """ Save project locally. + :param dict project_dict: PEP dictionary (raw project) :param bool force: overwrite project if exists :return: None @@ -231,6 +236,7 @@ def _load_raw_pep( ) -> 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. @@ -266,6 +272,7 @@ def _load_raw_pep( def _set_registry_data(self, query_string: str) -> None: """ Parse provided query string to extract project name, sample name, etc. + :param query_string: Passed by user. Contain information needed to locate the project. :return: Parsed query string. """ @@ -278,6 +285,7 @@ def _set_registry_data(self, query_string: str) -> None: def _get_header(jwt_data: Optional[str] = None) -> dict: """ Create Authorization header + :param jwt_data: jwt string :return: Authorization dict """ @@ -289,6 +297,7 @@ def _get_header(jwt_data: Optional[str] = None) -> dict: def _build_pull_request_url(self, query_param: dict = None) -> str: """ Build request for getting projects form pephub + :param query_param: dict of parameters used in query string :return: url string """ @@ -306,6 +315,7 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: def _build_push_request_url(namespace: str) -> str: """ Build project uplaod request used in pephub + :param namespace: namespace where project will be uploaded :return: url string """ @@ -316,6 +326,7 @@ 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. """ From b158c89e28da2c2ac75dc0c96c887467b09a2bb0 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 6 Apr 2023 15:33:16 -0400 Subject: [PATCH 041/165] polishing 3 --- pephubclient/pephubclient.py | 78 ++++++++++++++++++------------------ tests/test_pephubclient.py | 1 - 2 files changed, 39 insertions(+), 40 deletions(-) diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 1ea14924..6c7d16ea 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -181,36 +181,41 @@ def _save_raw_pep( :param bool force: overwrite project if exists :return: None """ + 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["name"] + 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") + config_dict = project_dict.get("_config", {}) config_dict["name"] = project_name config_dict["description"] = project_dict["description"] - config_dict["sample_table"] = "sample_table.csv" + config_dict["sample_table"] = sample_table_filename - sample_dict = project_dict.get("_sample_dict") - sample_pandas = pd.DataFrame(sample_dict) + sample_pandas = pd.DataFrame(project_dict.get("_sample_dict", {})) + subsample_list = [ + pd.DataFrame(sub_a) for sub_a in project_dict.get("_subsample_dict", []) + ] - if project_dict.get("_subsample_dict"): - subsample_list = [ - pd.DataFrame(sub_a) for sub_a in project_dict["_subsample_dict"] - ] - config_dict["subsample_table"] = [] - for number, value in enumerate(subsample_list, start=1): - config_dict["subsample_table"].append(f"subsample_table{number}.csv") - else: - subsample_list = None - reg_path_model = RegistryPath(**parse_registry_path(reg_path)) - folder_path = FilesManager.create_project_folder(registry_path=reg_path_model) - - yaml_full_path = os.path.join(folder_path, f"{project_name}_config.yaml") - sample_full_path = os.path.join(folder_path, config_dict["sample_table"]) - - if FilesManager.file_exists(yaml_full_path) or FilesManager.file_exists( - sample_full_path - ): - if not force: - raise PEPExistsError(f"PEP already exists locally: '{folder_path}'") + 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) @@ -242,8 +247,7 @@ def _load_raw_pep( :param jwt_data: JWT token. :return: Raw project in dict. """ - if not query_param: - query_param = {} + query_param = query_param or {} query_param["raw"] = "true" self._set_registry_data(registry_path) @@ -260,14 +264,10 @@ def _load_raw_pep( # This step is necessary because of this issue: https://github.com/pepkit/pephub/issues/124 return correct_proj_dict.dict(by_alias=True) - elif pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: + if pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: raise ResponseError("File does not exist, or you are unauthorized.") - elif pephub_response.status_code == ResponseStatusCodes.INTERNAL_ERROR: + if pephub_response.status_code == ResponseStatusCodes.INTERNAL_ERROR: raise ResponseError("Internal server error.") - else: - raise ResponseError( - f"Unknown error occurred. Status: {pephub_response.status_code}" - ) def _set_registry_data(self, query_string: str) -> None: """ @@ -301,14 +301,14 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: :param query_param: dict of parameters used in query string :return: url string """ - if not query_param: - query_param = {} + query_param = query_param or {} query_param["tag"] = self.registry_path.tag endpoint = self.registry_path.namespace + "/" + self.registry_path.item - if query_param: - variables_string = PEPHubClient._parse_query_param(query_param) - endpoint += variables_string + + variables_string = PEPHubClient._parse_query_param(query_param) + endpoint += variables_string + return PEPHUB_PEP_API_BASE_URL + endpoint @staticmethod @@ -345,5 +345,5 @@ def _handle_pephub_response(pephub_response: requests.Response): if pephub_response.status_code != ResponseStatusCodes.OK: raise ResponseError(message=json.loads(decoded_response).get("detail")) - else: - return decoded_response + + return decoded_response diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 76f7693c..cdfea05b 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -89,7 +89,6 @@ def test_pull(self, mocker, test_jwt, test_raw_pep_return): 500, "Internal server error.", ), - (501, "Unknown error occurred. Status: 501"), ], ) def test_pull_with_pephub_error_response( From fbcc39fe29212c51fb8b318c76740a7558d071b5 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 6 Apr 2023 16:10:02 -0400 Subject: [PATCH 042/165] fixed error in project models --- pephubclient/models.py | 2 +- pephubclient/pephubclient.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pephubclient/models.py b/pephubclient/models.py index ad89e0c9..cdd25af3 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -9,7 +9,7 @@ class ProjectDict(BaseModel): Project dict (raw) model """ - description: str = "" + description: Optional[str] = "" config: dict = Field(alias="_config") subsample_dict: Optional[list] = Field(alias="_subsample_dict") name: str diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 6c7d16ea..c66aba87 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -200,14 +200,15 @@ def full_path(fn: str) -> str: f"{len(extant)} file(s) exist(s): {', '.join(extant)}" ) - config_dict = project_dict.get("_config", {}) + config_dict = project_dict.get("_config") config_dict["name"] = project_name config_dict["description"] = project_dict["description"] config_dict["sample_table"] = sample_table_filename sample_pandas = pd.DataFrame(project_dict.get("_sample_dict", {})) + subsample_list = [ - pd.DataFrame(sub_a) for sub_a in project_dict.get("_subsample_dict", []) + pd.DataFrame(sub_a) for sub_a in project_dict.get("_subsample_dict") or [] ] filenames = [] From be0fae5dd8a5e977d67ef24e0dde492a4d56317e Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Fri, 14 Apr 2023 10:02:35 -0400 Subject: [PATCH 043/165] fixed setup dependencies --- requirements/requirements-all.txt | 1 + setup.py | 14 ++++++++++---- tests/conftest.py | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index 4999d3cc..7bcc433d 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -2,3 +2,4 @@ typer>=0.7.0 peppy>=0.35.4 requests>=2.28.2 pydantic>=1.10.6 +pandas>=2.0.0 diff --git a/setup.py b/setup.py index 5fc9e6d7..de760ba9 100644 --- a/setup.py +++ b/setup.py @@ -3,9 +3,15 @@ from setuptools import setup -from pephubclient import __app_name__, __author__, __version__ +with open(os.path.join("pephubclient", "__init__.py")) as f: + for line in f: + if line.startswith("__app_name__"): + PACKAGE = line.split("=")[1].strip().strip('"') + if line.startswith("__author__"): + AUTHOR = line.split("=")[1].strip().strip('"') + if line.startswith("__version__"): + VERSION = line.split("=")[1].strip().strip('"') -PACKAGE = __app_name__ REQDIR = "requirements" # Additional keyword arguments for setup(). @@ -26,7 +32,7 @@ def read_reqs(reqs_name): setup( name=PACKAGE, packages=[PACKAGE], - version=__version__, + version=VERSION, description="PEPhub command line interface.", long_description=long_description, long_description_content_type="text/markdown", @@ -41,7 +47,7 @@ def read_reqs(reqs_name): ], keywords="project, bioinformatics, metadata", url=f"https://github.com/databio/{PACKAGE}/", - author=__author__, + author=AUTHOR, license="BSD2", entry_points={ "console_scripts": [ diff --git a/tests/conftest.py b/tests/conftest.py index 140294e7..92baa279 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ def device_code_return(): @pytest.fixture() def test_raw_pep_return(): sample_prj = { - "description": "sample-desc", + "description": None, "config": {"This": "is config"}, "subsample_dict": [], "name": "sample name", From 6b8a571a3927394016869ad780452dfcd2720739 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sun, 16 Apr 2023 19:35:16 -0400 Subject: [PATCH 044/165] Added windows support --- .github/workflows/pytest-windows.yml | 35 ++++++++++++++++++++++++++++ pephubclient/pephubclient.py | 5 +++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pytest-windows.yml diff --git a/.github/workflows/pytest-windows.yml b/.github/workflows/pytest-windows.yml new file mode 100644 index 00000000..6884749b --- /dev/null +++ b/.github/workflows/pytest-windows.yml @@ -0,0 +1,35 @@ +name: Run pytests windows + +on: + push: + branches: [dev] + pull_request: + branches: [main, dev] + +jobs: + pytest: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ["3.10"] + os: [windows-latest] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install all dependencies + run: pip install -r requirements/requirements-all.txt + + - name: Install test dependencies + run: pip install -r requirements/requirements-test.txt + + - name: Install package + run: python -m pip install . + + - name: Run pytest tests + run: pytest tests -v \ No newline at end of file diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index c66aba87..a8f6218b 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -30,8 +30,11 @@ 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(os.getenv("HOME"), ".pephubclient/") + USER_DATA_FILE_NAME + os.path.join(home_path, ".pephubclient/") + USER_DATA_FILE_NAME ) def __init__(self): From 2883aae5f8eadaed2b7755ed9c9c9fef10967081 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sun, 16 Apr 2023 20:05:00 -0400 Subject: [PATCH 045/165] Added version flag --- pephubclient/cli.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 88302241..c5c91a93 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -70,9 +70,15 @@ def push( ) -@app.command() -def version(): - """ - Package version - """ - print(f"{__app_name__} v{__version__}") +def version_callback(value: bool): + if value: + typer.echo(f"{__app_name__} version: {__version__}") + raise typer.Exit() + + +@app.callback() +def common( + ctx: typer.Context, + version: bool = typer.Option(None, "--version", "-v", callback=version_callback), +): + pass From 19a88d91da81716656ec769618b9f31c8a7f6e60 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sun, 16 Apr 2023 20:17:42 -0400 Subject: [PATCH 046/165] fixed Usage docs --- README.md | 95 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 52 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 0db00ca0..d96791ea 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # `PEPHubClient` [![PEP compatible](https://pepkit.github.io/img/PEP-compatible-green.svg)](https://pepkit.github.io) -![Run pytests](https://github.com/pepkit/geofetch/workflows/Run%20pytests/badge.svg) +![Run pytests](https://github.com/pepkit/pephubclient/workflows/Run%20pytests/badge.svg) [![pypi-badge](https://img.shields.io/pypi/v/pephubclient)](https://pypi.org/project/pephubclient) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) @@ -18,54 +18,63 @@ To upload projects or to download private projects, user must be authorized thro To login, use the `login` argument; to logout, use `logout`. ---- -`phc --help` ```text -╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ login Login to PEPhub │ -│ logout Logout │ -│ pull Download and save project locally. │ -│ push Upload/update project in PEPhub │ -│ version Print package version │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +$ phc --help + + Usage: pephubclient [OPTIONS] COMMAND [ARGS]... + +╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --version -v │ +│ --install-completion Install completion for the current shell. │ +│ --show-completion Show completion for the current shell, to copy it or customize the │ +│ installation. │ +│ --help Show this message and exit. │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ login Login to PEPhub │ +│ logout Logout │ +│ pull Download and save project locally. │ +│ push Upload/update project in PEPhub │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` -`phc pull --help` ```text - Usage: pephubclient pull [OPTIONS] PROJECT_REGISTRY_PATH - - Download and save project locally. - -╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * project_registry_path TEXT [default: None] [required] │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ [default: default] │ -│ --force --no-force Overwrite project if exists [default: no-force] │ -│ --help Show this message and exit. │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - +$ phc pull --help + + Usage: pephubclient pull [OPTIONS] PROJECT_REGISTRY_PATH + + Download and save project locally. + +╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * project_registry_path TEXT [default: None] [required] │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --force --no-force Overwrite project if it exists. [default: no-force] │ +│ --help Show this message and exit. │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` -`phc push --help` ```text - Usage: pephubclient push [OPTIONS] CFG - - Upload/update project in PEPhub - -╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * cfg TEXT Project config file (YAML) or sample table (CSV/TSV) with one row per sample to │ -│ constitute project │ -│ [default: None] │ -│ [required] │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * --namespace TEXT Project name [default: None] [required] │ -│ * --name TEXT Project name [default: None] [required] │ -│ --tag TEXT Project tag [default: None] │ -│ --force --no-force Force push to the database. Use it to update, or upload project. │ -│ [default: no-force] │ -│ --is-private --no-is-private Upload project as private. [default: no-is-private] │ -│ --help Show this message and exit. │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +$ phc push --help + + Usage: pephubclient push [OPTIONS] CFG + + Upload/update project in PEPhub + +╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * cfg TEXT Project config file (YAML) or sample table (CSV/TSV)with one row per sample to constitute │ +│ project │ +│ [default: None] │ +│ [required] │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * --namespace TEXT Project namespace [default: None] [required] │ +│ * --name TEXT Project name [default: None] [required] │ +│ --tag TEXT Project tag [default: None] │ +│ --force --no-force Force push to the database. Use it to update, or upload project. │ +│ [default: no-force] │ +│ --is-private --no-is-private Upload project as private. [default: no-is-private] │ +│ --help Show this message and exit. │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` \ No newline at end of file From de141c060f94bafa83aea7a83f9b4cc36637c69e Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sun, 16 Apr 2023 20:19:56 -0400 Subject: [PATCH 047/165] fixed connection error message --- pephubclient/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 58fc0c56..9cbda3e5 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -78,7 +78,7 @@ def call_client_func(func: Callable[..., Any], **kwargs) -> Any: func(**kwargs) except ConnectionError as err: MessageHandler.print_error( - f"Failed to upload project. Connection Error. Try later. {err}" + f"Failed to connect to server. Try later. {err}" ) except ResponseError as err: MessageHandler.print_error(f"{err}") From 277c15b03f1efb9c7ec01159014c8bf61ecb5ee5 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sun, 16 Apr 2023 20:35:39 -0400 Subject: [PATCH 048/165] changed to public server lint added publish workflow --- .github/workflows/python-publish.yml | 31 ++++++++++++++++++++++++++++ pephubclient/cli.py | 2 +- pephubclient/constants.py | 4 ++-- pephubclient/helpers.py | 5 +---- 4 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 00000000..4e1ef42d --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,31 @@ +# This workflows will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/pephubclient/cli.py b/pephubclient/cli.py index c5c91a93..163c3243 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -79,6 +79,6 @@ def version_callback(value: bool): @app.callback() def common( ctx: typer.Context, - version: bool = typer.Option(None, "--version", "-v", callback=version_callback), + version: bool = typer.Option(None, "--version", "-v", callback=version_callback, help="App version"), ): pass diff --git a/pephubclient/constants.py b/pephubclient/constants.py index d01d5f9d..e9cee89a 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -4,8 +4,8 @@ import pydantic from pydantic import BaseModel -# PEPHUB_BASE_URL = "https://pephub.databio.org/" -PEPHUB_BASE_URL = "http://0.0.0.0:8000/" +PEPHUB_BASE_URL = "https://pephub.databio.org/" +# PEPHUB_BASE_URL = "http://0.0.0.0:8000/" PEPHUB_PEP_API_BASE_URL = f"{PEPHUB_BASE_URL}api/v1/projects/" PEPHUB_PUSH_URL = f"{PEPHUB_BASE_URL}api/v1/namespaces/{{namespace}}/projects/json" diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 9cbda3e5..02544838 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -77,11 +77,8 @@ def call_client_func(func: Callable[..., Any], **kwargs) -> Any: try: func(**kwargs) except ConnectionError as err: - MessageHandler.print_error( - f"Failed to connect to server. Try later. {err}" - ) + MessageHandler.print_error(f"Failed to connect to server. Try later. {err}") except ResponseError as err: MessageHandler.print_error(f"{err}") - except PEPExistsError as err: MessageHandler.print_warning(f"PEP already exists. {err}") From 57a1868e1c840ac56ef6929af18c6bd2e01364fd Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sun, 16 Apr 2023 20:43:20 -0400 Subject: [PATCH 049/165] Added changelog --- docs/changelog.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/changelog.md diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 00000000..1e369bce --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,9 @@ +# 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.1.0] - 2023-04-16 +### Added +- First release From 4af98bd3b31b781d84a0171cee8e1796e5352fc6 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sun, 16 Apr 2023 20:44:50 -0400 Subject: [PATCH 050/165] lint --- pephubclient/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 163c3243..75530620 100644 --- a/pephubclient/cli.py +++ b/pephubclient/cli.py @@ -79,6 +79,8 @@ def version_callback(value: bool): @app.callback() def common( ctx: typer.Context, - version: bool = typer.Option(None, "--version", "-v", callback=version_callback, help="App version"), + version: bool = typer.Option( + None, "--version", "-v", callback=version_callback, help="App version" + ), ): pass From 620347a5d3cfdd7d64a34eb17d6f8f06c41d038c Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 17 Apr 2023 09:05:42 -0400 Subject: [PATCH 051/165] Setup fix --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index de760ba9..3a02a106 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ def read_reqs(reqs_name): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Topic :: Scientific/Engineering :: Bioinformatics", + "Topic :: Scientific/Engineering :: Bio-Informatics", ], keywords="project, bioinformatics, metadata", url=f"https://github.com/databio/{PACKAGE}/", From 75f23032cac3fc75426699b4acbc2246b9db4bde Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Mon, 17 Apr 2023 09:26:47 -0400 Subject: [PATCH 052/165] Update setup.py2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3a02a106..aaf88938 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ def read_reqs(reqs_name): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Topic :: Scientific/Engineering :: Bio-Informatics", + "Topic :: Scientific/Engineering :: Bio-Informatics", ], keywords="project, bioinformatics, metadata", url=f"https://github.com/databio/{PACKAGE}/", From ef98cc7c7576a7aec7159fcfeb7f37df158e4091 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 19 Apr 2023 16:34:35 -0400 Subject: [PATCH 053/165] updated test --- .github/workflows/pytest.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 03c9f431..a53f5541 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -32,4 +32,10 @@ jobs: run: python -m pip install . - name: Run pytest tests - run: pytest tests -v \ No newline at end of file + run: pytest tests --cov=./ --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 + with: + file: ./coverage.xml + name: py-${{ matrix.python-version }}-${{ matrix.os }} \ No newline at end of file From 3972d82e58577a40ab01080fece9cf830b180fea Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 19 Apr 2023 16:36:22 -0400 Subject: [PATCH 054/165] updated test1 --- requirements/requirements-test.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index b2c2b3f8..1825cf5d 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -2,4 +2,6 @@ black pytest python-dotenv pytest-mock -flake8 \ No newline at end of file +flake8 +coveralls +pytest-cov \ No newline at end of file From 1eb00b55deb69cf3cef6673d074beed829330b5a Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 19 Apr 2023 17:03:56 -0400 Subject: [PATCH 055/165] updated test 2 --- .github/workflows/pytest.yml | 6 ------ .github/workflows/run-codecov.yml | 23 +++++++++++++++++++++++ README.md | 1 + codecov.yml | 5 +++++ 4 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/run-codecov.yml create mode 100644 codecov.yml diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a53f5541..57432f25 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -33,9 +33,3 @@ jobs: - name: Run pytest tests run: pytest tests --cov=./ --cov-report=xml - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 - with: - file: ./coverage.xml - name: py-${{ matrix.python-version }}-${{ matrix.os }} \ No newline at end of file diff --git a/.github/workflows/run-codecov.yml b/.github/workflows/run-codecov.yml new file mode 100644 index 00000000..d9b80a22 --- /dev/null +++ b/.github/workflows/run-codecov.yml @@ -0,0 +1,23 @@ +name: Run codecov + +on: + push: + branches: [dev] + pull_request: + branches: [master] + +jobs: + pytest: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: [3.11] + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v2 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + name: py-${{ matrix.python-version }}-${{ matrix.os }} \ No newline at end of file diff --git a/README.md b/README.md index d96791ea..0173b69d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![PEP compatible](https://pepkit.github.io/img/PEP-compatible-green.svg)](https://pepkit.github.io) ![Run pytests](https://github.com/pepkit/pephubclient/workflows/Run%20pytests/badge.svg) +[![codecov](https://codecov.io/gh/pepkit/pephubclient/branch/dev/graph/badge.svg)](https://codecov.io/gh/pepkit/pephubclient) [![pypi-badge](https://img.shields.io/pypi/v/pephubclient)](https://pypi.org/project/pephubclient) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..80dacb96 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,5 @@ +ignore: + - "*/cli.py" + - "*/__main__.py" + - "*/__init__.py" + - "setup.py" \ No newline at end of file From a380156a38144878fbe94035129fcd4794b949bf Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 19 Apr 2023 17:07:25 -0400 Subject: [PATCH 056/165] updated test 3 --- .github/workflows/pytest.yml | 2 +- .github/workflows/run-codecov.yml | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 57432f25..3a32dfc5 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -32,4 +32,4 @@ jobs: run: python -m pip install . - name: Run pytest tests - run: pytest tests --cov=./ --cov-report=xml + run: pytest tests -v diff --git a/.github/workflows/run-codecov.yml b/.github/workflows/run-codecov.yml index d9b80a22..364eb682 100644 --- a/.github/workflows/run-codecov.yml +++ b/.github/workflows/run-codecov.yml @@ -16,6 +16,21 @@ jobs: steps: - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install test dependencies + run: if [ -f requirements/requirements-test.txt ]; then pip install -r requirements/requirements-test.txt; fi + + - name: Install package + run: python -m pip install . + + - name: Run pytest tests + run: pytest tests --cov=./ --cov-report=xml + - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: From 3dd549035449847bb2c52491ec5fb2c16471e307 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 27 Jun 2023 12:14:43 -0400 Subject: [PATCH 057/165] response fix --- pephubclient/pephubclient.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index a8f6218b..b341fe47 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -271,7 +271,9 @@ def _load_raw_pep( if pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: raise ResponseError("File does not exist, or you are unauthorized.") if pephub_response.status_code == ResponseStatusCodes.INTERNAL_ERROR: - raise ResponseError("Internal server error.") + raise ResponseError( + f"Internal server error. Unexpected return value. Error: {pephub_response.status_code}" + ) def _set_registry_data(self, query_string: str) -> None: """ From 71b3292afa271bf5cf2cc0f9515cb86e02b82c71 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 19 Jul 2023 11:59:37 -0400 Subject: [PATCH 058/165] Fixed #20 --- pephubclient/constants.py | 4 ++-- pephubclient/helpers.py | 2 ++ pephubclient/models.py | 6 ++---- pephubclient/pephubclient.py | 20 +++++++++++++++----- requirements/requirements-all.txt | 4 ++-- tests/conftest.py | 22 ++++++++++------------ tests/test_pephubclient.py | 2 +- 7 files changed, 34 insertions(+), 26 deletions(-) diff --git a/pephubclient/constants.py b/pephubclient/constants.py index e9cee89a..d01d5f9d 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -4,8 +4,8 @@ import pydantic from pydantic import BaseModel -PEPHUB_BASE_URL = "https://pephub.databio.org/" -# PEPHUB_BASE_URL = "http://0.0.0.0:8000/" +# PEPHUB_BASE_URL = "https://pephub.databio.org/" +PEPHUB_BASE_URL = "http://0.0.0.0:8000/" PEPHUB_PEP_API_BASE_URL = f"{PEPHUB_BASE_URL}api/v1/projects/" PEPHUB_PUSH_URL = f"{PEPHUB_BASE_URL}api/v1/namespaces/{{namespace}}/projects/json" diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 02544838..4e42c4c9 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -82,3 +82,5 @@ def call_client_func(func: Callable[..., Any], **kwargs) -> Any: MessageHandler.print_error(f"{err}") except PEPExistsError as err: MessageHandler.print_warning(f"PEP already exists. {err}") + except OSError as err: + MessageHandler.print_error(f"{err}") diff --git a/pephubclient/models.py b/pephubclient/models.py index cdd25af3..bd9592b0 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -9,11 +9,9 @@ class ProjectDict(BaseModel): Project dict (raw) model """ - description: Optional[str] = "" config: dict = Field(alias="_config") - subsample_dict: Optional[list] = Field(alias="_subsample_dict") - name: str - sample_dict: dict = Field(alias="_sample_dict") + subsample_list: Optional[list] = Field(alias="_subsample_list") + sample_list: list = Field(alias="_sample_dict") class Config: allow_population_by_field_name = True diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index b341fe47..e47da5e5 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -4,6 +4,7 @@ import pandas as pd import peppy +from peppy.const import NAME_KEY, DESC_KEY, CONFIG_KEY import requests import urllib3 from pydantic.error_wrappers import ValidationError @@ -145,7 +146,10 @@ def upload( project["name"] = name upload_data = ProjectUploadData( - pep_dict=project.to_dict(extended=True), + pep_dict=project.to_dict( + extended=True, + orient="records", + ), tag=tag, is_private=is_private, overwrite=force, @@ -167,6 +171,10 @@ def upload( ) elif pephub_response.status_code == ResponseStatusCodes.UNAUTHORIZED: raise ResponseError("Unauthorized! Failure in uploading project.") + elif pephub_response.status_code == ResponseStatusCodes.FORBIDDEN: + raise ResponseError( + "User does not have permission to write to this namespace!" + ) else: raise ResponseError("Unexpected Response Error.") return None @@ -190,7 +198,7 @@ def _save_raw_pep( def full_path(fn: str) -> str: return os.path.join(folder_path, fn) - project_name = project_dict["name"] + 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) @@ -203,9 +211,9 @@ def full_path(fn: str) -> str: f"{len(extant)} file(s) exist(s): {', '.join(extant)}" ) - config_dict = project_dict.get("_config") - config_dict["name"] = project_name - config_dict["description"] = project_dict["description"] + 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_dict", {})) @@ -251,6 +259,8 @@ def _load_raw_pep( :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" diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index 7bcc433d..11876de1 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -1,5 +1,5 @@ typer>=0.7.0 -peppy>=0.35.4 +peppy>=0.35.7 requests>=2.28.2 -pydantic>=1.10.6 +pydantic<=2.0 pandas>=2.0.0 diff --git a/tests/conftest.py b/tests/conftest.py index 92baa279..48b44839 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,19 +17,17 @@ def device_code_return(): @pytest.fixture() def test_raw_pep_return(): sample_prj = { - "description": None, - "config": {"This": "is config"}, - "subsample_dict": [], - "name": "sample name", - "sample_dict": { - "organism": {"0": "pig", "1": "pig", "2": "frog", "3": "frog"}, - "sample_name": { - "0": "pig_0h", - "1": "pig_1h", - "2": "frog_0h", - "3": "frog_1h", - }, + "config": { + "This": "is config", + "description": "desc", + "name": "sample name", }, + "subsample_list": [], + "sample_list": [ + {"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"}, + ], } return json.dumps(sample_prj) diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index cdfea05b..9fce34ba 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -87,7 +87,7 @@ def test_pull(self, mocker, test_jwt, test_raw_pep_return): ), ( 500, - "Internal server error.", + "Internal server error. Unexpected return value. Error: 500", ), ], ) From efd1d9f44d47c72cb69e7df0fbac749ac2a02b8c Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 19 Jul 2023 12:02:13 -0400 Subject: [PATCH 059/165] updated changelog --- docs/changelog.md | 4 +++- pephubclient/__init__.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 1e369bce..9212ace5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,9 @@ 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.1.1] - 2023-07-29 +### Fixed +- New raw PEP structure was broken. ([#20](https://github.com/pepkit/pephubclient/issues/20)) ## [0.1.0] - 2023-04-16 ### Added diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 256b5240..ec986ea9 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,7 +1,7 @@ from pephubclient.pephubclient import PEPHubClient __app_name__ = "pephubclient" -__version__ = "0.1.0" +__version__ = "0.1.1" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" From 18a8c53af8b0bbcc4000cf38b4171818786eca11 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 20 Jul 2023 11:19:56 -0400 Subject: [PATCH 060/165] Updated const in models --- pephubclient/models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pephubclient/models.py b/pephubclient/models.py index bd9592b0..035cf8fd 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -2,6 +2,7 @@ import pydantic from pydantic import BaseModel, Extra, Field +from peppy.const import CONFIG_KEY, SUBSAMPLE_RAW_LIST_KEY, SAMPLE_RAW_DICT_KEY class ProjectDict(BaseModel): @@ -9,9 +10,9 @@ class ProjectDict(BaseModel): Project dict (raw) model """ - config: dict = Field(alias="_config") - subsample_list: Optional[list] = Field(alias="_subsample_list") - sample_list: list = Field(alias="_sample_dict") + config: dict = Field(alias=CONFIG_KEY) + subsample_list: Optional[list] = Field(alias=SUBSAMPLE_RAW_LIST_KEY) + sample_list: list = Field(alias=SAMPLE_RAW_DICT_KEY) class Config: allow_population_by_field_name = True From 59249fb831a2fb48c2f5cfc8569d2bcb4994b226 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 20 Jul 2023 11:27:36 -0400 Subject: [PATCH 061/165] Fixed pydanitc version --- requirements/requirements-all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index 11876de1..a2413eaa 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -1,5 +1,5 @@ typer>=0.7.0 peppy>=0.35.7 requests>=2.28.2 -pydantic<=2.0 +pydantic<2.0 pandas>=2.0.0 From 62e9d16b65ad82e8f23feaf6dfdab4363c1ba107 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 20 Jul 2023 13:08:08 -0400 Subject: [PATCH 062/165] Fixed base url --- pephubclient/constants.py | 4 ++-- pephubclient/pephubclient.py | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/pephubclient/constants.py b/pephubclient/constants.py index d01d5f9d..e9cee89a 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -4,8 +4,8 @@ import pydantic from pydantic import BaseModel -# PEPHUB_BASE_URL = "https://pephub.databio.org/" -PEPHUB_BASE_URL = "http://0.0.0.0:8000/" +PEPHUB_BASE_URL = "https://pephub.databio.org/" +# PEPHUB_BASE_URL = "http://0.0.0.0:8000/" PEPHUB_PEP_API_BASE_URL = f"{PEPHUB_BASE_URL}api/v1/projects/" PEPHUB_PUSH_URL = f"{PEPHUB_BASE_URL}api/v1/namespaces/{{namespace}}/projects/json" diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index e47da5e5..838deb5b 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -4,7 +4,13 @@ import pandas as pd import peppy -from peppy.const import NAME_KEY, DESC_KEY, CONFIG_KEY +from peppy.const import ( + NAME_KEY, + DESC_KEY, + CONFIG_KEY, + SUBSAMPLE_RAW_LIST_KEY, + SAMPLE_RAW_DICT_KEY, +) import requests import urllib3 from pydantic.error_wrappers import ValidationError @@ -143,7 +149,7 @@ def upload( """ jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) if name: - project["name"] = name + project[NAME_KEY] = name upload_data = ProjectUploadData( pep_dict=project.to_dict( @@ -216,10 +222,11 @@ def full_path(fn: str) -> str: 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_dict", {})) + 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_dict") or [] + pd.DataFrame(sub_a) + for sub_a in project_dict.get(SUBSAMPLE_RAW_LIST_KEY) or [] ] filenames = [] From a404399e8ee8e4010d6c43b8b9cf90119c149f3f Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 20 Jul 2023 13:15:15 -0400 Subject: [PATCH 063/165] fixed codecov --- codecov.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/codecov.yml b/codecov.yml index 80dacb96..97381958 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,4 +2,20 @@ ignore: - "*/cli.py" - "*/__main__.py" - "*/__init__.py" - - "setup.py" \ No newline at end of file + - "setup.py" + +coverage: + status: + project: + default: false # disable the default status that measures entire project + tests: + paths: + - "tests/" + target: 100% + source: + paths: + - "jupytext/" + target: 70% + patch: + default: + target: 70% \ No newline at end of file From 96798ef54e42f8624b260f80d88d5fe2592946c3 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 20 Jul 2023 13:16:40 -0400 Subject: [PATCH 064/165] fixed codecov --- docs/changelog.md | 4 ++++ pephubclient/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 9212ace5..a87a0b6c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ 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.1.1] - 2023-07-29 +### Fixed +- Incorrect base url + ## [0.1.1] - 2023-07-29 ### Fixed - New raw PEP structure was broken. ([#20](https://github.com/pepkit/pephubclient/issues/20)) diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index ec986ea9..83071889 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,7 +1,7 @@ from pephubclient.pephubclient import PEPHubClient __app_name__ = "pephubclient" -__version__ = "0.1.1" +__version__ = "0.1.2" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" From 62d52d2c08e98c646538740c5aebda13914bce14 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 28 Aug 2023 17:25:18 +0200 Subject: [PATCH 065/165] added project search functionality --- docs/changelog.md | 4 ++ pephubclient/__init__.py | 2 +- pephubclient/constants.py | 1 + pephubclient/models.py | 23 +++++++++- pephubclient/pephubclient.py | 82 ++++++++++++++++++++++++++++++++++-- tests/test_pephubclient.py | 11 +++++ 6 files changed, 118 insertions(+), 5 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index a87a0b6c..5d1ebd25 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ 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.2.0] - 2023-08-28 +### Added +- Project search functionality + ## [0.1.1] - 2023-07-29 ### Fixed - Incorrect base url diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 83071889..5d45d35b 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,7 +1,7 @@ from pephubclient.pephubclient import PEPHubClient __app_name__ = "pephubclient" -__version__ = "0.1.2" +__version__ = "0.2.0" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" diff --git a/pephubclient/constants.py b/pephubclient/constants.py index e9cee89a..7ee023ae 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -7,6 +7,7 @@ PEPHUB_BASE_URL = "https://pephub.databio.org/" # 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" diff --git a/pephubclient/models.py b/pephubclient/models.py index 035cf8fd..07a92ba7 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -1,4 +1,5 @@ -from typing import Optional +import datetime +from typing import Optional, List import pydantic from pydantic import BaseModel, Extra, Field @@ -32,3 +33,23 @@ class ProjectUploadData(BaseModel): @pydantic.validator("tag") def tag_should_not_be_none(cls, v): return v or "default" + + +class ProjectAnnotationModel(BaseModel): + namespace: str + name: str + tag: str + is_private: bool + number_of_samples: int + description: str + last_update_date: datetime.datetime + submission_date: datetime.datetime + digest: str + pep_schema: str + + +class SearchReturnModel(BaseModel): + count: int + limit: int + offset: int + items: List[ProjectAnnotationModel] diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 838deb5b..ce1e4444 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,6 +1,6 @@ import json import os -from typing import NoReturn, Optional +from typing import NoReturn, Optional, Literal import pandas as pd import peppy @@ -21,6 +21,7 @@ PEPHUB_PUSH_URL, RegistryPath, ResponseStatusCodes, + PEPHUB_PEP_SEARCH_URL, ) from pephubclient.exceptions import ( IncorrectQueryStringError, @@ -29,7 +30,12 @@ ) from pephubclient.files_manager import FilesManager from pephubclient.helpers import MessageHandler, RequestManager -from pephubclient.models import ProjectDict, ProjectUploadData +from pephubclient.models import ( + ProjectDict, + ProjectUploadData, + SearchReturnModel, + ProjectAnnotationModel, +) from pephubclient.pephub_oauth.pephub_oauth import PEPHubAuth urllib3.disable_warnings() @@ -182,9 +188,64 @@ def upload( "User does not have permission to write to this namespace!" ) else: - raise ResponseError("Unexpected Response Error.") + raise ResponseError( + f"Unexpected Response Error. {pephub_response.status_code}" + ) return None + def find_project( + self, + namespace: str, + query_string: str = None, + limit: int = 100, + offset: int = 0, + filter_by: Literal["submission_date", "last_update_date"] = None, + start_date: str = None, + end_date: str = None, + ) -> SearchReturnModel: + """ + Find project in specific namespace and return list of PEP annotation + + :param namespace: Namespace where to search for projects + :param query_string: Search query + :param limit: Return limit + :param offset: Return offset + :param filter_by: Use filter date. Option: [submission_date, last_update_date] + :param start_date: filter beginning date + :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, + "limit": limit, + "offset": offset, + } + if filter_by: + query_param["filter_by"] = filter_by + query_param["start_date"] = start_date + query_param["end_date"] = end_date + + url = self._build_project_search_url( + namespace=namespace, + query_param=query_param, + ) + + pephub_response = self.send_request( + method="GET", + url=url, + headers=self._get_header(jwt_data), + json=None, + cookies=None, + ) + if pephub_response.status_code == ResponseStatusCodes.OK: + decoded_response = self._handle_pephub_response(pephub_response) + project_list = [] + for project_found in json.loads(decoded_response)["items"]: + project_list.append(ProjectAnnotationModel(**project_found)) + return SearchReturnModel(**json.loads(decoded_response)) + @staticmethod def _save_raw_pep( reg_path: str, @@ -334,6 +395,21 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: return PEPHUB_PEP_API_BASE_URL + endpoint + def _build_project_search_url( + self, namespace: str, query_param: dict = None + ) -> str: + """ + Build request for searching projects form pephub + + :param query_param: dict of parameters used in query string + :return: url string + """ + + variables_string = PEPHubClient._parse_query_param(query_param) + endpoint = variables_string + + return PEPHUB_PEP_SEARCH_URL.format(namespace=namespace) + endpoint + @staticmethod def _build_push_request_url(namespace: str) -> str: """ diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 9fce34ba..a273fc52 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -147,3 +147,14 @@ def test_push_with_pephub_error_response( namespace="s_name", name="name", ) + + 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}' + requests_mock = mocker.patch( + "requests.request", + return_value=Mock(content=return_value, status_code=200), + ) + + return_value = PEPHubClient().find_project(namespace="namespace1") + assert return_value.count == 1 + assert len(return_value.items) == 1 From e8469058aff17f4d1e662091f0db2f75ce8ff183 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 28 Aug 2023 17:30:17 +0200 Subject: [PATCH 066/165] fixed codecov --- codecov.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codecov.yml b/codecov.yml index 97381958..f0e5c72f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -11,7 +11,7 @@ coverage: tests: paths: - "tests/" - target: 100% + target: 70% source: paths: - "jupytext/" From 544c49eac8781a5a59498df313c60e64ab702a2f Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 29 Aug 2023 14:54:20 +0200 Subject: [PATCH 067/165] fixed pephub url --- pephubclient/pephubclient.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index ce1e4444..9eb6b8c0 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -196,7 +196,7 @@ def upload( def find_project( self, namespace: str, - query_string: str = None, + query_string: str = "", limit: int = 100, offset: int = 0, filter_by: Literal["submission_date", "last_update_date"] = None, @@ -222,10 +222,11 @@ def find_project( "limit": limit, "offset": offset, } - if filter_by: + if filter_by in ["submission_date", "last_update_date"]: query_param["filter_by"] = filter_by - query_param["start_date"] = start_date - query_param["end_date"] = end_date + query_param["filter_start_date"] = start_date + if end_date: + query_param["filter_end_date"] = end_date url = self._build_project_search_url( namespace=namespace, From 22e0e6f6d4709835e2fd50727294de7a8667eb0b Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Mon, 2 Oct 2023 18:53:47 +0200 Subject: [PATCH 068/165] Update changelog.md --- docs/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 5d1ebd25..e52ba390 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,7 @@ 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.2.0] - 2023-08-28 +## [0.2.0] - 2023-10-02 ### Added - Project search functionality From 22b2ce6e4c7d0fdced171ea588e661e4c0cd1438 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 1 Nov 2023 17:31:24 +0100 Subject: [PATCH 069/165] added reg_path check in helpers --- .pre-commit-config.yaml | 10 +++++++ Makefile | 3 +- docs/changelog.md | 4 +++ pephubclient/__init__.py | 2 +- pephubclient/helpers.py | 19 +++++++++++++ requirements/requirements-all.txt | 1 + requirements/requirements-test.txt | 4 ++- tests/test_pephubclient.py | 45 +++++++++++++++++++++++++++++- 8 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..20df14e5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: + # Run the Ruff linter. + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.1.3 + hooks: + # Run the Ruff linter. + - id: ruff + # Run the Ruff formatter. + - id: ruff-format diff --git a/Makefile b/Makefile index 19130c8e..5033ebb1 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,5 @@ lint: - # black should be last in the list, as it lint the code. Tests can fail if order will be different - flake8 && isort . && black . + ruff format . run-coverage: coverage run -m pytest diff --git a/docs/changelog.md b/docs/changelog.md index 5d1ebd25..c3ff70dc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ 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.2.1] - 2023-10-01 +### Added +- is_registry_path checker function + ## [0.2.0] - 2023-08-28 ### Added - Project search functionality diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 5d45d35b..60771acc 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,7 +1,7 @@ from pephubclient.pephubclient import PEPHubClient __app_name__ = "pephubclient" -__version__ = "0.2.0" +__version__ = "0.2.1" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 4e42c4c9..467a8dd3 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -4,7 +4,11 @@ import requests from requests.exceptions import ConnectionError +from ubiquerg import parse_registry_path +from pydantic.error_wrappers import ValidationError + from pephubclient.exceptions import PEPExistsError, ResponseError +from pephubclient.constants import RegistryPath class RequestManager: @@ -84,3 +88,18 @@ def call_client_func(func: Callable[..., Any], **kwargs) -> Any: MessageHandler.print_warning(f"PEP already exists. {err}") except OSError as err: MessageHandler.print_error(f"{err}") + + +def is_registry_path(input_string: str) -> bool: + """ + Check if input is a registry path to pephub + :param str input_string: path to the PEP (or registry path) + :return bool: True if input is a registry path + """ + if input_string.endswith(".yaml"): + return False + try: + RegistryPath(**parse_registry_path(input_string)) + except (ValidationError, TypeError): + return False + return True diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index a2413eaa..8163295e 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -3,3 +3,4 @@ peppy>=0.35.7 requests>=2.28.2 pydantic<2.0 pandas>=2.0.0 +ubiquerg>=0.6.3 \ No newline at end of file diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index 1825cf5d..241ddcdf 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -1,7 +1,9 @@ black +ruff pytest python-dotenv pytest-mock flake8 coveralls -pytest-cov \ No newline at end of file +pytest-cov +pre-commit \ No newline at end of file diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index a273fc52..c35c8ac2 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -5,6 +5,7 @@ from pephubclient.exceptions import ResponseError from pephubclient.pephubclient import PEPHubClient +from pephubclient.helpers import is_registry_path SAMPLE_PEP = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -150,7 +151,7 @@ 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}' - requests_mock = mocker.patch( + mocker.patch( "requests.request", return_value=Mock(content=return_value, status_code=200), ) @@ -158,3 +159,45 @@ def test_search_prj(self, mocker): return_value = PEPHubClient().find_project(namespace="namespace1") assert return_value.count == 1 assert len(return_value.items) == 1 + + +class TestHelpers: + @pytest.mark.parametrize( + "input_str, expected_output", + [ + ( + "databio/pep:default", + True, + ), + ( + "pephub.databio.org::databio/pep:default", + True, + ), + ( + "pephub.databio.org://databio/pep:default", + True, + ), + ( + "databio/pep", + True, + ), + ( + "databio/pep/default", + False, + ), + ( + "some/random/path/to.yaml", + False, + ), + ( + "path_to.csv", + False, + ), + ( + "this/is/path/to.csv", + False, + ), + ], + ) + def test_is_registry_path(self, input_str, expected_output): + assert is_registry_path(input_str) is expected_output From 67119ad1df3f0eec751c27a8bc0a9dd2642b6c56 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 1 Nov 2023 17:33:11 +0100 Subject: [PATCH 070/165] added linter --- .github/workflows/black.yml | 14 -------------- .github/workflows/ruff_linter.yml | 12 ++++++++++++ 2 files changed, 12 insertions(+), 14 deletions(-) delete mode 100644 .github/workflows/black.yml create mode 100644 .github/workflows/ruff_linter.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index 05ccf402..00000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Lint - -on: - pull_request: - branches: [main] - - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: psf/black@stable diff --git a/.github/workflows/ruff_linter.yml b/.github/workflows/ruff_linter.yml new file mode 100644 index 00000000..5419d746 --- /dev/null +++ b/.github/workflows/ruff_linter.yml @@ -0,0 +1,12 @@ +name: Ruff + +on: + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: chartboost/ruff-action@v1 From 7a89e24b2d72ad9b7e0c4fa94ef6c07cb8ef3884 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 13 Nov 2023 02:51:10 +0100 Subject: [PATCH 071/165] rolled back to black checker --- .github/workflows/black_linter.yml | 13 +++++++++++++ .github/workflows/ruff_linter.yml | 12 ------------ 2 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/black_linter.yml delete mode 100644 .github/workflows/ruff_linter.yml diff --git a/.github/workflows/black_linter.yml b/.github/workflows/black_linter.yml new file mode 100644 index 00000000..6d34fe30 --- /dev/null +++ b/.github/workflows/black_linter.yml @@ -0,0 +1,13 @@ +name: Lint + +on: + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - uses: psf/black@stable \ No newline at end of file diff --git a/.github/workflows/ruff_linter.yml b/.github/workflows/ruff_linter.yml deleted file mode 100644 index 5419d746..00000000 --- a/.github/workflows/ruff_linter.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Ruff - -on: - pull_request: - branches: [main] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: chartboost/ruff-action@v1 From 385ddeb66d7496618830b7313a61928174ffbaea Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 16 Jan 2024 10:59:11 -0500 Subject: [PATCH 072/165] Fixed #22 --- README.md | 3 +++ pephubclient/constants.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0173b69d..5e6f6261 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ Additionally, our client supports pephub authorization. The authorization process is based on pephub device authorization protocol. To upload projects or to download private projects, user must be authorized through pephub. +If you want to use your own pephub instance, you can specify it by setting `PEPHUB_BASE_URL` environment variable. +e.g. `export PEPHUB_BASE_URL=https://pephub.databio.org` (This is original pephub instance) + To login, use the `login` argument; to logout, use `logout`. ---- diff --git a/pephubclient/constants.py b/pephubclient/constants.py index 7ee023ae..0c4a79ff 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -1,10 +1,11 @@ from enum import Enum from typing import Optional +import os import pydantic from pydantic import BaseModel -PEPHUB_BASE_URL = "https://pephub.databio.org/" +PEPHUB_BASE_URL = os.getenv("PEPHUB_BASE_URL", default="https://pephub.databio.org/") # 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" From f0ac5a732aec92734ca42390b08716343df4b4b2 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 16 Jan 2024 12:07:57 -0500 Subject: [PATCH 073/165] updated version --- docs/changelog.md | 4 ++++ pephubclient/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 5dbb083e..be7aa7ed 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ 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.2.2] - 2024-01-06 +### Added +- customization of the base pephub URL. [#22](https://github.com/pepkit/pephubclient/issues/22) + ## [0.2.1] - 2023-11-01 ### Added - is_registry_path checker function diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 60771acc..8e2e1a5c 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,7 +1,7 @@ from pephubclient.pephubclient import PEPHubClient __app_name__ = "pephubclient" -__version__ = "0.2.1" +__version__ = "0.2.2" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" From 764b9c4ff1147acfe45d58730203de79283a705a Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 17 Jan 2024 12:33:44 -0500 Subject: [PATCH 074/165] v0.3.0 updates --- .github/workflows/python-publish.yml | 11 +++++------ docs/changelog.md | 6 +++++- pephubclient/constants.py | 9 +++++---- pephubclient/helpers.py | 10 +++++----- pephubclient/models.py | 9 +++------ pephubclient/pephubclient.py | 6 +++--- requirements/requirements-all.txt | 4 ++-- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 4e1ef42d..3b2d40e2 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -8,10 +8,12 @@ on: types: [created] jobs: - deploy: - + pypi-publish: runs-on: ubuntu-latest - + name: release to PyPI + environment: release + permissions: + id-token: write steps: - uses: actions/checkout@v2 - name: Set up Python @@ -23,9 +25,6 @@ jobs: python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* diff --git a/docs/changelog.md b/docs/changelog.md index be7aa7ed..e2bb29a7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,10 +2,14 @@ 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.2.2] - 2024-01-06 +## [0.3.0] - 2024-01-17 ### Added - customization of the base pephub URL. [#22](https://github.com/pepkit/pephubclient/issues/22) +### Updated +- PEPhub API URL +- Increased the required pydantic version to >2.5.0 + ## [0.2.1] - 2023-11-01 ### Added - is_registry_path checker function diff --git a/pephubclient/constants.py b/pephubclient/constants.py index 0c4a79ff..e36e66af 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -2,10 +2,11 @@ from typing import Optional import os -import pydantic -from pydantic import BaseModel +from pydantic import BaseModel, field_validator -PEPHUB_BASE_URL = os.getenv("PEPHUB_BASE_URL", default="https://pephub.databio.org/") +PEPHUB_BASE_URL = os.getenv( + "PEPHUB_BASE_URL", default="https://pephub-api.databio.org/" +) # 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" @@ -19,7 +20,7 @@ class RegistryPath(BaseModel): subitem: Optional[str] tag: Optional[str] = "default" - @pydantic.validator("tag") + @field_validator("tag") def tag_should_not_be_none(cls, v): return v or "default" diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 467a8dd3..41f06e9d 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,11 +1,11 @@ import json -from typing import Any, Callable, NoReturn, Optional +from typing import Any, Callable, Optional import requests from requests.exceptions import ConnectionError from ubiquerg import parse_registry_path -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from pephubclient.exceptions import PEPExistsError, ResponseError from pephubclient.constants import RegistryPath @@ -57,15 +57,15 @@ class MessageHandler: GREEN = 40 @staticmethod - def print_error(text: str) -> NoReturn: + def print_error(text: str) -> None: print(f"\033[38;5;9m{text}\033[0m") @staticmethod - def print_success(text: str) -> NoReturn: + def print_success(text: str) -> None: print(f"\033[38;5;40m{text}\033[0m") @staticmethod - def print_warning(text: str) -> NoReturn: + def print_warning(text: str) -> None: print(f"\033[38;5;11m{text}\033[0m") diff --git a/pephubclient/models.py b/pephubclient/models.py index 07a92ba7..b4a01729 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -1,8 +1,7 @@ import datetime from typing import Optional, List -import pydantic -from pydantic import BaseModel, Extra, Field +from pydantic import BaseModel, Field, field_validator, ConfigDict from peppy.const import CONFIG_KEY, SUBSAMPLE_RAW_LIST_KEY, SAMPLE_RAW_DICT_KEY @@ -15,9 +14,7 @@ class ProjectDict(BaseModel): subsample_list: Optional[list] = Field(alias=SUBSAMPLE_RAW_LIST_KEY) sample_list: list = Field(alias=SAMPLE_RAW_DICT_KEY) - class Config: - allow_population_by_field_name = True - extra = Extra.allow + model_config = ConfigDict(populate_by_name=True, extra="allow") class ProjectUploadData(BaseModel): @@ -30,7 +27,7 @@ class ProjectUploadData(BaseModel): is_private: Optional[bool] = False overwrite: Optional[bool] = False - @pydantic.validator("tag") + @field_validator("tag") def tag_should_not_be_none(cls, v): return v or "default" diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 9eb6b8c0..118bdd23 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -13,7 +13,7 @@ ) import requests import urllib3 -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from ubiquerg import parse_registry_path from pephubclient.constants import ( @@ -170,7 +170,7 @@ def upload( method="POST", url=self._build_push_request_url(namespace=namespace), headers=self._get_header(jwt_data), - json=upload_data.dict(), + json=upload_data.model_dump(), cookies=None, ) if pephub_response.status_code == ResponseStatusCodes.ACCEPTED: @@ -345,7 +345,7 @@ def _load_raw_pep( correct_proj_dict = ProjectDict(**json.loads(decoded_response)) # This step is necessary because of this issue: https://github.com/pepkit/pephub/issues/124 - return correct_proj_dict.dict(by_alias=True) + return correct_proj_dict.model_dump(by_alias=True) if pephub_response.status_code == ResponseStatusCodes.NOT_EXIST: raise ResponseError("File does not exist, or you are unauthorized.") diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index 8163295e..e0915898 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -1,6 +1,6 @@ typer>=0.7.0 -peppy>=0.35.7 +peppy>=0.40.0 requests>=2.28.2 -pydantic<2.0 +pydantic>2.5.0 pandas>=2.0.0 ubiquerg>=0.6.3 \ No newline at end of file From 16d734ac1ae71350fbffd934cf13510ca4afdd8b Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 17 Jan 2024 13:16:46 -0500 Subject: [PATCH 075/165] updated pypi publishing script --- .github/workflows/python-publish.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 3b2d40e2..b120129e 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,6 +1,3 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - name: Upload Python Package on: @@ -8,10 +5,9 @@ on: types: [created] jobs: - pypi-publish: + deploy: + name: upload release to PyPI runs-on: ubuntu-latest - name: release to PyPI - environment: release permissions: id-token: write steps: @@ -27,4 +23,5 @@ jobs: - name: Build and publish run: | python setup.py sdist bdist_wheel - twine upload dist/* + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file From 65c607b8054bc852d9a86303f633f5b89b5d049c Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 18 Jan 2024 17:41:02 -0500 Subject: [PATCH 076/165] added parent path specification in save_pep function #32 --- docs/changelog.md | 4 ++++ pephubclient/files_manager.py | 20 +++++++++++++++----- pephubclient/pephubclient.py | 13 +++++++++++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index e2bb29a7..d6c64748 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ 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-XX-XX +### Added +- Added param parent dir where peps should be saved + ## [0.3.0] - 2024-01-17 ### Added - customization of the base pephub URL. [#22](https://github.com/pepkit/pephubclient/issues/22) diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 3da0d19d..a9341862 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -29,17 +29,27 @@ 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( + registry_path: RegistryPath, parent_path: str, just_name: bool = False + ) -> str: """ Create new project folder :param registry_path: project registry path + :param parent_path: parent path to create folder in + :param just_name: if True, create folder with just name, not full path :return: folder_path """ - folder_name = FilesManager._create_filename_to_save_downloaded_project( - registry_path - ) - folder_path = os.path.join(os.getcwd(), folder_name) + if just_name: + folder_name = registry_path.item + else: + folder_name = FilesManager._create_filename_to_save_downloaded_project( + registry_path + ) + 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 diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 118bdd23..0e8cb342 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -81,7 +81,9 @@ def pull(self, project_registry_path: str, force: Optional[bool] = False) -> Non ) self._save_raw_pep( - reg_path=project_registry_path, project_dict=project_dict, force=force + reg_path=project_registry_path, + project_dict=project_dict, + force=force, ) def load_project( @@ -252,16 +254,23 @@ def _save_raw_pep( reg_path: str, project_dict: dict, force: bool = False, + project_path: Optional[str] = None, + just_name: bool = False, ) -> None: """ Save project locally. + :param str reg_path: Project registry path in PEPhub (e.g. databio/base:default) :param dict project_dict: PEP dictionary (raw project) :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 just_name: If True, create project folder with just name, not full path :return: None """ reg_path_model = RegistryPath(**parse_registry_path(reg_path)) - folder_path = FilesManager.create_project_folder(registry_path=reg_path_model) + folder_path = FilesManager.create_project_folder( + registry_path=reg_path_model, parent_path=project_path, just_name=just_name + ) def full_path(fn: str) -> str: return os.path.join(folder_path, fn) From fae2fb523ff3df182fc820c3bcc63011949b7381 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 22 Jan 2024 13:09:14 -0500 Subject: [PATCH 077/165] updated pydantic model --- pephubclient/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pephubclient/constants.py b/pephubclient/constants.py index e36e66af..d625f582 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -14,10 +14,10 @@ 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") From 12b578afa40c552b7248ab9296d890059ac35f9b Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 22 Jan 2024 13:28:43 -0500 Subject: [PATCH 078/165] fixed #33 --- pephubclient/helpers.py | 72 ++++++++++++++++++++++++ pephubclient/pephubclient.py | 106 +++++++++-------------------------- 2 files changed, 97 insertions(+), 81 deletions(-) diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 41f06e9d..07cc7114 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,5 +1,14 @@ import json from typing import Any, Callable, Optional +import os +import pandas as pd +from peppy.const import ( + NAME_KEY, + DESC_KEY, + CONFIG_KEY, + SUBSAMPLE_RAW_LIST_KEY, + SAMPLE_RAW_DICT_KEY, +) import requests from requests.exceptions import ConnectionError @@ -9,6 +18,7 @@ from pephubclient.exceptions import PEPExistsError, ResponseError from pephubclient.constants import RegistryPath +from pephubclient.files_manager import FilesManager class RequestManager: @@ -103,3 +113,65 @@ def is_registry_path(input_string: str) -> bool: except (ValidationError, TypeError): return False return True + + +def save_raw_pep( + reg_path: str, + project_dict: dict, + force: bool = False, +) -> None: + """ + Save project locally. + + :param dict project_dict: PEP dictionary (raw project) + :param bool force: overwrite project if exists + :return: None + """ + 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 diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 118bdd23..a12e21b7 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,16 +1,10 @@ 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, -) +from peppy.const import NAME_KEY import requests import urllib3 from pydantic import ValidationError @@ -25,11 +19,10 @@ ) 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_raw_pep from pephubclient.models import ( ProjectDict, ProjectUploadData, @@ -76,11 +69,11 @@ def pull(self, project_registry_path: str, force: Optional[bool] = False) -> Non :return: None """ jwt_data = FilesManager.load_jwt_data_from_file(self.PATH_TO_FILE_WITH_JWT) - project_dict = self._load_raw_pep( + project_dict = self.load_raw_pep( registry_path=project_registry_path, jwt_data=jwt_data ) - self._save_raw_pep( + save_raw_pep( reg_path=project_registry_path, project_dict=project_dict, force=force ) @@ -97,7 +90,7 @@ def load_project( :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, jwt, query_param) peppy_project = peppy.Project().from_dict(raw_pep) return peppy_project @@ -247,80 +240,32 @@ def find_project( project_list.append(ProjectAnnotationModel(**project_found)) return SearchReturnModel(**json.loads(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 + :param jwt_data: JWT token. + :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, jwt_data, 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" @@ -396,9 +341,8 @@ def _build_pull_request_url(self, query_param: dict = None) -> str: 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 From de713165504579db2fd45d34cc284500daabd318 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 23 Jan 2024 15:23:26 -0500 Subject: [PATCH 079/165] fixed #32 fixed #34 --- pephubclient/__init__.py | 10 ++- pephubclient/cli.py | 4 ++ pephubclient/files_manager.py | 47 ++++++------- pephubclient/helpers.py | 121 ++++++++++++++++++++++++++++++---- pephubclient/pephubclient.py | 96 +++++---------------------- tests/test_pephubclient.py | 35 ++++++++++ 6 files changed, 195 insertions(+), 118 deletions(-) diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 8e2e1a5c..c94b9ef2 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,8 +1,16 @@ from pephubclient.pephubclient import PEPHubClient +from pephubclient.helpers import is_registry_path, save_pep __app_name__ = "pephubclient" __version__ = "0.2.2" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" -__all__ = ["PEPHubClient", __app_name__, __author__, __version__] +__all__ = [ + "PEPHubClient", + __app_name__, + __author__, + __version__, + "is_registry_path", + "save_pep", +] diff --git a/pephubclient/cli.py b/pephubclient/cli.py index 75530620..7be8cfa7 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/files_manager.py b/pephubclient/files_manager.py index a9341862..84bebc59 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -4,6 +4,7 @@ import pandas import yaml +import zipfile from pephubclient.constants import RegistryPath from pephubclient.exceptions import PEPExistsError @@ -30,25 +31,21 @@ def load_jwt_data_from_file(path: str) -> str: @staticmethod def create_project_folder( - registry_path: RegistryPath, parent_path: str, just_name: bool = False + 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 just_name: if True, create folder with just name, not full path + :param folder_name: folder name :return: folder_path """ - if just_name: - folder_name = registry_path.item - else: - folder_name = FilesManager._create_filename_to_save_downloaded_project( - registry_path - ) if parent_path: if not Path(parent_path).exists(): - raise OSError(f"Parent path does not exist. Provided path: {parent_path}") + 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 @@ -73,20 +70,24 @@ 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 - @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 07cc7114..027c38ff 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -1,5 +1,7 @@ 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 ( @@ -8,6 +10,8 @@ CONFIG_KEY, SUBSAMPLE_RAW_LIST_KEY, SAMPLE_RAW_DICT_KEY, + CFG_SAMPLE_TABLE_KEY, + CFG_SUBSAMPLE_TABLE_KEY, ) import requests @@ -19,6 +23,7 @@ from pephubclient.exceptions import PEPExistsError, ResponseError from pephubclient.constants import RegistryPath from pephubclient.files_manager import FilesManager +from pephubclient.models import ProjectDict class RequestManager: @@ -115,20 +120,69 @@ def is_registry_path(input_string: str) -> bool: return True -def save_raw_pep( - reg_path: str, - project_dict: dict, - force: bool = False, +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 project locally. + Save unzipped project to specified folder - :param dict project_dict: PEP dictionary (raw project) - :param bool force: overwrite project if exists + :param project_dict: raw pep project + :param folder_path: path to save project + :param force: overwrite project if exists :return: None """ - 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) @@ -171,7 +225,48 @@ def full_path(fn: str) -> str: not_force=False, ) - MessageHandler.print_success( - f"Project was downloaded successfully -> {folder_path}" - ) + 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" + ) + 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/pephubclient.py b/pephubclient/pephubclient.py index d9c021bc..500d4662 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -22,7 +22,7 @@ ResponseError, ) from pephubclient.files_manager import FilesManager -from pephubclient.helpers import MessageHandler, RequestManager, save_raw_pep +from pephubclient.helpers import MessageHandler, RequestManager, save_pep from pephubclient.models import ( ProjectDict, ProjectUploadData, @@ -60,12 +60,20 @@ def logout(self) -> NoReturn: """ FilesManager.delete_file_if_exists(self.PATH_TO_FILE_WITH_JWT) - 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) @@ -73,12 +81,12 @@ def pull(self, project_registry_path: str, force: Optional[bool] = False) -> Non registry_path=project_registry_path, jwt_data=jwt_data ) - self._save_raw_pep( + save_pep( + project=project_dict, reg_path=project_registry_path, - project_dict=project_dict, - force=force - save_raw_pep( - reg_path=project_registry_path, project_dict=project_dict, force=force + force=force, + project_path=output, + zip=zip, ) def load_project( @@ -244,80 +252,6 @@ def find_project( project_list.append(ProjectAnnotationModel(**project_found)) return SearchReturnModel(**json.loads(decoded_response)) - @staticmethod - def _save_raw_pep( - reg_path: str, - project_dict: dict, - force: bool = False, - project_path: Optional[str] = None, - just_name: bool = False, - ) -> None: - """ - Save project locally. - - :param str reg_path: Project registry path in PEPhub (e.g. databio/base:default) - :param dict project_dict: PEP dictionary (raw project) - :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 just_name: If True, create project folder with just name, not full path - :return: None - """ - reg_path_model = RegistryPath(**parse_registry_path(reg_path)) - folder_path = FilesManager.create_project_folder( - registry_path=reg_path_model, parent_path=project_path, just_name=just_name - ) - - 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 - @deprecated("This method is deprecated. Use load_raw_pep instead.") def _load_raw_pep( self, diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index c35c8ac2..b179b944 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -201,3 +201,38 @@ class TestHelpers: ) def test_is_registry_path(self, input_str, expected_output): assert is_registry_path(input_str) is expected_output + + @pytest.mark.skipif(True, reason="not implemented yet") + def test_save_zip_pep(self): + ... + + @pytest.mark.skipif(True, reason="not implemented yet") + def test_save_unzip_pep(self): + ... + + +@pytest.mark.skipif(True, reason="not implemented yet") +class TestSamplesModification: + def test_get_sumple(self): + ... + + def test_add_sample(self): + ... + + def test_remove_sample(self): + ... + + def test_update_sample(self): + ... + + +@pytest.mark.skipif(True, reason="not implemented yet") +class TestProjectVeiw: + def test_get_view(self): + ... + + def test_create_view(self): + ... + + def test_delete_view(self): + ... From e93238628c350299b254f4760f509cb77790dd95 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 23 Jan 2024 15:24:16 -0500 Subject: [PATCH 080/165] updated version --- pephubclient/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index c94b9ef2..39deb457 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -2,7 +2,7 @@ from pephubclient.helpers import is_registry_path, save_pep __app_name__ = "pephubclient" -__version__ = "0.2.2" +__version__ = "0.4.0" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" From bdbb7ab4a352c1d792fd3d93275947fa92c5f199 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 23 Jan 2024 16:10:24 -0500 Subject: [PATCH 081/165] Fixed saving force bug --- pephubclient/helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 027c38ff..7d582a1f 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -262,7 +262,9 @@ def save_pep( if zip: _save_zip_pep( - project, zip_filepath=f"{os.path.join(project_path, file_name)}.zip" + project, + zip_filepath=f"{os.path.join(project_path, file_name)}.zip", + force=force, ) return None From b0888a454386ac996cb3332ca220f008ecc53299 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 23 Jan 2024 16:35:03 -0500 Subject: [PATCH 082/165] Added the skeleton for Views and Samples functions --- pephubclient/files_manager.py | 4 +++- pephubclient/pephubclient.py | 12 ++++++++++ pephubclient/samples/__init__.py | 3 +++ pephubclient/samples/samples.py | 38 ++++++++++++++++++++++++++++++++ pephubclient/views/__init__.py | 3 +++ pephubclient/views/views.py | 34 ++++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 pephubclient/samples/__init__.py create mode 100644 pephubclient/samples/samples.py create mode 100644 pephubclient/views/__init__.py create mode 100644 pephubclient/views/views.py diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index a9341862..d3a96935 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -48,7 +48,9 @@ def create_project_folder( ) if parent_path: if not Path(parent_path).exists(): - raise OSError(f"Parent path does not exist. Provided path: {parent_path}") + 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 diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 0e8cb342..eaf812a8 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -37,6 +37,8 @@ ProjectAnnotationModel, ) from pephubclient.pephub_oauth.pephub_oauth import PEPHubAuth +from pephubclient.samples import Samples +from pephubclient.views import Views urllib3.disable_warnings() @@ -52,6 +54,16 @@ class PEPHubClient(RequestManager): def __init__(self): self.registry_path = None + self.__view = Views() + self.__sample = Samples() + + @property + def view(self): + return self.__view + + @property + def sample(self): + return self.__sample def login(self) -> NoReturn: """ diff --git a/pephubclient/samples/__init__.py b/pephubclient/samples/__init__.py new file mode 100644 index 00000000..67f2f90b --- /dev/null +++ b/pephubclient/samples/__init__.py @@ -0,0 +1,3 @@ +from samples import Samples + +__all__ = ["Samples"] diff --git a/pephubclient/samples/samples.py b/pephubclient/samples/samples.py new file mode 100644 index 00000000..4e071810 --- /dev/null +++ b/pephubclient/samples/samples.py @@ -0,0 +1,38 @@ +from ..files_manager import FilesManager + + +class Samples: + def __init__(self): + self.jwt_data = "" + + def get( + self, + namespace: str, + name: str, + tag: str, + sample_name: str = None, + ): + ... + + def create( + self, + namespace: str, + name: str, + tag: str, + sample_name: str, + sample_dict: dict, + ): + ... + + def update( + self, + namespace: str, + name: str, + tag: str, + sample_name: str, + sample_dict: dict, + ): + ... + + def remove(self, namespace: str, name: str, tag: str, sample_name: str): + ... diff --git a/pephubclient/views/__init__.py b/pephubclient/views/__init__.py new file mode 100644 index 00000000..4c3c1b4c --- /dev/null +++ b/pephubclient/views/__init__.py @@ -0,0 +1,3 @@ +from views import Views + +__all__ = ["Views"] diff --git a/pephubclient/views/views.py b/pephubclient/views/views.py new file mode 100644 index 00000000..04213e30 --- /dev/null +++ b/pephubclient/views/views.py @@ -0,0 +1,34 @@ +class Views: + def __init__(self, jwt_data: str): + self._jwt_data = jwt_data + + def get(self, namespace: str, name: str, tag: str, view_name: str): + ... + + def create( + self, namespace: str, name: str, tag: str, view_name: str, view_dict: dict + ): + ... + + def delete(self, namespace: str, name: str, tag: str, view_name: str): + ... + + def add_sample( + self, + namespace: str, + name: str, + tag: str, + view_name: str, + sample_name: str, + ): + ... + + def remove_sample( + self, + namespace: str, + name: str, + tag: str, + view_name: str, + sample_name: str, + ): + ... From 72af16a8923d31e02ef8722929d7c207b36b2410 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 23 Jan 2024 17:05:42 -0500 Subject: [PATCH 083/165] updated docstring --- pephubclient/pephubclient.py | 4 ++-- pephubclient/samples/samples.py | 6 ++++++ pephubclient/views/views.py | 9 ++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index eaf812a8..2352fc3f 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -58,11 +58,11 @@ def __init__(self): self.__sample = Samples() @property - def view(self): + def view(self) -> Views: return self.__view @property - def sample(self): + def sample(self) -> Samples: return self.__sample def login(self) -> NoReturn: diff --git a/pephubclient/samples/samples.py b/pephubclient/samples/samples.py index 4e071810..09d86e0c 100644 --- a/pephubclient/samples/samples.py +++ b/pephubclient/samples/samples.py @@ -2,6 +2,12 @@ class Samples: + """ + 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): self.jwt_data = "" diff --git a/pephubclient/views/views.py b/pephubclient/views/views.py index 04213e30..dd41597b 100644 --- a/pephubclient/views/views.py +++ b/pephubclient/views/views.py @@ -1,5 +1,12 @@ class Views: - def __init__(self, jwt_data: str): + """ + 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): self._jwt_data = jwt_data def get(self, namespace: str, name: str, tag: str, view_name: str): From 1bbd3276c64db0b45a3692a6281f086511b1e8bc Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Fri, 26 Jan 2024 14:43:07 -0500 Subject: [PATCH 084/165] updated delete function --- pephubclient/files_manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 84bebc59..6331ed85 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -69,6 +69,9 @@ def file_exists(full_path: str) -> bool: def delete_file_if_exists(filename: str) -> None: with suppress(FileNotFoundError): os.remove(filename) + print( + f"\033[38;5;11m{f'File was deleted successfully -> {filename}'}\033[0m" + ) @staticmethod def check_writable(path: str, force: bool = True): From 8a7f8a7f6d79130f7ff712dfb41e4548652bc26a Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Sat, 27 Jan 2024 20:12:58 -0500 Subject: [PATCH 085/165] updated changelog --- docs/changelog.md | 8 ++++++-- pephubclient/__init__.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index d6c64748..0a0194b4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,11 +2,15 @@ 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-XX-XX +## [0.3.0] - 2024-XX-XX ### Added - Added param parent dir where peps should be saved +- Added zip option to save_pep function -## [0.3.0] - 2024-01-17 +### Changed +- Transferred save_pep function to helpers + +## [0.2.2] - 2024-01-17 ### Added - customization of the base pephub URL. [#22](https://github.com/pepkit/pephubclient/issues/22) diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 39deb457..45fd561c 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -2,7 +2,7 @@ from pephubclient.helpers import is_registry_path, save_pep __app_name__ = "pephubclient" -__version__ = "0.4.0" +__version__ = "0.3.0" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" From 4229494210506688fbf1d4aea8c3c0c9578cd7da Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 30 Jan 2024 23:14:43 +0100 Subject: [PATCH 086/165] added samples functionality --- MANIFEST.in | 4 +- pephubclient/constants.py | 9 + pephubclient/helpers.py | 28 ++++ pephubclient/modules/__init__.py | 0 pephubclient/modules/sample.py | 155 ++++++++++++++++++ .../{views/views.py => modules/view.py} | 24 +-- pephubclient/pephubclient.py | 85 +++------- pephubclient/samples/__init__.py | 3 - pephubclient/samples/samples.py | 44 ----- pephubclient/views/__init__.py | 3 - tests/test_pephubclient.py | 111 +++++++++---- 11 files changed, 310 insertions(+), 156 deletions(-) create mode 100644 pephubclient/modules/__init__.py create mode 100644 pephubclient/modules/sample.py rename pephubclient/{views/views.py => modules/view.py} (80%) delete mode 100644 pephubclient/samples/__init__.py delete mode 100644 pephubclient/samples/samples.py delete mode 100644 pephubclient/views/__init__.py diff --git a/MANIFEST.in b/MANIFEST.in index 4797e8e0..185b75ee 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ include requirements/* include README.md -include pephubclient/pephub_oauth/* \ No newline at end of file +include pephubclient/pephub_oauth/* +include pephubclient/samples/* +include pephubclient/views/* \ No newline at end of file diff --git a/pephubclient/constants.py b/pephubclient/constants.py index d625f582..f2ae0325 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -12,6 +12,8 @@ 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}}" + class RegistryPath(BaseModel): protocol: Optional[str] = None @@ -33,3 +35,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/helpers.py b/pephubclient/helpers.py index 7d582a1f..7d376c1b 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -61,6 +61,34 @@ def decode_response(response: requests.Response, encoding: str = "utf-8") -> str 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. + """ + 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 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: """ diff --git a/pephubclient/modules/__init__.py b/pephubclient/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pephubclient/modules/sample.py b/pephubclient/modules/sample.py new file mode 100644 index 00000000..4e2ae5e3 --- /dev/null +++ b/pephubclient/modules/sample.py @@ -0,0 +1,155 @@ +from pephubclient.helpers import RequestManager +from pephubclient.constants import PEPHUB_SAMPLE_URL +import json + + +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) + ) + output = dict(json.loads(self.decode_response(response))) + return output + + 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} + ) + + response = self.send_request( + method="POST", + url=url, + headers=self.parse_header(self.__jwt_data), + json=sample_dict, + ) + output = self.decode_response(response) + return output + + 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, + ) + output = self.decode_response(response) + return output + + 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), + ) + output = self.decode_response(response) + return output + + @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/views/views.py b/pephubclient/modules/view.py similarity index 80% rename from pephubclient/views/views.py rename to pephubclient/modules/view.py index dd41597b..19d2ed93 100644 --- a/pephubclient/views/views.py +++ b/pephubclient/modules/view.py @@ -1,4 +1,4 @@ -class Views: +class PEPHubView: """ Class for managing views in PEPhub and provides methods for getting, creating, updating and removing views. @@ -6,19 +6,21 @@ class Views: This class aims to warp the Views API for easier maintenance and better user experience. """ + def __init__(self, jwt_data: str = None): - self._jwt_data = jwt_data + """ + :param jwt_data: jwt token for authorization + """ + + self.__jwt_data = jwt_data - def get(self, namespace: str, name: str, tag: str, view_name: str): - ... + def get(self, namespace: str, name: str, tag: str, view_name: str): ... def create( self, namespace: str, name: str, tag: str, view_name: str, view_dict: dict - ): - ... + ): ... - def delete(self, namespace: str, name: str, tag: str, view_name: str): - ... + def delete(self, namespace: str, name: str, tag: str, view_name: str): ... def add_sample( self, @@ -27,8 +29,7 @@ def add_sample( tag: str, view_name: str, sample_name: str, - ): - ... + ): ... def remove_sample( self, @@ -37,5 +38,4 @@ def remove_sample( tag: str, view_name: str, sample_name: str, - ): - ... + ): ... diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 2768a54d..1cd62b56 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,5 +1,4 @@ import json -import os from typing import NoReturn, Optional, Literal from typing_extensions import deprecated @@ -16,6 +15,7 @@ RegistryPath, ResponseStatusCodes, PEPHUB_PEP_SEARCH_URL, + PATH_TO_FILE_WITH_JWT, ) from pephubclient.exceptions import ( IncorrectQueryStringError, @@ -30,32 +30,25 @@ ProjectAnnotationModel, ) from pephubclient.pephub_oauth.pephub_oauth import PEPHubAuth -from pephubclient.samples import Samples -from pephubclient.views import Views +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.__view = Views() - self.__sample = Samples() + 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) -> Views: + def view(self) -> PEPHubView: return self.__view @property - def sample(self) -> Samples: + def sample(self) -> PEPHubSample: return self.__sample def login(self) -> NoReturn: @@ -64,13 +57,15 @@ 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, @@ -88,9 +83,8 @@ def pull( :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 + registry_path=project_registry_path, ) save_pep( @@ -113,8 +107,8 @@ 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) + jwt = FilesManager.load_jwt_data_from_file(PATH_TO_FILE_WITH_JWT) + raw_pep = self.load_raw_pep(project_registry_path, query_param) peppy_project = peppy.Project().from_dict(raw_pep) return peppy_project @@ -170,7 +164,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 @@ -186,7 +179,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, ) @@ -232,7 +225,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, @@ -253,7 +245,7 @@ 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, ) @@ -278,15 +270,13 @@ def _load_raw_pep( :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. """ - return self.load_raw_pep(registry_path, jwt_data, query_param) + return self.load_raw_pep(registry_path, query_param) def load_raw_pep( self, registry_path: str, - jwt_data: Optional[str] = None, query_param: Optional[dict] = None, ) -> dict: """ @@ -294,11 +284,8 @@ def load_raw_pep( :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" @@ -306,7 +293,7 @@ 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: @@ -335,19 +322,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 @@ -360,7 +334,7 @@ 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 @@ -374,7 +348,7 @@ def _build_project_search_url(namespace: str, query_param: dict = None) -> str: :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 @@ -389,21 +363,6 @@ def _build_push_request_url(namespace: str) -> str: """ 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): """ diff --git a/pephubclient/samples/__init__.py b/pephubclient/samples/__init__.py deleted file mode 100644 index 67f2f90b..00000000 --- a/pephubclient/samples/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from samples import Samples - -__all__ = ["Samples"] diff --git a/pephubclient/samples/samples.py b/pephubclient/samples/samples.py deleted file mode 100644 index 09d86e0c..00000000 --- a/pephubclient/samples/samples.py +++ /dev/null @@ -1,44 +0,0 @@ -from ..files_manager import FilesManager - - -class Samples: - """ - 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): - self.jwt_data = "" - - def get( - self, - namespace: str, - name: str, - tag: str, - sample_name: str = None, - ): - ... - - def create( - self, - namespace: str, - name: str, - tag: str, - sample_name: str, - sample_dict: dict, - ): - ... - - def update( - self, - namespace: str, - name: str, - tag: str, - sample_name: str, - sample_dict: dict, - ): - ... - - def remove(self, namespace: str, name: str, tag: str, sample_name: str): - ... diff --git a/pephubclient/views/__init__.py b/pephubclient/views/__init__.py deleted file mode 100644 index 4c3c1b4c..00000000 --- a/pephubclient/views/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from views import Views - -__all__ = ["Views"] diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index b179b944..234e278d 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -202,37 +202,88 @@ class TestHelpers: def test_is_registry_path(self, input_str, expected_output): assert is_registry_path(input_str) is expected_output - @pytest.mark.skipif(True, reason="not implemented yet") - def test_save_zip_pep(self): - ... - - @pytest.mark.skipif(True, reason="not implemented yet") - def test_save_unzip_pep(self): - ... - - -@pytest.mark.skipif(True, reason="not implemented yet") -class TestSamplesModification: - def test_get_sumple(self): - ... - - def test_add_sample(self): - ... - - def test_remove_sample(self): - ... - - def test_update_sample(self): - ... +# @pytest.mark.skipif(True, reason="not implemented yet") +# def test_save_zip_pep(self): +# ... +# +# @pytest.mark.skipif(True, reason="not implemented yet") +# def test_save_unzip_pep(self): +# ... +# +# +# @pytest.mark.skipif(True, reason="not implemented yet") +# class TestSamplesModification: +# def test_get_sumple(self): +# ... +# +# def test_add_sample(self): +# ... +# +# def test_remove_sample(self): +# ... +# +# def test_update_sample(self): +# ... +# +# +# @pytest.mark.skipif(True, reason="not implemented yet") +# class TestProjectVeiw: +# def test_get_view(self): +# ... +# +# def test_create_view(self): +# ... +# +# def test_delete_view(self): +# ... +# +# +class TestManual: + def test_manual(self): + ff = PEPHubClient().sample.get( + "khoroshevskyi", + "bedset1", + "default", + "newf", + ) -@pytest.mark.skipif(True, reason="not implemented yet") -class TestProjectVeiw: - def test_get_view(self): - ... + def test_update(self): + ff = PEPHubClient().sample.get( + "khoroshevskyi", + "bedset1", + "default", + "newf", + ) + ff.update({"fff": "test1"}) + ff["sample_type"] = "new_type" + PEPHubClient().sample.update( + "khoroshevskyi", + "bedset1", + "default", + "newf", + sample_dict=ff, + ) - def test_create_view(self): - ... + def test_add(self): + ff = { + "genome": "phc_test1", + "sample_type": "phc_test", + "sample_name": "test_phc", + } + PEPHubClient().sample.create( + "khoroshevskyi", + "bedset1", + "default", + "test_phc", + overwrite=True, + sample_dict=ff, + ) - def test_delete_view(self): - ... + def test_delete(self): + PEPHubClient().sample.remove( + "khoroshevskyi", + "bedset1", + "default", + "test_phc", + ) From a0ea82c1fcc0e7ffa29fe3e92d6e8ae1e75725db Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 1 Feb 2024 19:38:31 +0100 Subject: [PATCH 087/165] updated manifest --- MANIFEST.in | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 185b75ee..a948aa7d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,4 @@ include requirements/* include README.md include pephubclient/pephub_oauth/* -include pephubclient/samples/* -include pephubclient/views/* \ No newline at end of file +include pephubclient/modules/* \ No newline at end of file From dadffc675826136ea14c8e80043c5164f4bbd9bf Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 1 Feb 2024 22:30:27 +0100 Subject: [PATCH 088/165] work on samples and viewes --- pephubclient/constants.py | 6 ++ pephubclient/helpers.py | 10 +- pephubclient/modules/sample.py | 32 ++++-- pephubclient/modules/view.py | 191 +++++++++++++++++++++++++++++++-- 4 files changed, 220 insertions(+), 19 deletions(-) diff --git a/pephubclient/constants.py b/pephubclient/constants.py index f2ae0325..a1a505b2 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -13,6 +13,12 @@ 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): diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 7d376c1b..a9f764bb 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -47,17 +47,23 @@ 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}") diff --git a/pephubclient/modules/sample.py b/pephubclient/modules/sample.py index 4e2ae5e3..68ec9b5e 100644 --- a/pephubclient/modules/sample.py +++ b/pephubclient/modules/sample.py @@ -1,6 +1,6 @@ from pephubclient.helpers import RequestManager -from pephubclient.constants import PEPHUB_SAMPLE_URL -import json +from pephubclient.constants import PEPHUB_SAMPLE_URL, ResponseStatusCodes +from pephubclient.exceptions import ResponseError class PEPHubSample(RequestManager): @@ -42,8 +42,8 @@ def get( response = self.send_request( method="GET", url=url, headers=self.parse_header(self.__jwt_data) ) - output = dict(json.loads(self.decode_response(response))) - return output + if response.status_code == ResponseStatusCodes.OK: + return self.decode_response(response, output_json=True) def create( self, @@ -81,8 +81,12 @@ def create( headers=self.parse_header(self.__jwt_data), json=sample_dict, ) - output = self.decode_response(response) - return output + if response.status_code == ResponseStatusCodes.OK: + return None + else: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) def update( self, @@ -115,8 +119,12 @@ def update( headers=self.parse_header(self.__jwt_data), json=sample_dict, ) - output = self.decode_response(response) - return output + if response.status_code == ResponseStatusCodes.OK: + return None + else: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) def remove(self, namespace: str, name: str, tag: str, sample_name: str): """ @@ -139,8 +147,12 @@ def remove(self, namespace: str, name: str, tag: str, sample_name: str): url=url, headers=self.parse_header(self.__jwt_data), ) - output = self.decode_response(response) - return output + if response.status_code == ResponseStatusCodes.OK: + return None + 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: diff --git a/pephubclient/modules/view.py b/pephubclient/modules/view.py index 19d2ed93..63e1f2ad 100644 --- a/pephubclient/modules/view.py +++ b/pephubclient/modules/view.py @@ -1,4 +1,12 @@ -class PEPHubView: +from typing import Union +import peppy + +from pephubclient.helpers import RequestManager +from pephubclient.constants import PEPHUB_VIEW_URL, PEPHUB_VIEW_SAMPLE_URL, ResponseStatusCodes +from pephubclient.exceptions import ResponseError + + +class PEPHubView(RequestManager): """ Class for managing views in PEPhub and provides methods for getting, creating, updating and removing views. @@ -14,13 +22,102 @@ def __init__(self, jwt_data: str = None): self.__jwt_data = jwt_data - def get(self, namespace: str, name: str, tag: str, view_name: str): ... + 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 + return peppy.Project.from_dict(output) def create( - self, namespace: str, name: str, tag: str, view_name: str, view_dict: dict - ): ... + 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}) - def delete(self, namespace: str, name: str, tag: str, view_name: str): ... + response = self.send_request( + method="POST", + url=url, + headers=self.parse_header(self.__jwt_data), + json=sample_list, + ) + if response.status_code != ResponseStatusCodes.ACCEPTED: + raise ResponseError( + f"Unexpected return value. Error: {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: + pass + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("File does not exist, or you are unauthorized.") + elif response.status_code == ResponseStatusCodes.INTERNAL_ERROR: + raise ResponseError( + f"Internal server error. Unexpected return value. Error: {response.status_code}" + ) + else: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) + + return None def add_sample( self, @@ -29,7 +126,34 @@ def add_sample( 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: + raise ResponseError( + f"Unexpected return value. Error: {response.status_code}" + ) def remove_sample( self, @@ -38,4 +162,57 @@ def remove_sample( 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: + 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, + ) From ff6735bdaf158945359d29e0fbc7ff10c71f8638 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 5 Feb 2024 21:31:23 +0100 Subject: [PATCH 089/165] updated code response --- pephubclient/constants.py | 12 +++-- pephubclient/modules/sample.py | 33 ++++++++++++-- pephubclient/modules/view.py | 60 ++++++++++++++++++------- tests/test_pephubclient.py | 80 +++++++++++++++++++++++++++++++--- 4 files changed, 153 insertions(+), 32 deletions(-) diff --git a/pephubclient/constants.py b/pephubclient/constants.py index a1a505b2..27f22cbc 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -4,10 +4,10 @@ from pydantic import BaseModel, field_validator -PEPHUB_BASE_URL = os.getenv( - "PEPHUB_BASE_URL", default="https://pephub-api.databio.org/" -) -# PEPHUB_BASE_URL = "http://0.0.0.0:8000/" +# PEPHUB_BASE_URL = os.getenv( +# "PEPHUB_BASE_URL", default="https://pephub-api.databio.org/" +# ) +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" @@ -16,9 +16,7 @@ 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}}" -) +PEPHUB_VIEW_SAMPLE_URL = f"{PEPHUB_BASE_URL}api/v1/projects/{{namespace}}/{{project}}/views/{{view_name}}/{{sample_name}}" class RegistryPath(BaseModel): diff --git a/pephubclient/modules/sample.py b/pephubclient/modules/sample.py index 68ec9b5e..881ed6e6 100644 --- a/pephubclient/modules/sample.py +++ b/pephubclient/modules/sample.py @@ -42,7 +42,11 @@ def get( response = self.send_request( method="GET", url=url, headers=self.parse_header(self.__jwt_data) ) - if response.status_code == ResponseStatusCodes.OK: + if response.status_code != ResponseStatusCodes.OK: + raise ResponseError( + f"Sample does not exist, or Internal server error occurred." + ) + else: return self.decode_response(response, output_json=True) def create( @@ -75,14 +79,27 @@ def create( 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.OK: + if response.status_code == ResponseStatusCodes.ACCEPTED: return None + + elif response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError( + f"Project '{namespace}/{name}:{tag}' does not exist. Error: {response.status_code}" + ) + 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}" @@ -119,8 +136,12 @@ def update( headers=self.parse_header(self.__jwt_data), json=sample_dict, ) - if response.status_code == ResponseStatusCodes.OK: + if response.status_code == ResponseStatusCodes.ACCEPTED: 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}" @@ -147,8 +168,12 @@ def remove(self, namespace: str, name: str, tag: str, sample_name: str): url=url, headers=self.parse_header(self.__jwt_data), ) - if response.status_code == ResponseStatusCodes.OK: + if response.status_code == ResponseStatusCodes.ACCEPTED: 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}" diff --git a/pephubclient/modules/view.py b/pephubclient/modules/view.py index 63e1f2ad..ef14d55f 100644 --- a/pephubclient/modules/view.py +++ b/pephubclient/modules/view.py @@ -2,7 +2,11 @@ import peppy from pephubclient.helpers import RequestManager -from pephubclient.constants import PEPHUB_VIEW_URL, PEPHUB_VIEW_SAMPLE_URL, ResponseStatusCodes +from pephubclient.constants import ( + PEPHUB_VIEW_URL, + PEPHUB_VIEW_SAMPLE_URL, + ResponseStatusCodes, +) from pephubclient.exceptions import ResponseError @@ -49,6 +53,12 @@ def get( if raw: return output 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, @@ -79,10 +89,16 @@ def create( headers=self.parse_header(self.__jwt_data), json=sample_list, ) - if response.status_code != ResponseStatusCodes.ACCEPTED: + if response.status_code == ResponseStatusCodes.ACCEPTED: + return None + elif response.status_code == ResponseStatusCodes.NOT_EXIST: raise ResponseError( - f"Unexpected return value. Error: {response.status_code}" + 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: """ @@ -105,19 +121,13 @@ def delete(self, namespace: str, name: str, tag: str, view_name: str) -> None: ) if response.status_code == ResponseStatusCodes.ACCEPTED: - pass + return None elif response.status_code == ResponseStatusCodes.NOT_EXIST: - raise ResponseError("File does not exist, or you are unauthorized.") - elif response.status_code == ResponseStatusCodes.INTERNAL_ERROR: - raise ResponseError( - f"Internal server error. Unexpected return value. Error: {response.status_code}" - ) + 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( - f"Unexpected return value. Error: {response.status_code}" - ) - - return None + raise ResponseError("Unexpected return value. ") def add_sample( self, @@ -150,7 +160,15 @@ def add_sample( url=url, headers=self.parse_header(self.__jwt_data), ) - if response.status_code != ResponseStatusCodes.ACCEPTED: + if response.status_code == ResponseStatusCodes.ACCEPTED: + 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}" ) @@ -187,7 +205,17 @@ def remove_sample( url=url, headers=self.parse_header(self.__jwt_data), ) - if response.status_code != ResponseStatusCodes.ACCEPTED: + if response.status_code == ResponseStatusCodes.ACCEPTED: + 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( + f"You are unauthorized to remove this sample from the view." + ) + else: raise ResponseError( f"Unexpected return value. Error: {response.status_code}" ) diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 234e278d..f6caa538 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -245,8 +245,9 @@ def test_manual(self): "khoroshevskyi", "bedset1", "default", - "newf", + "grape1", ) + ff def test_update(self): ff = PEPHubClient().sample.get( @@ -269,14 +270,13 @@ def test_add(self): ff = { "genome": "phc_test1", "sample_type": "phc_test", - "sample_name": "test_phc", } PEPHubClient().sample.create( "khoroshevskyi", "bedset1", "default", - "test_phc", - overwrite=True, + "new_f", + overwrite=False, sample_dict=ff, ) @@ -285,5 +285,75 @@ def test_delete(self): "khoroshevskyi", "bedset1", "default", - "test_phc", + "new_f", + ) + + # 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 + + +class TestViews: + + def test_get(self): + ff = PEPHubClient().view.get( + "khoroshevskyi", + "bedset1", + "default", + "test_view", + ) + print(ff) + + def test_create(self): + PEPHubClient().view.create( + "khoroshevskyi", + "bedset1", + "default", + "test_view", + sample_list=["orange", "grape1", "apple1"], + ) + + def test_delete(self): + PEPHubClient().view.delete( + "khoroshevskyi", + "bedset1", + "default", + "test_view", + ) + + def test_add_sample(self): + PEPHubClient().view.add_sample( + "khoroshevskyi", + "bedset1", + "default", + "test_view", + "apple", + ) + + def test_delete_sample(self): + PEPHubClient().view.remove_sample( + "khoroshevskyi", + "bedset1", + "default", + "test_view", + "apple", ) From c9fe4617c6315d71fc06e2f71dd4186463b81ab1 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 12 Feb 2024 20:14:53 +0100 Subject: [PATCH 090/165] Added test and updated request methods --- .github/workflows/pytest-windows.yml | 2 +- .github/workflows/pytest.yml | 2 +- Makefile | 2 +- pephubclient/constants.py | 8 +- pephubclient/files_manager.py | 1 - pephubclient/models.py | 5 +- pephubclient/modules/sample.py | 16 +- pephubclient/modules/view.py | 4 +- pephubclient/pephubclient.py | 25 +- requirements/requirements-dev.txt | 0 setup.py | 1 + tests/conftest.py | 4 +- tests/test_manual.py | 101 ++++++ tests/test_pephubclient.py | 489 ++++++++++++++++++++------- 14 files changed, 502 insertions(+), 158 deletions(-) delete mode 100644 requirements/requirements-dev.txt create mode 100644 tests/test_manual.py diff --git a/.github/workflows/pytest-windows.yml b/.github/workflows/pytest-windows.yml index 6884749b..34a557ed 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 3a32dfc5..334600e1 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/Makefile b/Makefile index 5033ebb1..40d1dfea 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/pephubclient/constants.py b/pephubclient/constants.py index 27f22cbc..26e8ed70 100644 --- a/pephubclient/constants.py +++ b/pephubclient/constants.py @@ -4,10 +4,10 @@ from pydantic import BaseModel, field_validator -# PEPHUB_BASE_URL = os.getenv( -# "PEPHUB_BASE_URL", default="https://pephub-api.databio.org/" -# ) -PEPHUB_BASE_URL = "http://0.0.0.0:8000/" +PEPHUB_BASE_URL = os.getenv( + "PEPHUB_BASE_URL", default="https://pephub-api.databio.org/" +) +# 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" diff --git a/pephubclient/files_manager.py b/pephubclient/files_manager.py index 6331ed85..a3d9b56a 100644 --- a/pephubclient/files_manager.py +++ b/pephubclient/files_manager.py @@ -6,7 +6,6 @@ import yaml import zipfile -from pephubclient.constants import RegistryPath from pephubclient.exceptions import PEPExistsError diff --git a/pephubclient/models.py b/pephubclient/models.py index b4a01729..2df76811 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/pephubclient/modules/sample.py b/pephubclient/modules/sample.py index 881ed6e6..d66a8f11 100644 --- a/pephubclient/modules/sample.py +++ b/pephubclient/modules/sample.py @@ -42,12 +42,16 @@ def get( response = self.send_request( method="GET", url=url, headers=self.parse_header(self.__jwt_data) ) - if response.status_code != ResponseStatusCodes.OK: + if response.status_code == ResponseStatusCodes.OK: + return self.decode_response(response, output_json=True) + if response.status_code == ResponseStatusCodes.NOT_EXIST: + raise ResponseError("Sample does not exist.") + elif response.status_code == ResponseStatusCodes.INTERNAL_ERROR: + raise ResponseError("Internal server error. Unexpected return value.") + else: raise ResponseError( - f"Sample does not exist, or Internal server error occurred." + f"Unexpected return value. Error: {response.status_code}" ) - else: - return self.decode_response(response, output_json=True) def create( self, @@ -93,9 +97,7 @@ def create( return None elif response.status_code == ResponseStatusCodes.NOT_EXIST: - raise ResponseError( - f"Project '{namespace}/{name}:{tag}' does not exist. Error: {response.status_code}" - ) + 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." diff --git a/pephubclient/modules/view.py b/pephubclient/modules/view.py index ef14d55f..5633e289 100644 --- a/pephubclient/modules/view.py +++ b/pephubclient/modules/view.py @@ -8,6 +8,7 @@ ResponseStatusCodes, ) from pephubclient.exceptions import ResponseError +from pephubclient.models import ProjectDict class PEPHubView(RequestManager): @@ -52,6 +53,7 @@ def get( 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.") @@ -213,7 +215,7 @@ def remove_sample( ) elif response.status_code == ResponseStatusCodes.UNAUTHORIZED: raise ResponseError( - f"You are unauthorized to remove this sample from the view." + "You are unauthorized to remove this sample from the view." ) else: raise ResponseError( diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 1cd62b56..6aa54ed0 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -1,10 +1,8 @@ -import json from typing import NoReturn, Optional, Literal from typing_extensions import deprecated import peppy from peppy.const import NAME_KEY -import requests import urllib3 from pydantic import ValidationError from ubiquerg import parse_registry_path @@ -107,7 +105,6 @@ def load_project( :param query_param: query parameters used in get request :return Project: peppy project. """ - jwt = FilesManager.load_jwt_data_from_file(PATH_TO_FILE_WITH_JWT) raw_pep = self.load_raw_pep(project_registry_path, query_param) peppy_project = peppy.Project().from_dict(raw_pep) return peppy_project @@ -250,11 +247,11 @@ def find_project( 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) @deprecated("This method is deprecated. Use load_raw_pep instead.") def _load_raw_pep( @@ -297,8 +294,8 @@ def load_raw_pep( 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) @@ -362,15 +359,3 @@ def _build_push_request_url(namespace: str) -> str: :return: url string """ return PEPHUB_PUSH_URL.format(namespace=namespace) - - @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-dev.txt b/requirements/requirements-dev.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/setup.py b/setup.py index aaf88938..99438601 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", diff --git a/tests/conftest.py b/tests/conftest.py index 48b44839..e0a54692 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 00000000..18482104 --- /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 f6caa538..6a9aec9c 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 @@ -203,157 +230,383 @@ def test_is_registry_path(self, input_str, expected_output): assert is_registry_path(input_str) is expected_output -# @pytest.mark.skipif(True, reason="not implemented yet") -# def test_save_zip_pep(self): -# ... -# -# @pytest.mark.skipif(True, reason="not implemented yet") -# def test_save_unzip_pep(self): -# ... -# -# -# @pytest.mark.skipif(True, reason="not implemented yet") -# class TestSamplesModification: -# def test_get_sumple(self): -# ... -# -# def test_add_sample(self): -# ... -# -# def test_remove_sample(self): -# ... -# -# def test_update_sample(self): -# ... -# -# -# @pytest.mark.skipif(True, reason="not implemented yet") -# class TestProjectVeiw: -# def test_get_view(self): -# ... -# -# def test_create_view(self): -# ... -# -# def test_delete_view(self): -# ... -# -# -class TestManual: - def test_manual(self): - ff = PEPHubClient().sample.get( - "khoroshevskyi", - "bedset1", - "default", - "grape1", - ) - ff +class TestSamples: - def test_update(self): - ff = PEPHubClient().sample.get( - "khoroshevskyi", - "bedset1", - "default", - "newf", + 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), ) - ff.update({"fff": "test1"}) - ff["sample_type"] = "new_type" - PEPHubClient().sample.update( - "khoroshevskyi", - "bedset1", + mocker.patch( + "pephubclient.helpers.RequestManager.decode_response", + return_value=return_value, + ) + return_value = PEPHubClient().sample.get( + "test_namespace", + "taest_name", "default", - "newf", - sample_dict=ff, + "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), ) - def test_add(self): - ff = { - "genome": "phc_test1", - "sample_type": "phc_test", - } PEPHubClient().sample.create( - "khoroshevskyi", - "bedset1", + "test_namespace", + "taest_name", "default", - "new_f", - overwrite=False, - sample_dict=ff, + "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), ) - def test_delete(self): PEPHubClient().sample.remove( - "khoroshevskyi", - "bedset1", + "test_namespace", + "taest_name", "default", - "new_f", + "gg1", ) + 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 + @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", + ) - # delete sample: - # 1. delete existing 202 - # 2. delete unexisting 404 + def test_update(self, mocker): + mocker_obj = mocker.patch( + "requests.request", + return_value=Mock(status_code=202), + ) - # get sample: - # 1. get existing 200 - # 2. get unexisting 404 - # 3. get with raw 200 - # 4. get from unexisting project 404 + 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 - # update sample: - # 1. update existing 202 - # 2. update unexisting sample 404 - # 3. update unexisting project 404 + @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, + ) - def test_get(self): - ff = PEPHubClient().view.get( - "khoroshevskyi", - "bedset1", + return_value = PEPHubClient().view.get( + "test_namespace", + "taest_name", "default", - "test_view", + "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), ) - print(ff) - def test_create(self): PEPHubClient().view.create( - "khoroshevskyi", - "bedset1", + "test_namespace", + "taest_name", "default", - "test_view", - sample_list=["orange", "grape1", "apple1"], + "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), ) - def test_delete(self): PEPHubClient().view.delete( - "khoroshevskyi", - "bedset1", + "test_namespace", + "taest_name", "default", - "test_view", + "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), ) - def test_add_sample(self): PEPHubClient().view.add_sample( - "khoroshevskyi", - "bedset1", + "test_namespace", + "taest_name", "default", - "test_view", - "apple", + "gg1", + "sample1", + ) + assert mocker_obj.called + + def test_delete_sample(self, mocker): + mocker_obj = mocker.patch( + "requests.request", + return_value=Mock(status_code=202), ) - def test_delete_sample(self): PEPHubClient().view.remove_sample( - "khoroshevskyi", - "bedset1", + "test_namespace", + "taest_name", "default", - "test_view", - "apple", + "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 From 0c2cef2a05e8c45a24431137767bf793156358b8 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 12 Feb 2024 20:33:49 +0100 Subject: [PATCH 091/165] Added logging --- pephubclient/__init__.py | 10 ++++++++++ pephubclient/modules/sample.py | 14 +++++++++++++- pephubclient/modules/view.py | 15 +++++++++++++++ requirements/requirements-all.txt | 5 +++-- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 45fd561c..32006201 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -1,5 +1,7 @@ from pephubclient.pephubclient import PEPHubClient from pephubclient.helpers import is_registry_path, save_pep +import logging +import coloredlogs __app_name__ = "pephubclient" __version__ = "0.3.0" @@ -14,3 +16,11 @@ "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/modules/sample.py b/pephubclient/modules/sample.py index d66a8f11..0194af9a 100644 --- a/pephubclient/modules/sample.py +++ b/pephubclient/modules/sample.py @@ -1,7 +1,11 @@ +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): """ @@ -94,8 +98,10 @@ def create( 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: @@ -139,6 +145,9 @@ def update( 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( @@ -171,6 +180,9 @@ def remove(self, namespace: str, name: str, tag: str, sample_name: str): 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( diff --git a/pephubclient/modules/view.py b/pephubclient/modules/view.py index 5633e289..f68d36ce 100644 --- a/pephubclient/modules/view.py +++ b/pephubclient/modules/view.py @@ -1,5 +1,6 @@ from typing import Union import peppy +import logging from pephubclient.helpers import RequestManager from pephubclient.constants import ( @@ -10,6 +11,8 @@ from pephubclient.exceptions import ResponseError from pephubclient.models import ProjectDict +_LOGGER = logging.getLogger("pephubclient") + class PEPHubView(RequestManager): """ @@ -92,6 +95,9 @@ def create( 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( @@ -123,6 +129,9 @@ def delete(self, namespace: str, name: str, tag: str, view_name: str) -> None: ) 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.") @@ -163,6 +172,9 @@ def add_sample( 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( @@ -208,6 +220,9 @@ def remove_sample( 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( diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index e0915898..3dd60438 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 From dc03117c8483adc1d94a56d9197600ecadaa3aa1 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 12 Feb 2024 20:37:34 +0100 Subject: [PATCH 092/165] fixed tests --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 99438601..cdf8165c 100644 --- a/setup.py +++ b/setup.py @@ -59,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 [] ), From bf21b788f7655d6a11aa4e344d0bda21bc4c1069 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 12 Feb 2024 22:11:59 +0100 Subject: [PATCH 093/165] fixed pr comments --- pephubclient/helpers.py | 7 ++----- pephubclient/modules/sample.py | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index a9f764bb..85979bf6 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -16,6 +16,7 @@ import requests from requests.exceptions import ConnectionError +from urllib.parse import urlencode from ubiquerg import parse_registry_path from pydantic import ValidationError @@ -76,11 +77,7 @@ def parse_query_param(pep_variables: dict) -> str: :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) + return "?" + urlencode(pep_variables) @staticmethod def parse_header(jwt_data: Optional[str] = None) -> dict: diff --git a/pephubclient/modules/sample.py b/pephubclient/modules/sample.py index 0194af9a..c8208d10 100644 --- a/pephubclient/modules/sample.py +++ b/pephubclient/modules/sample.py @@ -49,7 +49,9 @@ def get( if response.status_code == ResponseStatusCodes.OK: return self.decode_response(response, output_json=True) if response.status_code == ResponseStatusCodes.NOT_EXIST: - raise ResponseError("Sample does 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: From c71a4af5c1c018d01ddcc07e2dd8dfa47ef0aacc Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 12 Feb 2024 22:54:15 +0100 Subject: [PATCH 094/165] updated version --- docs/changelog.md | 20 +++++++++++++++----- pephubclient/__init__.py | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 0a0194b4..c0209da7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,13 +2,23 @@ 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.3.0] - 2024-XX-XX +## [0.4.0] - 2024-02-12 ### Added -- Added param parent dir where peps should be saved -- Added zip option to save_pep function +- 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)) -### Changed -- Transferred save_pep function to helpers +### 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 diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 32006201..4cdf008c 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -4,7 +4,7 @@ import coloredlogs __app_name__ = "pephubclient" -__version__ = "0.3.0" +__version__ = "0.4.0" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" From 2b6d203ce1607981409d74ff4910a726bf7df605 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 7 Mar 2024 22:22:53 +0100 Subject: [PATCH 095/165] fixed #17 --- .github/workflows/cli-coverage.yml | 40 ++++++++++++++++++++++++++++++ .github/workflows/run-codecov.yml | 38 ---------------------------- README.md | 32 ++++++++++++++++++++---- codecov.yml | 21 ---------------- pephubclient/__init__.py | 2 +- pephubclient/helpers.py | 11 +++++++- requirements/requirements-test.txt | 3 ++- setup.py | 2 +- 8 files changed, 81 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/cli-coverage.yml delete mode 100644 .github/workflows/run-codecov.yml delete mode 100644 codecov.yml diff --git a/.github/workflows/cli-coverage.yml b/.github/workflows/cli-coverage.yml new file mode 100644 index 00000000..8221d4a1 --- /dev/null +++ b/.github/workflows/cli-coverage.yml @@ -0,0 +1,40 @@ +name: test coverage + +on: + push: + branches: [master, dev] + +jobs: + cli-coverage-report: + strategy: + matrix: + python-version: [ "3.11" ] + os: [ ubuntu-latest ] + r: [ release ] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-python@v1 + with: + python-version: '3.10' + + - name: Install test dependencies + run: if [ -f requirements/requirements-dev.txt ]; then pip install -r requirements/requirements-test.txt; fi + + - run: pip install . + + - name: Run tests + run: coverage run -m pytest + + - name: build coverage + run: coverage html -i + + - run: smokeshow upload htmlcov + env: + SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage} + SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 80 + SMOKESHOW_GITHUB_CONTEXT: coverage + SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }} \ No newline at end of file diff --git a/.github/workflows/run-codecov.yml b/.github/workflows/run-codecov.yml deleted file mode 100644 index 364eb682..00000000 --- a/.github/workflows/run-codecov.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Run codecov - -on: - push: - branches: [dev] - pull_request: - branches: [master] - -jobs: - pytest: - runs-on: ${{ matrix.os }} - strategy: - matrix: - python-version: [3.11] - os: [ubuntu-latest] - - steps: - - uses: actions/checkout@v2 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: Install test dependencies - run: if [ -f requirements/requirements-test.txt ]; then pip install -r requirements/requirements-test.txt; fi - - - name: Install package - run: python -m pip install . - - - name: Run pytest tests - run: pytest tests --cov=./ --cov-report=xml - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - file: ./coverage.xml - name: py-${{ matrix.python-version }}-${{ matrix.os }} \ No newline at end of file diff --git a/README.md b/README.md index 5e6f6261..d76f2efd 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,36 @@ -# `PEPHubClient` +

PEPHubClient

[![PEP compatible](https://pepkit.github.io/img/PEP-compatible-green.svg)](https://pepkit.github.io) ![Run pytests](https://github.com/pepkit/pephubclient/workflows/Run%20pytests/badge.svg) -[![codecov](https://codecov.io/gh/pepkit/pephubclient/branch/dev/graph/badge.svg)](https://codecov.io/gh/pepkit/pephubclient) -[![pypi-badge](https://img.shields.io/pypi/v/pephubclient)](https://pypi.org/project/pephubclient) +[![pypi-badge](https://img.shields.io/pypi/v/pephubclient?color=%2334D058)](https://pypi.org/project/pephubclient) +[![pypi-version](https://img.shields.io/pypi/pyversions/pephubclient.svg?color=%2334D058)](https://pypi.org/project/pephubclient) +[![Coverage](https://coverage-badge.samuelcolvin.workers.dev/pepkit/pephubclient.svg)](https://coverage-badge.samuelcolvin.workers.dev/redirect/pepkit/pephubclient) +[![Github badge](https://img.shields.io/badge/source-github-354a75?logo=github)](https://github.com/pepkit/pephubclient) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + `PEPHubClient` is a tool to provide Python API and CLI for [PEPhub](https://pephub.databio.org). -`pephubclient` features: + +--- + +**Documentation**: https://pep.databio.org + +**Source Code**: https://github.com/pepkit/pephubclient + +--- + +## Installation +To install `pepdbagent` use this command: +``` +pip install pephubclient +``` +or install the latest version from the GitHub repository: +``` +pip install git+https://github.com/pepkit/pephubclient.git +``` + +### `pephubclient` features: 1) `push` (upload) projects) 2) `pull` (download projects) @@ -17,7 +39,7 @@ The authorization process is based on pephub device authorization protocol. To upload projects or to download private projects, user must be authorized through pephub. If you want to use your own pephub instance, you can specify it by setting `PEPHUB_BASE_URL` environment variable. -e.g. `export PEPHUB_BASE_URL=https://pephub.databio.org` (This is original pephub instance) +e.g. `export PEPHUB_BASE_URL=https://pephub.databio.org/` (This is original pephub instance) To login, use the `login` argument; to logout, use `logout`. diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index f0e5c72f..00000000 --- a/codecov.yml +++ /dev/null @@ -1,21 +0,0 @@ -ignore: - - "*/cli.py" - - "*/__main__.py" - - "*/__init__.py" - - "setup.py" - -coverage: - status: - project: - default: false # disable the default status that measures entire project - tests: - paths: - - "tests/" - target: 70% - source: - paths: - - "jupytext/" - target: 70% - patch: - default: - target: 70% \ No newline at end of file diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 4cdf008c..0c454622 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -4,7 +4,7 @@ import coloredlogs __app_name__ = "pephubclient" -__version__ = "0.4.0" +__version__ = "0.4.1" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index 85979bf6..e2f53f49 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -37,7 +37,7 @@ def send_request( params: Optional[dict] = None, json: Optional[dict] = None, ) -> requests.Response: - return requests.request( + request_return = requests.request( method=method, url=url, verify=False, @@ -46,6 +46,15 @@ def send_request( params=params, json=json, ) + if request_return.status_code == 401: + if ( + RequestManager.decode_response(request_return, output_json=True).get( + "detail" + ) + == "JWT has expired" + ): + raise ResponseError("JWT has expired. Please log in again.") + return request_return @staticmethod def decode_response( diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index 241ddcdf..d5878df3 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -6,4 +6,5 @@ pytest-mock flake8 coveralls pytest-cov -pre-commit \ No newline at end of file +pre-commit +coverage \ No newline at end of file diff --git a/setup.py b/setup.py index cdf8165c..0b83253f 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ def read_reqs(reqs_name): keywords="project, bioinformatics, metadata", url=f"https://github.com/databio/{PACKAGE}/", author=AUTHOR, + author_email="khorosh@virginia.edu", license="BSD2", entry_points={ "console_scripts": [ @@ -56,7 +57,6 @@ def read_reqs(reqs_name): ], }, package_data={PACKAGE: ["templates/*"]}, - scripts=None, include_package_data=True, test_suite="tests", tests_require=read_reqs("test"), From 697bd9a36a28348b5250a2fda842b081a1d04b10 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 7 Mar 2024 22:26:06 +0100 Subject: [PATCH 096/165] updated requirements --- README.md | 2 ++ requirements/requirements-test.txt | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d76f2efd..42bf9e82 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ or install the latest version from the GitHub repository: pip install git+https://github.com/pepkit/pephubclient.git ``` +--- + ### `pephubclient` features: 1) `push` (upload) projects) 2) `pull` (download projects) diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index d5878df3..ba8d468a 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -7,4 +7,5 @@ flake8 coveralls pytest-cov pre-commit -coverage \ No newline at end of file +coverage +smokeshow From f0b6c76274eba4e9ec975e96d7fb56629271fb7f Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 7 Mar 2024 22:29:47 +0100 Subject: [PATCH 097/165] updated requirements2 --- .github/workflows/cli-coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cli-coverage.yml b/.github/workflows/cli-coverage.yml index 8221d4a1..5365a277 100644 --- a/.github/workflows/cli-coverage.yml +++ b/.github/workflows/cli-coverage.yml @@ -20,7 +20,7 @@ jobs: python-version: '3.10' - name: Install test dependencies - run: if [ -f requirements/requirements-dev.txt ]; then pip install -r requirements/requirements-test.txt; fi + run: if [ -f requirements/requirements-test.txt ]; then pip install -r requirements/requirements-test.txt; fi - run: pip install . From e10550c99523b6e6a4826b9bf5c625814f266aec Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 7 Mar 2024 22:33:58 +0100 Subject: [PATCH 098/165] changelog --- docs/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index c0209da7..71bbe7ec 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ 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.1] - 2024-03-07 +### Fixed +- Expired token error handling ([#17](https://github.com/pepkit/pephubclient/issues/17)) + ## [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)) From 8317c8d8798b6057518bba3a29f8419e73e2a29c Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 16 Apr 2024 14:28:03 -0400 Subject: [PATCH 099/165] updated view creation --- docs/changelog.md | 5 +++ docs/templates/usage.template | 6 +++ docs/usage.md | 65 ++++++++++++++++++++++++++++++ pephubclient/__init__.py | 2 +- pephubclient/helpers.py | 2 +- pephubclient/modules/view.py | 9 +++++ requirements/requirements-test.txt | 2 +- scripts/update_usage_docs.sh | 21 ++++++++++ 8 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 docs/templates/usage.template create mode 100644 docs/usage.md create mode 100755 scripts/update_usage_docs.sh diff --git a/docs/changelog.md b/docs/changelog.md index 71bbe7ec..6d63f1da 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,11 @@ 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.2] - 2024-04-16 +### Updated +- View creation, by adding description and no_fail flag + + ## [0.4.1] - 2024-03-07 ### Fixed - Expired token error handling ([#17](https://github.com/pepkit/pephubclient/issues/17)) diff --git a/docs/templates/usage.template b/docs/templates/usage.template new file mode 100644 index 00000000..c7211be4 --- /dev/null +++ b/docs/templates/usage.template @@ -0,0 +1,6 @@ +# Usage reference + +pephubclient is a command line tool that can be used to interact with the PEPhub API. +It can be used to create, update, delete PEPs in the PEPhub database. + +Below are usage examples for the different commands that can be used with pephubclient. \ No newline at end of file diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 00000000..6fd05bdc --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,65 @@ +# Usage reference + +pephubclient is a command line tool that can be used to interact with the PEPhub API. +It can be used to create, update, delete PEPs in the PEPhub database. + +Below are usage examples for the different commands that can be used with pephubclient.## `phc --help` +```console + + Usage: pephubclient [OPTIONS] COMMAND [ARGS]... + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --version -v App version │ +│ --install-completion [bash|zsh|fish|powershell|pwsh] Install completion for the specified shell. [default: None] │ +│ --show-completion [bash|zsh|fish|powershell|pwsh] Show completion for the specified shell, to copy it or customize the installation. [default: None] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ login Login to PEPhub │ +│ logout Logout │ +│ pull Download and save project locally. │ +│ push Upload/update project in PEPhub │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +``` + +## `phc pull --help` +```console + + Usage: pephubclient pull [OPTIONS] PROJECT_REGISTRY_PATH + + Download and save project locally. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * project_registry_path TEXT [default: None] [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --force --no-force Overwrite project if it exists. [default: no-force] │ +│ --zip --no-zip Save project as zip file. [default: no-zip] │ +│ --output TEXT Output directory. [default: None] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +``` + +## `phc push --help` +```console + + Usage: pephubclient push [OPTIONS] CFG + + Upload/update project in PEPhub + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * cfg TEXT Project config file (YAML) or sample table (CSV/TSV)with one row per sample to constitute project [default: None] [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * --namespace TEXT Project namespace [default: None] [required] │ +│ * --name TEXT Project name [default: None] [required] │ +│ --tag TEXT Project tag [default: None] │ +│ --force --no-force Force push to the database. Use it to update, or upload project. [default: no-force] │ +│ --is-private --no-is-private Upload project as private. [default: no-is-private] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +``` + diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 0c454622..9ae06115 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -4,7 +4,7 @@ import coloredlogs __app_name__ = "pephubclient" -__version__ = "0.4.1" +__version__ = "0.4.2" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index e2f53f49..f2911ce0 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -35,7 +35,7 @@ def send_request( headers: Optional[dict] = None, cookies: Optional[dict] = None, params: Optional[dict] = None, - json: Optional[dict] = None, + json: Optional[Union[dict, list]] = None, ) -> requests.Response: request_return = requests.request( method=method, diff --git a/pephubclient/modules/view.py b/pephubclient/modules/view.py index f68d36ce..7c73722e 100644 --- a/pephubclient/modules/view.py +++ b/pephubclient/modules/view.py @@ -71,7 +71,9 @@ def create( name: str, tag: str, view_name: str, + description: str = None, sample_list: list = None, + no_fail: bool = False, ): """ Create view in project in PEPhub. @@ -79,9 +81,15 @@ def create( :param namespace: namespace of project :param name: name of project :param tag: tag of project + :param description: description of the view :param view_name: name of the view :param sample_list: list of sample names + :param no_fail: whether to raise an error if view was not added to the project """ + + if not sample_list or not isinstance(sample_list, list): + raise ValueError("Sample list must be a list of sample names.") + url = self._build_view_request_url( namespace=namespace, name=name, view_name=view_name ) @@ -92,6 +100,7 @@ def create( method="POST", url=url, headers=self.parse_header(self.__jwt_data), + params={"description": description, "no_fail": no_fail}, json=sample_list, ) if response.status_code == ResponseStatusCodes.ACCEPTED: diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index ba8d468a..65d4db9f 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -8,4 +8,4 @@ coveralls pytest-cov pre-commit coverage -smokeshow +smokeshow \ No newline at end of file diff --git a/scripts/update_usage_docs.sh b/scripts/update_usage_docs.sh new file mode 100755 index 00000000..95d150ee --- /dev/null +++ b/scripts/update_usage_docs.sh @@ -0,0 +1,21 @@ +#!/bin/bash +cp ../docs/templates/usage.template usage.template + +for cmd in "--help" "pull --help" "push --help"; do + echo $cmd + echo -e "## \`phc $cmd\`" > USAGE_header.temp + phc $cmd --help > USAGE.temp 2>&1 + # sed -i 's/^/\t/' USAGE.temp + sed -i.bak '1s;^;\`\`\`console\ +;' USAGE.temp +# sed -i '1s/^/\n\`\`\`console\n/' USAGE.temp + echo -e "\`\`\`\n" >> USAGE.temp + #sed -i -e "/\`looper $cmd\`/r USAGE.temp" -e '$G' usage.template # for -in place inserts + cat USAGE_header.temp USAGE.temp >> usage.template # to append to the end +done +rm USAGE.temp +rm USAGE_header.temp +rm USAGE.temp.bak +mv usage.template ../docs/usage.md +#cat usage.template +# rm USAGE.temp \ No newline at end of file From 8d64c0065dc9d64d98c9fe4e9fb99a29499ed3e6 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Tue, 16 Apr 2024 14:28:03 -0400 Subject: [PATCH 100/165] updated view creation --- docs/templates/usage.template | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/templates/usage.template diff --git a/docs/templates/usage.template b/docs/templates/usage.template new file mode 100644 index 00000000..c7211be4 --- /dev/null +++ b/docs/templates/usage.template @@ -0,0 +1,6 @@ +# Usage reference + +pephubclient is a command line tool that can be used to interact with the PEPhub API. +It can be used to create, update, delete PEPs in the PEPhub database. + +Below are usage examples for the different commands that can be used with pephubclient. \ No newline at end of file From 23c049f931910ef64b372573df11fb790e34f458 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Fri, 19 Jul 2024 16:57:55 -0400 Subject: [PATCH 101/165] fixed updates in endpoints --- docs/changelog.md | 6 ++++++ pephubclient/__init__.py | 2 +- pephubclient/models.py | 2 +- pephubclient/pephubclient.py | 2 +- tests/test_pephubclient.py | 4 ++-- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 6d63f1da..66ddebbc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,12 @@ 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.3] - 2024-07-19 +### Updated +- Updated models for new pephub API + + ## [0.4.2] - 2024-04-16 ### Updated - View creation, by adding description and no_fail flag diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index 9ae06115..eb0dba70 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -4,7 +4,7 @@ import coloredlogs __app_name__ = "pephubclient" -__version__ = "0.4.2" +__version__ = "0.4.3" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" diff --git a/pephubclient/models.py b/pephubclient/models.py index 2df76811..55a12c66 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -52,4 +52,4 @@ class SearchReturnModel(BaseModel): count: int limit: int offset: int - items: List[ProjectAnnotationModel] + results: List[ProjectAnnotationModel] diff --git a/pephubclient/pephubclient.py b/pephubclient/pephubclient.py index 6aa54ed0..b09760a6 100644 --- a/pephubclient/pephubclient.py +++ b/pephubclient/pephubclient.py @@ -249,7 +249,7 @@ def find_project( if pephub_response.status_code == ResponseStatusCodes.OK: decoded_response = self.decode_response(pephub_response, output_json=True) project_list = [] - for project_found in decoded_response["items"]: + for project_found in decoded_response["results"]: project_list.append(ProjectAnnotationModel(**project_found)) return SearchReturnModel(**decoded_response) diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 6a9aec9c..5c891775 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -154,7 +154,7 @@ def test_search_prj(self, mocker): "count": 1, "limit": 100, "offset": 0, - "items": [ + "results": [ { "namespace": "namespace1", "name": "basic", @@ -185,7 +185,7 @@ def test_search_prj(self, mocker): return_value = PEPHubClient().find_project(namespace="namespace1") assert return_value.count == 1 - assert len(return_value.items) == 1 + assert len(return_value.results) == 1 class TestHelpers: From cd335faf4bfcfca879daa4303c576480becd8c97 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Wed, 21 Aug 2024 14:14:00 -0400 Subject: [PATCH 102/165] fixed projectAnnotationModel --- docs/changelog.md | 7 ++++++- pephubclient/__init__.py | 2 +- pephubclient/models.py | 2 +- requirements/requirements-all.txt | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 66ddebbc..5237b033 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,9 +3,14 @@ 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.4] - 2024-08-21 +### Fixed +- Project annotation model + + ## [0.4.3] - 2024-07-19 ### Updated -- Updated models for new pephub API +- Updated models for new PEPhub API ## [0.4.2] - 2024-04-16 diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index eb0dba70..f9509880 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -4,7 +4,7 @@ import coloredlogs __app_name__ = "pephubclient" -__version__ = "0.4.3" +__version__ = "0.4.4" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" diff --git a/pephubclient/models.py b/pephubclient/models.py index 55a12c66..473dd0be 100644 --- a/pephubclient/models.py +++ b/pephubclient/models.py @@ -42,7 +42,7 @@ class ProjectAnnotationModel(BaseModel): last_update_date: datetime.datetime submission_date: datetime.datetime digest: str - pep_schema: str + pep_schema: Union[str, int, None] = None pop: bool = False stars_number: Optional[int] = 0 forked_from: Optional[Union[str, None]] = None diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index 3dd60438..803ef236 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -1,5 +1,5 @@ typer>=0.7.0 -peppy>=0.40.1 +peppy>=0.40.5 requests>=2.28.2 pydantic>2.5.0 pandas>=2.0.0 From 9649c42efa0cbddaf26afb130b41c4a7a5b9d586 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Thu, 21 Nov 2024 18:20:18 -0500 Subject: [PATCH 103/165] 1. added timeout 10 sec for responses 2. added unwrapping for reg path --- docs/changelog.md | 5 +++++ pephubclient/__init__.py | 2 +- pephubclient/helpers.py | 10 ++++++++++ tests/test_manual.py | 1 - tests/test_pephubclient.py | 1 - 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 5237b033..60b4ff28 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,11 @@ 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.5] - 2024-11-21 +### Added +- Function for unwrapping PEPhub registry path +- Timeout for requests + ## [0.4.4] - 2024-08-21 ### Fixed - Project annotation model diff --git a/pephubclient/__init__.py b/pephubclient/__init__.py index f9509880..a099d01e 100644 --- a/pephubclient/__init__.py +++ b/pephubclient/__init__.py @@ -4,7 +4,7 @@ import coloredlogs __app_name__ = "pephubclient" -__version__ = "0.4.4" +__version__ = "0.4.5" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" diff --git a/pephubclient/helpers.py b/pephubclient/helpers.py index f2911ce0..dcd9b51c 100644 --- a/pephubclient/helpers.py +++ b/pephubclient/helpers.py @@ -45,6 +45,7 @@ def send_request( headers=headers, params=params, json=json, + timeout=10, ) if request_return.status_code == 401: if ( @@ -160,6 +161,15 @@ def is_registry_path(input_string: str) -> bool: return True +def unwrap_registry_path(input_string: str) -> RegistryPath: + """ + Unwrap registry path from string + :param str input_string: path to the PEP (or registry path) + :return RegistryPath: RegistryPath object + """ + return RegistryPath(**parse_registry_path(input_string)) + + def _build_filename(registry_path: RegistryPath) -> str: """ Takes query string and creates output filename to save the project to. diff --git a/tests/test_manual.py b/tests/test_manual.py index 18482104..cac8be0c 100644 --- a/tests/test_manual.py +++ b/tests/test_manual.py @@ -4,7 +4,6 @@ @pytest.mark.skip(reason="Manual test") class TestViewsManual: - def test_get(self): ff = PEPHubClient().view.get( "databio", diff --git a/tests/test_pephubclient.py b/tests/test_pephubclient.py index 5c891775..211a1436 100644 --- a/tests/test_pephubclient.py +++ b/tests/test_pephubclient.py @@ -231,7 +231,6 @@ def test_is_registry_path(self, input_str, expected_output): class TestSamples: - def test_get(self, mocker): return_value = { "genome": "phc_test1", From 9713e03553d34c03536b1108826fccf88f874a5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Oct 2025 11:23:32 -0400 Subject: [PATCH 104/165] partially merge eido --- peppy/const.py | 10 + peppy/eido/const.py | 50 ++++ peppy/eido/exceptions.py | 35 +++ peppy/eido/schema.py | 74 ++++++ peppy/eido/validation.py | 237 ++++++++++++++++++ peppy/project.py | 21 +- peppy/sample.py | 7 +- peppy/utils.py | 47 +++- tests/conftest.py | 46 ++++ .../example_issue499/project_config.yaml | 10 + .../example_issue499/sample_table.csv | 5 + .../example_issue499/sample_table_pre.csv | 5 + tests/data/peps/multiline_output/config.yaml | 5 + .../multiline_output/multiline_output.csv | 8 + .../peps/multiline_output/samplesheet.csv | 6 + .../peps/multiline_output/subsamplesheet.csv | 8 + .../multiple_subsamples/project_config.yaml | 19 ++ .../peps/multiple_subsamples/sample_table.csv | 5 + .../multiple_subsamples/subsample_table1.csv | 6 + .../multiple_subsamples/subsample_table2.csv | 6 + .../peps/pep_nextflow_taxprofiler/config.yaml | 3 + .../peps/pep_nextflow_taxprofiler/output.csv | 7 + .../pep_nextflow_taxprofiler/samplesheet.csv | 7 + .../peps/pep_with_fasta_column/config.yaml | 3 + .../peps/pep_with_fasta_column/output.csv | 8 + .../pep_with_fasta_column/samplesheet.csv | 6 + .../pep_with_fasta_column/subsamplesheet.csv | 8 + .../test_file_existing/project_config.yaml | 12 + .../peps/test_file_existing/sample_table.csv | 5 + .../test_file_existing/subsample_table.csv | 6 + tests/data/peps/test_pep/test_cfg.yaml | 10 + .../data/peps/test_pep/test_sample_table.csv | 3 + .../peps/value_check_pep/project_config.yaml | 6 + .../peps/value_check_pep/sample_table.csv | 7 + .../data/schemas/schema_test_file_exist.yaml | 35 +++ tests/data/schemas/test_schema.yaml | 22 ++ tests/data/schemas/test_schema_imports.yaml | 17 ++ tests/data/schemas/test_schema_invalid.yaml | 25 ++ .../test_schema_invalid_with_type.yaml | 25 ++ .../schemas/test_schema_sample_invalid.yaml | 26 ++ tests/data/schemas/test_schema_samples.yaml | 22 ++ tests/data/schemas/value_check_schema.yaml | 16 ++ tests/test_Project.py | 15 +- tests/test_validations.py | 53 ++++ 44 files changed, 940 insertions(+), 17 deletions(-) create mode 100644 peppy/eido/const.py create mode 100644 peppy/eido/exceptions.py create mode 100644 peppy/eido/schema.py create mode 100644 peppy/eido/validation.py create mode 100644 tests/data/example_peps-master/example_issue499/project_config.yaml create mode 100755 tests/data/example_peps-master/example_issue499/sample_table.csv create mode 100755 tests/data/example_peps-master/example_issue499/sample_table_pre.csv create mode 100644 tests/data/peps/multiline_output/config.yaml create mode 100644 tests/data/peps/multiline_output/multiline_output.csv create mode 100644 tests/data/peps/multiline_output/samplesheet.csv create mode 100644 tests/data/peps/multiline_output/subsamplesheet.csv create mode 100644 tests/data/peps/multiple_subsamples/project_config.yaml create mode 100644 tests/data/peps/multiple_subsamples/sample_table.csv create mode 100644 tests/data/peps/multiple_subsamples/subsample_table1.csv create mode 100644 tests/data/peps/multiple_subsamples/subsample_table2.csv create mode 100644 tests/data/peps/pep_nextflow_taxprofiler/config.yaml create mode 100644 tests/data/peps/pep_nextflow_taxprofiler/output.csv create mode 100644 tests/data/peps/pep_nextflow_taxprofiler/samplesheet.csv create mode 100644 tests/data/peps/pep_with_fasta_column/config.yaml create mode 100644 tests/data/peps/pep_with_fasta_column/output.csv create mode 100644 tests/data/peps/pep_with_fasta_column/samplesheet.csv create mode 100644 tests/data/peps/pep_with_fasta_column/subsamplesheet.csv create mode 100644 tests/data/peps/test_file_existing/project_config.yaml create mode 100644 tests/data/peps/test_file_existing/sample_table.csv create mode 100644 tests/data/peps/test_file_existing/subsample_table.csv create mode 100644 tests/data/peps/test_pep/test_cfg.yaml create mode 100644 tests/data/peps/test_pep/test_sample_table.csv create mode 100644 tests/data/peps/value_check_pep/project_config.yaml create mode 100644 tests/data/peps/value_check_pep/sample_table.csv create mode 100644 tests/data/schemas/schema_test_file_exist.yaml create mode 100644 tests/data/schemas/test_schema.yaml create mode 100644 tests/data/schemas/test_schema_imports.yaml create mode 100644 tests/data/schemas/test_schema_invalid.yaml create mode 100644 tests/data/schemas/test_schema_invalid_with_type.yaml create mode 100644 tests/data/schemas/test_schema_sample_invalid.yaml create mode 100644 tests/data/schemas/test_schema_samples.yaml create mode 100644 tests/data/schemas/value_check_schema.yaml create mode 100644 tests/test_validations.py diff --git a/peppy/const.py b/peppy/const.py index b52c6cdd..35f62ccc 100644 --- a/peppy/const.py +++ b/peppy/const.py @@ -123,3 +123,13 @@ SUBSAMPLE_RAW_LIST_KEY = "_subsample_list" __all__ = PROJECT_CONSTANTS + SAMPLE_CONSTANTS + OTHER_CONSTANTS + + +SCHEMA_SECTIONS = ["PROP_KEY", "TANGIBLE_KEY", "SIZING_KEY"] + +SCHEMA_VALIDAION_KEYS = [ + "MISSING_KEY", + "REQUIRED_INPUTS_KEY", + "ALL_INPUTS_KEY", + "INPUT_FILE_SIZE_KEY", +] diff --git a/peppy/eido/const.py b/peppy/eido/const.py new file mode 100644 index 00000000..2ec49428 --- /dev/null +++ b/peppy/eido/const.py @@ -0,0 +1,50 @@ +""" +Constant variables for eido package +""" + +LOGGING_LEVEL = "INFO" +PKG_NAME = "eido" +INSPECT_CMD = "inspect" +VALIDATE_CMD = "validate" +CONVERT_CMD = "convert" +FILTERS_CMD = "filters" +SUBPARSER_MSGS = { + VALIDATE_CMD: "Validate a PEP or its components", + INSPECT_CMD: "Inspect a PEP", + CONVERT_CMD: "Convert PEP format using filters", +} +PROP_KEY = "properties" + +SAMPLES_KEY = "samples" + +TANGIBLE_KEY = "tangible" +SIZING_KEY = "sizing" + +# sample schema input validation key names, these values are required by looper +# to refer to the dict values +MISSING_KEY = "missing" +REQUIRED_INPUTS_KEY = "required_inputs" +ALL_INPUTS_KEY = "all_inputs" +INPUT_FILE_SIZE_KEY = "input_file_size" + +# groups of constants +GENERAL = [ + "LOGGING_LEVEL", + "PKG_NAME", + "INSPECT_CMD", + "VALIDATE_CMD", + "CONVERT_CMD", + "FILTERS_CMD", + "SUBPARSER_MSGS", +] + +SCHEMA_SECTIONS = ["PROP_KEY", "TANGIBLE_KEY", "SIZING_KEY"] + +SCHEMA_VALIDAION_KEYS = [ + "MISSING_KEY", + "REQUIRED_INPUTS_KEY", + "ALL_INPUTS_KEY", + "INPUT_FILE_SIZE_KEY", +] + +__all__ = GENERAL + SCHEMA_SECTIONS + SCHEMA_VALIDAION_KEYS diff --git a/peppy/eido/exceptions.py b/peppy/eido/exceptions.py new file mode 100644 index 00000000..dc314a69 --- /dev/null +++ b/peppy/eido/exceptions.py @@ -0,0 +1,35 @@ +""" Exceptions for specific eido issues. """ + +from abc import ABCMeta + +_all__ = [ + "EidoFilterError", + "EidoSchemaInvalidError", + "EidoValidationError", + "PathAttrNotFoundError", +] + + +class EidoException(Exception): + """Base type for custom package errors.""" + + __metaclass__ = ABCMeta + + +class PathAttrNotFoundError(EidoException): + """Path-like argument does not exist.""" + + def __init__(self, key): + super(PathAttrNotFoundError, self).__init__(key) + + +class EidoValidationError(EidoException): + """Object was not validated successfully according to schema.""" + + def __init__(self, message, errors_by_type): + super().__init__(message) + self.errors_by_type = errors_by_type + self.message = message + + def __str__(self): + return f"EidoValidationError ({self.message}): {self.errors_by_type}" diff --git a/peppy/eido/schema.py b/peppy/eido/schema.py new file mode 100644 index 00000000..229ab973 --- /dev/null +++ b/peppy/eido/schema.py @@ -0,0 +1,74 @@ +from logging import getLogger + +from ..utils import load_yaml +from .const import PROP_KEY, SAMPLES_KEY + +_LOGGER = getLogger(__name__) + + +def preprocess_schema(schema_dict): + """ + Preprocess schema before validation for user's convenience + + Preprocessing includes: + - renaming 'samples' to '_samples' since in the peppy.Project object + _samples attribute holds the list of peppy.Samples objects. + - adding array of strings entry for every string specified to accommodate + subsamples in peppy.Project + + :param dict schema_dict: schema dictionary to preprocess + :return dict: preprocessed schema + """ + _LOGGER.debug(f"schema ori: {schema_dict}") + if "project" not in schema_dict[PROP_KEY]: + _LOGGER.debug("No project section found in schema") + + if SAMPLES_KEY in schema_dict[PROP_KEY]: + if ( + "items" in schema_dict[PROP_KEY][SAMPLES_KEY] + and PROP_KEY in schema_dict[PROP_KEY][SAMPLES_KEY]["items"] + ): + s_props = schema_dict[PROP_KEY][SAMPLES_KEY]["items"][PROP_KEY] + for prop, val in s_props.items(): + if "type" in val and val["type"] in ["string", "number", "boolean"]: + s_props[prop] = {} + s_props[prop]["anyOf"] = [val, {"type": "array", "items": val}] + else: + _LOGGER.debug("No samples section found in schema") + _LOGGER.debug(f"schema processed: {schema_dict}") + return schema_dict + + +def read_schema(schema): + """ + Safely read schema from YAML-formatted file. + + If the schema imports any other schemas, they will be read recursively. + + :param str | Mapping schema: path to the schema file + or schema in a dict form + :return list[dict]: read schemas + :raise TypeError: if the schema arg is neither a Mapping nor a file path or + if the 'imports' sections in any of the schemas is not a list + """ + + def _recursively_read_schemas(x, lst): + if "imports" in x: + if isinstance(x["imports"], list): + for sch in x["imports"]: + lst.extend(read_schema(sch)) + else: + raise TypeError("In schema the 'imports' section has to be a list") + lst.append(x) + return lst + + schema_list = [] + if isinstance(schema, str): + _LOGGER.debug(f"Reading schema: {schema}") + schema = load_yaml(schema) + if not isinstance(schema, dict): + raise TypeError( + f"schema has to be a dict, path to an existing file or URL to a remote one. " + f"Got: {type(schema)}" + ) + return _recursively_read_schemas(schema, schema_list) diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py new file mode 100644 index 00000000..fd604952 --- /dev/null +++ b/peppy/eido/validation.py @@ -0,0 +1,237 @@ +import os +from copy import deepcopy as dpcpy +from logging import getLogger +from typing import Mapping, NoReturn, Union +from warnings import warn + +from jsonschema import Draft7Validator +from pandas.core.common import flatten + +from ..project import Project +from ..sample import Sample +from .const import PROP_KEY, SAMPLES_KEY, SIZING_KEY, TANGIBLE_KEY +from .exceptions import EidoValidationError, PathAttrNotFoundError +from .schema import preprocess_schema, read_schema + +_LOGGER = getLogger(__name__) + + +def _validate_object(obj: Mapping, schema: Union[str, dict], sample_name_colname=False): + """ + Generic function to validate object against a schema + + :param Mapping obj: an object to validate + :param str | dict schema: schema dict to validate against or a path to one + from the error. Useful when used ith large projects + + :raises EidoValidationError: if validation is unsuccessful + """ + validator = Draft7Validator(schema) + _LOGGER.debug(f"{obj},\n {schema}") + if not validator.is_valid(obj): + errors = sorted(validator.iter_errors(obj), key=lambda e: e.path) + errors_by_type = {} + + # Accumulate and restructure error objects by error type + for error in errors: + if not error.message in errors_by_type: + errors_by_type[error.message] = [] + + try: + instance_name = error.instance[sample_name_colname] + except KeyError: + instance_name = "project" + except TypeError: + instance_name = obj["samples"][error.absolute_path[1]][ + sample_name_colname + ] + errors_by_type[error.message].append( + { + "type": error.message, + "message": f"{error.message} on instance {instance_name}", + "sample_name": instance_name, + } + ) + + raise EidoValidationError("Validation failed", errors_by_type) + else: + _LOGGER.debug("Validation was successful...") + + +def validate_project(project: Project, schema: Union[str, dict]) -> NoReturn: + """ + Validate a project object against a schema + + :param peppy.Project project: a project object to validate + :param str | dict schema: schema dict to validate against or a path to one + from the error. Useful when used ith large projects + + :return: NoReturn + :raises EidoValidationError: if validation is unsuccessful + """ + sample_name_colname = project.sample_name_colname + schema_dicts = read_schema(schema=schema) + for schema_dict in schema_dicts: + project_dict = project.to_dict() + _validate_object( + project_dict, preprocess_schema(schema_dict), sample_name_colname + ) + _LOGGER.debug("Project validation successful") + + +def _validate_sample_object(sample: Sample, schemas): + """ + Internal function that allows to validate a peppy.Sample object without + requiring a reference to peppy.Project. + + :param peppy.Sample sample: a sample object to validate + :param list[dict] schemas: list of schemas to validate against or a path to one + """ + for schema_dict in schemas: + schema_dict = preprocess_schema(schema_dict) + sample_schema_dict = schema_dict[PROP_KEY][SAMPLES_KEY]["items"] + _validate_object(sample.to_dict(), sample_schema_dict) + _LOGGER.debug( + f"{getattr(sample, 'sample_name', '')} sample validation successful" + ) + + +def validate_sample( + project: Project, sample_name: Union[str, int], schema: Union[str, dict] +) -> NoReturn: + """ + Validate the selected sample object against a schema + + :param peppy.Project project: a project object to validate + :param str | int sample_name: name or index of the sample to validate + :param str | dict schema: schema dict to validate against or a path to one + + :raises EidoValidationError: if validation is unsuccessful + """ + sample = ( + project.samples[sample_name] + if isinstance(sample_name, int) + else project.get_sample(sample_name) + ) + _validate_sample_object( + sample=sample, + schemas=read_schema(schema=schema), + ) + + +def validate_config( + project: Union[Project, dict], schema: Union[str, dict] +) -> NoReturn: + """ + Validate the config part of the Project object against a schema + + :param peppy.Project project: a project object to validate + :param str | dict schema: schema dict to validate against or a path to one + """ + schema_dicts = read_schema(schema=schema) + for schema_dict in schema_dicts: + schema_cpy = preprocess_schema(dpcpy(schema_dict)) + try: + del schema_cpy[PROP_KEY][SAMPLES_KEY] + except KeyError: + pass + if "required" in schema_cpy: + try: + schema_cpy["required"].remove(SAMPLES_KEY) + except ValueError: + pass + if isinstance(project, dict): + _validate_object({"project": project}, schema_cpy) + + else: + project_dict = project.to_dict() + _validate_object(project_dict, schema_cpy) + _LOGGER.debug("Config validation successful") + + +def _get_attr_values(obj, attrlist): + """ + Get value corresponding to each given attribute. + + :param Mapping obj: an object to get the attributes from + :param str | Iterable[str] attrlist: names of attributes to + retrieve values for + :return dict: value corresponding to + each named attribute; null if this Sample's value for the + attribute given by the argument to the "attrlist" parameter is + empty/null, or if this Sample lacks the indicated attribute + """ + # If attribute is None, then value is also None. + if not attrlist: + return None + if not isinstance(attrlist, list): + attrlist = [attrlist] + # Strings contained here are appended later so shouldn't be null. + return list(flatten([getattr(obj, attr, "") for attr in attrlist])) + + +def validate_input_files( + project: Project, + schemas: Union[str, dict], + sample_name: Union[str, int] = None, +): + """ + Determine which of the required and optional files are missing. + + The names of the attributes that are required and/or deemed as inputs + are sourced from the schema, more specifically from `required_files` + and `files` sections in samples section: + + - If any of the required files are missing, this function raises an error. + - If any of the optional files are missing, the function raises a warning. + + Note, this function also performs Sample object validation with jsonschema. + + :param peppy.Project project: project that defines the samples to validate + :param str | dict schema: schema dict to validate against or a path to one + :param str | int sample_name: name or index of the sample to validate. If None, + validate all samples in the project + :raise PathAttrNotFoundError: if any required sample attribute is missing + """ + + if sample_name is None: + samples = project.samples + else: + samples = ( + project.samples[sample_name] + if isinstance(sample_name, int) + else project.get_sample(sample_name) + ) + samples = [samples] + + if isinstance(schemas, str): + schemas = read_schema(schemas) + + for sample in samples: + # validate attrs existence first + _validate_sample_object(schemas=schemas, sample=sample) + + all_inputs = set() + required_inputs = set() + schema = schemas[-1] # use only first schema, in case there are imports + sample_schema_dict = schema[PROP_KEY][SAMPLES_KEY]["items"] + if SIZING_KEY in sample_schema_dict: + all_inputs.update(_get_attr_values(sample, sample_schema_dict[SIZING_KEY])) + if TANGIBLE_KEY in sample_schema_dict: + required_inputs = set( + _get_attr_values(sample, sample_schema_dict[TANGIBLE_KEY]) + ) + all_inputs.update(required_inputs) + + missing_required_inputs = [i for i in required_inputs if not os.path.exists(i)] + missing_inputs = [i for i in all_inputs if not os.path.exists(i)] + if missing_inputs: + warn( + f"For sample '{getattr(sample, project.sample_table_index)}'. " + f"Optional inputs not found: {missing_inputs}" + ) + if missing_required_inputs: + raise PathAttrNotFoundError( + f"For sample '{getattr(sample, project.sample_table_index)}'. " + f"Required inputs not found: {required_inputs}" + ) diff --git a/peppy/project.py b/peppy/project.py index a05c05b1..081fd2b0 100644 --- a/peppy/project.py +++ b/peppy/project.py @@ -6,8 +6,9 @@ import sys from collections.abc import Mapping, MutableMapping from contextlib import suppress +from copy import deepcopy from logging import getLogger -from typing import Iterable, List, Tuple, Union, Literal +from typing import Iterable, List, Literal, Tuple, Union import numpy as np import pandas as pd @@ -16,11 +17,11 @@ from rich.console import Console from rich.progress import track from ubiquerg import is_url -from copy import deepcopy from .const import ( ACTIVE_AMENDMENTS_KEY, AMENDMENTS_KEY, + APPEND_KEY, ATTR_KEY_PREFIX, CFG_IMPORTS_KEY, CFG_SAMPLE_TABLE_KEY, @@ -28,7 +29,6 @@ CONFIG_FILE_KEY, CONFIG_KEY, CONFIG_VERSION_KEY, - APPEND_KEY, DERIVED_ATTRS_KEY, DERIVED_KEY, DERIVED_SOURCES_KEY, @@ -41,6 +41,7 @@ MAX_PROJECT_SAMPLES_REPR, METADATA_KEY, NAME_KEY, + ORIGINAL_CONFIG_KEY, PEP_LATEST_VERSION, PKG_NAME, PROJ_MODS_KEY, @@ -60,13 +61,12 @@ SUBSAMPLE_RAW_LIST_KEY, SUBSAMPLE_TABLE_INDEX_KEY, SUBSAMPLE_TABLES_FILE_KEY, - ORIGINAL_CONFIG_KEY, ) from .exceptions import ( - InvalidSampleTableFileException, - MissingAmendmentError, IllegalStateException, InvalidConfigFileException, + InvalidSampleTableFileException, + MissingAmendmentError, ) from .parsers import select_parser from .sample import Sample @@ -76,6 +76,7 @@ load_yaml, make_abs_via_cfg, make_list, + unpopulated_env_var, ) _LOGGER = getLogger(PKG_NAME) @@ -665,7 +666,7 @@ def _assert_samples_have_names(self): if self.st_index not in sample: message = ( f"{CFG_SAMPLE_TABLE_KEY} is missing '{self.st_index}' column; " - f"you must specify {CFG_SAMPLE_TABLE_KEY}s in {self.st_index} or derive them" + f"you must specify a {self.st_index} column for your {CFG_SAMPLE_TABLE_KEY} or derive it" ) raise InvalidSampleTableFileException(message) @@ -916,6 +917,7 @@ def attr_derive(self, attrs=None): ds = self[CONFIG_KEY][SAMPLE_MODS_KEY][DERIVED_KEY][DERIVED_SOURCES_KEY] derivations = attrs or (da if isinstance(da, list) else [da]) _LOGGER.debug("Derivations to be done: {}".format(derivations)) + env_var_miss = set() for sample in track( self.samples, description="Deriving sample attributes", @@ -938,6 +940,9 @@ def attr_derive(self, attrs=None): derived_attr = sample.derive_attribute(ds, attr) if derived_attr: + if "$" in derived_attr: + env_var_miss.add(derived_attr) + _LOGGER.debug("Setting '{}' to '{}'".format(attr, derived_attr)) sample[attr] = derived_attr else: @@ -945,6 +950,8 @@ def attr_derive(self, attrs=None): f"Not setting null/empty value for data source '{attr}': {type(derived_attr)}" ) sample._derived_cols_done.append(attr) + if len(env_var_miss) > 0: + unpopulated_env_var(env_var_miss) def activate_amendments(self, amendments): """ diff --git a/peppy/sample.py b/peppy/sample.py index 60d14677..8b662f7b 100644 --- a/peppy/sample.py +++ b/peppy/sample.py @@ -20,8 +20,8 @@ SAMPLE_SHEET_KEY, ) from .exceptions import InvalidSampleTableFileException -from .utils import copy, grab_project_data from .simple_attr_map import SimpleAttMap +from .utils import copy, grab_project_data _LOGGER = getLogger(PKG_NAME) @@ -199,11 +199,6 @@ def _format_regex(regex, items): keys = [i[1] for i in Formatter().parse(regex) if i[1] is not None] if not keys: return [regex] - if "$" in regex: - _LOGGER.warning( - "Not all environment variables were populated " - "in derived attribute source: {}".format(regex) - ) attr_lens = [ len(v) for k, v in items.items() if (isinstance(v, list) and k in keys) ] diff --git a/peppy/utils.py b/peppy/utils.py index d7598e7d..59090cd8 100644 --- a/peppy/utils.py +++ b/peppy/utils.py @@ -2,7 +2,10 @@ import logging import os -from typing import Dict, Mapping, Type, Union +import posixpath as psp +import re +from collections import defaultdict +from typing import Dict, Mapping, Set, Type, Union from urllib.request import urlopen import yaml @@ -198,3 +201,45 @@ def extract_custom_index_for_subsample_table(pep_dictionary: Dict): if SUBSAMPLE_TABLE_INDEX_KEY in pep_dictionary else None ) + + +def unpopulated_env_var(paths: Set[str]): + """ + Given a set of paths that may contain env vars, group by env var and + print a warning for each group with the deepest common directory and + the paths relative to that directory. + """ + _VAR_RE = re.compile(r"^\$(\w+)/(.*)$") + groups: dict[str, list[str]] = defaultdict(list) + + # 1) Group by env var + for s in paths: + m = _VAR_RE.match(s.strip()) + if not m: + # Not in "$VAR/..." form — skip or collect under a special key if you prefer + continue + var, tail = m.group(1), m.group(2) + # normalize to POSIX-ish, no leading "./" + tail = tail.lstrip("/") + groups[var].append(tail) + + # 2) For each var, compute deepest common directory and print + for var, tails in groups.items(): + if not tails: + continue + + if len(tails) == 1: + # With a single path, use its directory as the common dir + common_dir = psp.dirname(tails[0]) or "." + else: + common_dir = psp.commonpath(tails) or "." + # Ensure it's a directory; commonpath is component-wise, so it's fine. + + _LOGGER.warning( + "Not all environment variables were populated in derived attribute source: $%s", + var, + ) + for t in tails: + rel = psp.relpath(t, start=common_dir or ".") + # show with leading "./" per your example + _LOGGER.warning(" ./%s", rel) diff --git a/tests/conftest.py b/tests/conftest.py index 50c7ffd5..ffd9107d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import pandas as pd import pytest +from peppy.project import Project __author__ = "Michal Stolarczyk" __email__ = "michal.stolarczyk@nih.gov" @@ -12,6 +13,11 @@ EPB = "master" +@pytest.fixture +def data_path(): + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") + + def merge_paths(pep_branch, directory_name): return os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -68,3 +74,43 @@ def config_with_pandas_obj(request): return pd.read_csv( get_path_to_example_file(EPB, request.param, "sample_table.csv"), dtype=str ) + + +@pytest.fixture +def schemas_path(data_path): + return os.path.join(data_path, "schemas") + + +@pytest.fixture +def peps_path(data_path): + return os.path.join(data_path, "peps") + + +@pytest.fixture +def project_file_path(peps_path): + return os.path.join(peps_path, "test_pep", "test_cfg.yaml") + + +@pytest.fixture +def project_object(project_file_path): + return Project(project_file_path) + + +@pytest.fixture +def schema_file_path(schemas_path): + return os.path.join(schemas_path, "test_schema.yaml") + + +@pytest.fixture +def schema_samples_file_path(schemas_path): + return os.path.join(schemas_path, "test_schema_samples.yaml") + + +@pytest.fixture +def schema_invalid_file_path(schemas_path): + return os.path.join(schemas_path, "test_schema_invalid.yaml") + + +@pytest.fixture +def schema_imports_file_path(schemas_path): + return os.path.join(schemas_path, "test_schema_imports.yaml") diff --git a/tests/data/example_peps-master/example_issue499/project_config.yaml b/tests/data/example_peps-master/example_issue499/project_config.yaml new file mode 100644 index 00000000..ee846cb7 --- /dev/null +++ b/tests/data/example_peps-master/example_issue499/project_config.yaml @@ -0,0 +1,10 @@ +pep_version: "2.0.0" +sample_table: sample_table.csv +output_dir: "$HOME/hello_looper_results" + +sample_modifiers: + derive: + attributes: [file_path] + sources: + source1: $PROJECT/{organism}_{time}h.fastq + source2: $COLLABORATOR/weirdNamingScheme_{external_id}.fastq diff --git a/tests/data/example_peps-master/example_issue499/sample_table.csv b/tests/data/example_peps-master/example_issue499/sample_table.csv new file mode 100755 index 00000000..bcfd9bde --- /dev/null +++ b/tests/data/example_peps-master/example_issue499/sample_table.csv @@ -0,0 +1,5 @@ +sample_name,protocol,organism,time,file_path +pig_0h,RRBS,pig,0,source1 +pig_1h,RRBS,pig,1,source1 +frog_0h,RRBS,frog,0,source1 +frog_1h,RRBS,frog,1,source1 diff --git a/tests/data/example_peps-master/example_issue499/sample_table_pre.csv b/tests/data/example_peps-master/example_issue499/sample_table_pre.csv new file mode 100755 index 00000000..159fc341 --- /dev/null +++ b/tests/data/example_peps-master/example_issue499/sample_table_pre.csv @@ -0,0 +1,5 @@ +sample_name,protocol,organism,time,file_path +pig_0h,RRBS,pig,0,data/lab/project/pig_0h.fastq +pig_1h,RRBS,pig,1,data/lab/project/pig_1h.fastq +frog_0h,RRBS,frog,0,data/lab/project/frog_0h.fastq +frog_1h,RRBS,frog,1,data/lab/project/frog_1h.fastq diff --git a/tests/data/peps/multiline_output/config.yaml b/tests/data/peps/multiline_output/config.yaml new file mode 100644 index 00000000..4196ee81 --- /dev/null +++ b/tests/data/peps/multiline_output/config.yaml @@ -0,0 +1,5 @@ +pep_version: "2.0.0" +sample_table: "samplesheet.csv" +subsample_table: "subsamplesheet.csv" +sample_table_index: "sample" +subsample_table_index: "sample" \ No newline at end of file diff --git a/tests/data/peps/multiline_output/multiline_output.csv b/tests/data/peps/multiline_output/multiline_output.csv new file mode 100644 index 00000000..5e889262 --- /dev/null +++ b/tests/data/peps/multiline_output/multiline_output.csv @@ -0,0 +1,8 @@ +sample,strandedness,instrument_platform,run_accession,fastq_1,fastq_2 +WT_REP1,reverse,ABI_SOLID,runaccession1,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357070_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357070_2.fastq.gz +WT_REP1,reverse,BGISEQ,runaccession2,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357071_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357071_2.fastq.gz +WT_REP2,reverse,CAPILLARY,123123123,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357072_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357072_2.fastq.gz +RAP1_UNINDUCED_REP1,reverse,COMPLETE_GENOMICS,somerunaccesion,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357073_1.fastq.gz, +RAP1_UNINDUCED_REP2,reverse,DNBSEQ,ERR2412421,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357074_1.fastq.gz, +RAP1_UNINDUCED_REP2,reverse,HELICOS,xxxxxxxxxx,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357075_1.fastq.gz, +RAP1_IAA_30M_REP1,reverse,ILLUMINA,None,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357076_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357076_2.fastq.gz diff --git a/tests/data/peps/multiline_output/samplesheet.csv b/tests/data/peps/multiline_output/samplesheet.csv new file mode 100644 index 00000000..a26933ef --- /dev/null +++ b/tests/data/peps/multiline_output/samplesheet.csv @@ -0,0 +1,6 @@ +sample,strandedness +WT_REP1,reverse +WT_REP2,reverse +RAP1_UNINDUCED_REP1,reverse +RAP1_UNINDUCED_REP2,reverse +RAP1_IAA_30M_REP1,reverse diff --git a/tests/data/peps/multiline_output/subsamplesheet.csv b/tests/data/peps/multiline_output/subsamplesheet.csv new file mode 100644 index 00000000..1e56c363 --- /dev/null +++ b/tests/data/peps/multiline_output/subsamplesheet.csv @@ -0,0 +1,8 @@ +sample,instrument_platform,run_accession,fastq_1,fastq_2 +WT_REP1,ABI_SOLID,runaccession1,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357070_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357070_2.fastq.gz +WT_REP1,BGISEQ,runaccession2,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357071_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357071_2.fastq.gz +WT_REP2,CAPILLARY,123123123,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357072_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357072_2.fastq.gz +RAP1_UNINDUCED_REP1,COMPLETE_GENOMICS,somerunaccesion,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357073_1.fastq.gz, +RAP1_UNINDUCED_REP2,DNBSEQ,ERR2412421,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357074_1.fastq.gz, +RAP1_UNINDUCED_REP2,HELICOS,xxxxxxxxxx,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357075_1.fastq.gz, +RAP1_IAA_30M_REP1,ILLUMINA,None,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357076_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357076_2.fastq.gz diff --git a/tests/data/peps/multiple_subsamples/project_config.yaml b/tests/data/peps/multiple_subsamples/project_config.yaml new file mode 100644 index 00000000..e0e580b7 --- /dev/null +++ b/tests/data/peps/multiple_subsamples/project_config.yaml @@ -0,0 +1,19 @@ +pep_version: "2.1.0" +sample_table: sample_table.csv +subsample_table: + - subsample_table1.csv + - subsample_table2.csv + +sample_modifiers: + append: + local_files: LOCAL + genome: "fg" + derive: + attributes: [local_files] + sources: + LOCAL: "../data/{file_path}" + imply: + - if: + identifier: "frog1" + then: + genome: "frog_frog" diff --git a/tests/data/peps/multiple_subsamples/sample_table.csv b/tests/data/peps/multiple_subsamples/sample_table.csv new file mode 100644 index 00000000..7c06204c --- /dev/null +++ b/tests/data/peps/multiple_subsamples/sample_table.csv @@ -0,0 +1,5 @@ +sample_id,protocol,identifier +frog_1,anySampleType,frog1 +frog_2,anySampleType,frog2 +frog_3,anySampleType,frog3 +frog_4,anySampleType,frog4 diff --git a/tests/data/peps/multiple_subsamples/subsample_table1.csv b/tests/data/peps/multiple_subsamples/subsample_table1.csv new file mode 100644 index 00000000..f1b3c2f1 --- /dev/null +++ b/tests/data/peps/multiple_subsamples/subsample_table1.csv @@ -0,0 +1,6 @@ +sample_id,file_path,subsample_name +frog_1,file/a.txt,a +frog_1,file/b.txt,b +frog_1,file/c.txt,c +frog_2,file/a.txt,a +frog_2,file/b.txt,b diff --git a/tests/data/peps/multiple_subsamples/subsample_table2.csv b/tests/data/peps/multiple_subsamples/subsample_table2.csv new file mode 100644 index 00000000..5e6d2981 --- /dev/null +++ b/tests/data/peps/multiple_subsamples/subsample_table2.csv @@ -0,0 +1,6 @@ +sample_id,random_string,subsample_name +frog_1,x_x,x +frog_1,y_y,y +frog_1,z_z,z +frog_2,xy_yx,xy +frog_2,xx_xx,xx diff --git a/tests/data/peps/pep_nextflow_taxprofiler/config.yaml b/tests/data/peps/pep_nextflow_taxprofiler/config.yaml new file mode 100644 index 00000000..51cc3784 --- /dev/null +++ b/tests/data/peps/pep_nextflow_taxprofiler/config.yaml @@ -0,0 +1,3 @@ +pep_version: "2.1.0" +sample_table: "samplesheet.csv" + diff --git a/tests/data/peps/pep_nextflow_taxprofiler/output.csv b/tests/data/peps/pep_nextflow_taxprofiler/output.csv new file mode 100644 index 00000000..d70a0e77 --- /dev/null +++ b/tests/data/peps/pep_nextflow_taxprofiler/output.csv @@ -0,0 +1,7 @@ +sample,instrument_platform,run_accession,fastq_1,fastq_2,fasta +2611,ILLUMINA,ERR5766174,,,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fasta/ERX5474930_ERR5766174_1.fa.gz +2613,ILLUMINA,ERR5766181,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474937_ERR5766181_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474937_ERR5766181_2.fastq.gz, +ERR3201952,OXFORD_NANOPORE,ERR3201952,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERR3201952.fastq.gz,, +2612,ILLUMINA,ERR5766176,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474932_ERR5766176_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474932_ERR5766176_2.fastq.gz, +2612,ILLUMINA,ERR5766176_B,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474932_ERR5766176_B_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474932_ERR5766176_B_2.fastq.gz, +2612,ILLUMINA,ERR5766180,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474936_ERR5766180_1.fastq.gz,, diff --git a/tests/data/peps/pep_nextflow_taxprofiler/samplesheet.csv b/tests/data/peps/pep_nextflow_taxprofiler/samplesheet.csv new file mode 100644 index 00000000..1b17b767 --- /dev/null +++ b/tests/data/peps/pep_nextflow_taxprofiler/samplesheet.csv @@ -0,0 +1,7 @@ +sample,instrument_platform,run_accession,fastq_1,fastq_2,fasta +2611,ILLUMINA,ERR5766174,,,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fasta/ERX5474930_ERR5766174_1.fa.gz +2612,ILLUMINA,ERR5766176,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474932_ERR5766176_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474932_ERR5766176_2.fastq.gz, +2612,ILLUMINA,ERR5766176_B,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474932_ERR5766176_B_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474932_ERR5766176_B_2.fastq.gz, +2612,ILLUMINA,ERR5766180,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474936_ERR5766180_1.fastq.gz,, +2613,ILLUMINA,ERR5766181,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474937_ERR5766181_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERX5474937_ERR5766181_2.fastq.gz, +ERR3201952,OXFORD_NANOPORE,ERR3201952,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fastq/ERR3201952.fastq.gz,, diff --git a/tests/data/peps/pep_with_fasta_column/config.yaml b/tests/data/peps/pep_with_fasta_column/config.yaml new file mode 100644 index 00000000..a56d90b3 --- /dev/null +++ b/tests/data/peps/pep_with_fasta_column/config.yaml @@ -0,0 +1,3 @@ +pep_version: "2.0.0" +sample_table: "samplesheet.csv" +subsample_table: "subsamplesheet.csv" diff --git a/tests/data/peps/pep_with_fasta_column/output.csv b/tests/data/peps/pep_with_fasta_column/output.csv new file mode 100644 index 00000000..ac7dc5b2 --- /dev/null +++ b/tests/data/peps/pep_with_fasta_column/output.csv @@ -0,0 +1,8 @@ +sample,strandedness,instrument_platform,run_accession,fastq_1,fastq_2,fasta +WT_REP1,reverse,ABI_SOLID,runaccession1,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357076_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357076_2.fastq.gz, +WT_REP1,reverse,ABI_SOLID,runaccession2,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357071_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357071_2.fastq.gz, +WT_REP2,reverse,BGISEQ,123123123,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357072_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357072_2.fastq.gz, +RAP1_UNINDUCED_REP1,reverse,CAPILLARY,somerunaccesion,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357073_1.fastq.gz,, +RAP1_UNINDUCED_REP2,reverse,COMPLETE_GENOMICS,ERR2412421,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357074_1.fastq.gz,, +RAP1_UNINDUCED_REP2,reverse,COMPLETE_GENOMICS,xxxxxxxxxx,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357075_1.fastq.gz,, +RAP1_IAA_30M_REP1,reverse,DNBSEQ,None,,,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fasta/ERX5474930_ERR5766174_1.fa.gz diff --git a/tests/data/peps/pep_with_fasta_column/samplesheet.csv b/tests/data/peps/pep_with_fasta_column/samplesheet.csv new file mode 100644 index 00000000..6d9956d8 --- /dev/null +++ b/tests/data/peps/pep_with_fasta_column/samplesheet.csv @@ -0,0 +1,6 @@ +sample,strandedness,instrument_platform +WT_REP1,reverse,ABI_SOLID +WT_REP2,reverse,BGISEQ +RAP1_UNINDUCED_REP1,reverse,CAPILLARY +RAP1_UNINDUCED_REP2,reverse,COMPLETE_GENOMICS +RAP1_IAA_30M_REP1,reverse,DNBSEQ diff --git a/tests/data/peps/pep_with_fasta_column/subsamplesheet.csv b/tests/data/peps/pep_with_fasta_column/subsamplesheet.csv new file mode 100644 index 00000000..446f9a91 --- /dev/null +++ b/tests/data/peps/pep_with_fasta_column/subsamplesheet.csv @@ -0,0 +1,8 @@ +sample,run_accession,fastq_1,fastq_2,fasta,strandedness +WT_REP1,runaccession1,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357076_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357076_2.fastq.gz,,reverse +WT_REP1,runaccession2,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357071_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357071_2.fastq.gz,,reverse +WT_REP2,123123123,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357072_1.fastq.gz,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357072_2.fastq.gz,,reverse +RAP1_UNINDUCED_REP1,somerunaccesion,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357073_1.fastq.gz,,,reverse +RAP1_UNINDUCED_REP2,ERR2412421,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357074_1.fastq.gz,,,reverse +RAP1_UNINDUCED_REP2,xxxxxxxxxx,https://raw.githubusercontent.com/nf-core/test-datasets/rnaseq/testdata/GSE110004/SRR6357075_1.fastq.gz,,,reverse +RAP1_IAA_30M_REP1,None,,,https://raw.githubusercontent.com/nf-core/test-datasets/taxprofiler/data/fasta/ERX5474930_ERR5766174_1.fa.gz,reverse diff --git a/tests/data/peps/test_file_existing/project_config.yaml b/tests/data/peps/test_file_existing/project_config.yaml new file mode 100644 index 00000000..23ebfee5 --- /dev/null +++ b/tests/data/peps/test_file_existing/project_config.yaml @@ -0,0 +1,12 @@ +pep_version: "2.1.0" +sample_table: sample_table.csv +subsample_table: subsample_table.csv + + +sample_modifiers: + append: + local_files: LOCAL + derive: + attributes: [local_files] + sources: + LOCAL: "../data/{file_path}" diff --git a/tests/data/peps/test_file_existing/sample_table.csv b/tests/data/peps/test_file_existing/sample_table.csv new file mode 100644 index 00000000..1137443a --- /dev/null +++ b/tests/data/peps/test_file_existing/sample_table.csv @@ -0,0 +1,5 @@ +sample_name,protocol,identifier +frog_1,anySampleType,frog1 +frog_2,anySampleType,frog2 +frog_3,anySampleType,frog3 +frog_4,anySampleType,frog4 diff --git a/tests/data/peps/test_file_existing/subsample_table.csv b/tests/data/peps/test_file_existing/subsample_table.csv new file mode 100644 index 00000000..1d4f9553 --- /dev/null +++ b/tests/data/peps/test_file_existing/subsample_table.csv @@ -0,0 +1,6 @@ +sample_name,file_path,subsample_name +frog_1,file/a.txt,a +frog_1,file/b.txt,b +frog_1,file/c.txt,c +frog_2,file/a.txt,a +frog_2,file/b.txt,b diff --git a/tests/data/peps/test_pep/test_cfg.yaml b/tests/data/peps/test_pep/test_cfg.yaml new file mode 100644 index 00000000..32f028d7 --- /dev/null +++ b/tests/data/peps/test_pep/test_cfg.yaml @@ -0,0 +1,10 @@ +name: test +pep_version: 2.0.0 +sample_table: test_sample_table.csv + +sample_modifiers: + imply: + - if: + organism: "Homo sapiens" + then: + genome: hg38 diff --git a/tests/data/peps/test_pep/test_sample_table.csv b/tests/data/peps/test_pep/test_sample_table.csv new file mode 100644 index 00000000..d2881012 --- /dev/null +++ b/tests/data/peps/test_pep/test_sample_table.csv @@ -0,0 +1,3 @@ +sample_name,protocol,genome +GSM1558746,GRO,hg38 +GSM1480327,PRO,hg38 diff --git a/tests/data/peps/value_check_pep/project_config.yaml b/tests/data/peps/value_check_pep/project_config.yaml new file mode 100644 index 00000000..66c4380c --- /dev/null +++ b/tests/data/peps/value_check_pep/project_config.yaml @@ -0,0 +1,6 @@ +description: None +name: encode_prj +pep_version: 2.0.0 +project_name: value_check_pep +sample_table: sample_table.csv +subsample_table: [] diff --git a/tests/data/peps/value_check_pep/sample_table.csv b/tests/data/peps/value_check_pep/sample_table.csv new file mode 100644 index 00000000..cefc2aa3 --- /dev/null +++ b/tests/data/peps/value_check_pep/sample_table.csv @@ -0,0 +1,7 @@ +sample_name,file_name,genome,assay,cell_line,target,format_type +encode_4,ENCFF452DAM.bed.gz,hg38,Histone ChIP-seq,skeletal muscle myoblast,H3K36me3,narrowPeak +encode_20,ENCFF121AXG.bed.gz,hg38,DNase-seq,RPMI7951,,tssPeak +encode_21,ENCFF710ECJ.bed.gz,hg38,DNase-seq,RPMI7951,,broadPeak +encode_22,ENCFF945FZN.bed.gz,hg38,DNase-seq,RPMI7951,,narrowPeak +encode_23,ENCFF322PQO.bed.gz,hg38,DNase-seq,RPMI7951,,tssPeak +encode_24,ENCFF322PQO.bed.gz,hg38,DNase-seq,RPMI7951,,tssPeak1 diff --git a/tests/data/schemas/schema_test_file_exist.yaml b/tests/data/schemas/schema_test_file_exist.yaml new file mode 100644 index 00000000..e1814b8d --- /dev/null +++ b/tests/data/schemas/schema_test_file_exist.yaml @@ -0,0 +1,35 @@ +description: test existing files in subsamples + +properties: + dcc: + type: object + properties: + compute_packages: + type: object + samples: + type: array + items: + type: object + properties: + sample_name: + type: string + protocol: + type: string + local_files: + anyOf: + - type: string + - type: array + items: + type: string + sizing: + - local_files + + tangible: + - local_files + + required: + - sample_name + - local_files + +required: + - samples diff --git a/tests/data/schemas/test_schema.yaml b/tests/data/schemas/test_schema.yaml new file mode 100644 index 00000000..13cdd1d6 --- /dev/null +++ b/tests/data/schemas/test_schema.yaml @@ -0,0 +1,22 @@ +description: test PEP schema + +properties: + dcc: + type: object + properties: + compute_packages: + type: object + samples: + type: array + items: + type: object + properties: + sample_name: + type: string + protocol: + type: string + genome: + type: string + +required: + - samples diff --git a/tests/data/schemas/test_schema_imports.yaml b/tests/data/schemas/test_schema_imports.yaml new file mode 100644 index 00000000..e48db5df --- /dev/null +++ b/tests/data/schemas/test_schema_imports.yaml @@ -0,0 +1,17 @@ +imports: + - http://schema.databio.org/pep/2.0.0.yaml +description: Schema for a more restrictive PEP +properties: + samples: + type: array + items: + type: object + properties: + my_numeric_attribute: + type: integer + minimum: 0 + maximum: 1 + required: + - my_numeric_attribute +required: + - samples diff --git a/tests/data/schemas/test_schema_invalid.yaml b/tests/data/schemas/test_schema_invalid.yaml new file mode 100644 index 00000000..715e8243 --- /dev/null +++ b/tests/data/schemas/test_schema_invalid.yaml @@ -0,0 +1,25 @@ +description: test PEP schema + +properties: + dcc: + type: object + properties: + compute_packages: + type: object + samples: + type: array + items: + type: object + properties: + sample_name: + type: string + protocol: + type: string + genome: + type: string + invalid: + type: string + +required: + - samples + - invalid diff --git a/tests/data/schemas/test_schema_invalid_with_type.yaml b/tests/data/schemas/test_schema_invalid_with_type.yaml new file mode 100644 index 00000000..9815bdae --- /dev/null +++ b/tests/data/schemas/test_schema_invalid_with_type.yaml @@ -0,0 +1,25 @@ +description: test PEP schema +type: object +properties: + dcc: + type: object + properties: + compute_packages: + type: object + samples: + type: array + items: + type: object + properties: + sample_name: + type: string + protocol: + type: string + genome: + type: string + invalid: + type: string + +required: + - samples + - invalid diff --git a/tests/data/schemas/test_schema_sample_invalid.yaml b/tests/data/schemas/test_schema_sample_invalid.yaml new file mode 100644 index 00000000..7b429c1e --- /dev/null +++ b/tests/data/schemas/test_schema_sample_invalid.yaml @@ -0,0 +1,26 @@ +description: test PEP schema + +properties: + dcc: + type: object + properties: + compute_packages: + type: object + samples: + type: array + items: + type: object + properties: + sample_name: + type: string + protocol: + type: string + genome: + type: string + newattr: + type: string + required: + - newattr + +required: + - samples diff --git a/tests/data/schemas/test_schema_samples.yaml b/tests/data/schemas/test_schema_samples.yaml new file mode 100644 index 00000000..13cdd1d6 --- /dev/null +++ b/tests/data/schemas/test_schema_samples.yaml @@ -0,0 +1,22 @@ +description: test PEP schema + +properties: + dcc: + type: object + properties: + compute_packages: + type: object + samples: + type: array + items: + type: object + properties: + sample_name: + type: string + protocol: + type: string + genome: + type: string + +required: + - samples diff --git a/tests/data/schemas/value_check_schema.yaml b/tests/data/schemas/value_check_schema.yaml new file mode 100644 index 00000000..fb2352dc --- /dev/null +++ b/tests/data/schemas/value_check_schema.yaml @@ -0,0 +1,16 @@ +description: bedboss run-all pep schema +properties: + samples: + items: + properties: + format_type: + description: whether the regions are narrow (transcription factor implies + narrow, histone mark implies broad peaks) + enum: + - narrowPeak + - broadPeak + type: string + type: object + type: array +required: +- samples diff --git a/tests/test_Project.py b/tests/test_Project.py index d4112ac6..60313259 100644 --- a/tests/test_Project.py +++ b/tests/test_Project.py @@ -1,15 +1,13 @@ """ Classes for peppy.Project smoketesting """ import os +import pickle import socket import tempfile import numpy as np import pytest from pandas import DataFrame -from yaml import dump, safe_load -import pickle - from peppy import Project from peppy.const import SAMPLE_NAME_ATTR, SAMPLE_TABLE_FILE_KEY from peppy.exceptions import ( @@ -18,6 +16,7 @@ MissingAmendmentError, RemoteYAMLError, ) +from yaml import dump, safe_load __author__ = "Michal Stolarczyk" __email__ = "michal.stolarczyk@nih.gov" @@ -38,6 +37,7 @@ "subtable4", "subtable5", "remove", + "issue499", ] @@ -577,6 +577,15 @@ def test_derive(self, example_pep_cfg_path): p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) _cmp_all_samples_attr(p, pd, "file_path") + @pytest.mark.parametrize("example_pep_cfg_path", ["issue499"], indirect=True) + def test_issue499(self, example_pep_cfg_path): + """ + Verify that the derivation the same way in a post init + sample creation scenario + """ + p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) + _cmp_all_samples_attr(p, pd, "file_path") + @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) def test_equality(self, example_pep_cfg_path): """ diff --git a/tests/test_validations.py b/tests/test_validations.py new file mode 100644 index 00000000..3ffb6fc0 --- /dev/null +++ b/tests/test_validations.py @@ -0,0 +1,53 @@ +import urllib + +import pytest +from peppy import Project +from peppy.eido.exceptions import EidoValidationError, PathAttrNotFoundError +from peppy.eido.validation import validate_project +from peppy.utils import load_yaml + + +def _check_remote_file_accessible(url): + try: + code = urllib.request.urlopen(url).getcode() + except: + pytest.skip(f"Remote file not found: {url}") + else: + if code != 200: + pytest.skip(f"Return code: {code}. Remote file not found: {url}") + + +class TestProjectValidation: + def test_validate_works(self, project_object, schema_file_path): + validate_project(project=project_object, schema=schema_file_path) + + def test_validate_detects_invalid(self, project_object, schema_invalid_file_path): + with pytest.raises(EidoValidationError): + validate_project(project=project_object, schema=schema_invalid_file_path) + + def test_validate_detects_invalid_imports( + self, project_object, schema_imports_file_path + ): + with pytest.raises(EidoValidationError): + validate_project(project=project_object, schema=schema_imports_file_path) + + def test_validate_converts_samples_to_private_attr( + self, project_object, schema_samples_file_path + ): + """ + In peppy.Project the list of peppy.Sample objects is + accessible via _samples attr. + To make the schema creation more accessible for eido users + samples->_samples key conversion has been implemented + """ + validate_project(project=project_object, schema=schema_samples_file_path) + + def test_validate_works_with_dict_schema(self, project_object, schema_file_path): + validate_project(project=project_object, schema=load_yaml(schema_file_path)) + + @pytest.mark.parametrize("schema_arg", [1, None, [1, 2, 3]]) + def test_validate_raises_error_for_incorrect_schema_type( + self, project_object, schema_arg + ): + with pytest.raises(TypeError): + validate_project(project=project_object, schema=schema_arg) From 1f97bf800bb60e6b4551ff87eb45b26f43d3f0c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Oct 2025 14:26:08 -0400 Subject: [PATCH 105/165] merge eido tests --- peppy/eido/conversion.py | 122 ++++++++++++++++++++++++++++++++ peppy/eido/exceptions.py | 7 ++ tests/conftest.py | 98 +++++++++++++++++++++++++ tests/test_conversions.py | 106 +++++++++++++++++++++++++++ tests/test_schema_operations.py | 15 ++++ tests/test_validations.py | 106 ++++++++++++++++++++++++++- 6 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 peppy/eido/conversion.py create mode 100644 tests/test_conversions.py create mode 100644 tests/test_schema_operations.py diff --git a/peppy/eido/conversion.py b/peppy/eido/conversion.py new file mode 100644 index 00000000..a55f83e4 --- /dev/null +++ b/peppy/eido/conversion.py @@ -0,0 +1,122 @@ +import sys + +if sys.version_info < (3, 10): + from importlib_metadata import entry_points +else: + from importlib.metadata import entry_points + +import inspect +import os +from logging import getLogger +from typing import NoReturn + +from .exceptions import EidoFilterError + +_LOGGER = getLogger(__name__) + + +def pep_conversion_plugins(): + """ + Plugins registered by entry points in the current Python env + + :return dict[dict[function(peppy.Project)]]: dict which keys + are names of all possible hooks and values are dicts mapping + registered functions names to their values + :raise EidoFilterError: if any of the filters has an invalid signature. + """ + plugins = {} + for ep in entry_points(group="pep.filters"): + plugin_fun = ep.load() + if len(list(inspect.signature(plugin_fun).parameters)) != 2: + raise EidoFilterError( + f"Invalid filter plugin signature: {ep.name}. " + f"Filter functions must take 2 arguments: peppy.Project and **kwargs" + ) + plugins[ep.name] = plugin_fun + return plugins + + +def convert_project(prj, target_format, plugin_kwargs=None): + """ + Convert a `peppy.Project` object to a selected format + + :param peppy.Project prj: a Project object to convert + :param dict plugin_kwargs: kwargs to pass to the plugin function + :param str target_format: the format to convert the Project object to + :raise EidoFilterError: if the requested filter is not defined + """ + return run_filter(prj, target_format, plugin_kwargs=plugin_kwargs or dict()) + + +def run_filter(prj, filter_name, verbose=True, plugin_kwargs=None): + """ + Run a selected filter on a peppy.Project object + + :param peppy.Project prj: a Project to run filter on + :param str filter_name: name of the filter to run + :param dict plugin_kwargs: kwargs to pass to the plugin function + :raise EidoFilterError: if the requested filter is not defined + """ + # convert to empty dictionary if no plugin_kwargs are passed + plugin_kwargs = plugin_kwargs or dict() + + # get necessary objects + installed_plugins = pep_conversion_plugins() + installed_plugin_names = list(installed_plugins.keys()) + paths = plugin_kwargs.get("paths") + env = plugin_kwargs.get("env") + + # set environment + if env is not None: + for var in env: + os.environ[var] = env[var] + + # check for valid filter + if filter_name not in installed_plugin_names: + raise EidoFilterError( + f"Requested filter ({filter_name}) not found. " + f"Available: {', '.join(installed_plugin_names)}" + ) + _LOGGER.info(f"Running plugin {filter_name}") + func = installed_plugins[filter_name] + + # run filter + conv_result = func(prj, **plugin_kwargs) + + # if paths supplied, write to disk + if paths is not None: + # map conversion result to the + # specified path + for result_key in conv_result: + result_path = paths.get(result_key) + if result_path is None: + _LOGGER.warning( + f"Conversion plugin returned key that doesn't exist in specified paths: '{result_key}'." + ) + else: + # create path if it doesn't exist + if not os.path.exists(result_path) and os.path.isdir( + os.path.dirname(result_path) + ): + os.makedirs(os.path.dirname(result_path), exist_ok=True) + save_result(result_path, conv_result[result_key]) + + if verbose: + for result_key in conv_result: + sys.stdout.write(conv_result[result_key]) + + return conv_result + + +def save_result(result_path: str, content: str) -> NoReturn: + with open(result_path, "w") as f: + f.write(content) + + +def get_available_pep_filters(): + """ + Get a list of available target formats + + :return List[str]: a list of available formats + """ + return list(pep_conversion_plugins().keys()) diff --git a/peppy/eido/exceptions.py b/peppy/eido/exceptions.py index dc314a69..36e47fb5 100644 --- a/peppy/eido/exceptions.py +++ b/peppy/eido/exceptions.py @@ -33,3 +33,10 @@ def __init__(self, message, errors_by_type): def __str__(self): return f"EidoValidationError ({self.message}): {self.errors_by_type}" + + +class EidoFilterError(EidoException): + """Issue with the PEP filter.""" + + def __init__(self, key): + super(EidoFilterError, self).__init__(key) diff --git a/tests/conftest.py b/tests/conftest.py index ffd9107d..b67d02b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -111,6 +111,104 @@ def schema_invalid_file_path(schemas_path): return os.path.join(schemas_path, "test_schema_invalid.yaml") +@pytest.fixture +def schema_sample_invalid_file_path(schemas_path): + return os.path.join(schemas_path, "test_schema_sample_invalid.yaml") + + @pytest.fixture def schema_imports_file_path(schemas_path): return os.path.join(schemas_path, "test_schema_imports.yaml") + + +@pytest.fixture +def taxprofiler_project_path(peps_path): + return os.path.join(peps_path, "multiline_output", "config.yaml") + + +@pytest.fixture +def taxprofiler_project(taxprofiler_project_path): + return Project(taxprofiler_project_path) + + +@pytest.fixture +def path_to_taxprofiler_csv_multiline_output(peps_path): + return os.path.join(peps_path, "multiline_output", "multiline_output.csv") + + +@pytest.fixture +def path_pep_with_fasta_column(peps_path): + return os.path.join(peps_path, "pep_with_fasta_column", "config.yaml") + + +@pytest.fixture +def project_pep_with_fasta_column(path_pep_with_fasta_column): + return Project(path_pep_with_fasta_column, sample_table_index="sample") + + +@pytest.fixture +def output_pep_with_fasta_column(path_pep_with_fasta_column): + with open( + os.path.join(os.path.dirname(path_pep_with_fasta_column), "output.csv") + ) as f: + return f.read() + + +@pytest.fixture +def taxprofiler_csv_multiline_output(path_to_taxprofiler_csv_multiline_output): + with open(path_to_taxprofiler_csv_multiline_output, "r") as file: + data = file.read() + return data + # This is broken unless I add na_filter=False. But it's a bad idea anyway, since + # we're just using this for string comparison anyway... + return pd.read_csv( + path_to_taxprofiler_csv_multiline_output, na_filter=False + ).to_csv(path_or_buf=None, index=None) + + +@pytest.fixture +def path_pep_nextflow_taxprofiler(peps_path): + return os.path.join(peps_path, "pep_nextflow_taxprofiler", "config.yaml") + + +@pytest.fixture +def project_pep_nextflow_taxprofiler(path_pep_nextflow_taxprofiler): + return Project(path_pep_nextflow_taxprofiler, sample_table_index="sample") + + +@pytest.fixture +def output_pep_nextflow_taxprofiler(path_pep_nextflow_taxprofiler): + with open( + os.path.join(os.path.dirname(path_pep_nextflow_taxprofiler), "output.csv") + ) as f: + return f.read() + + +@pytest.fixture +def save_result_mock(mocker): + return mocker.patch("peppy.eido.conversion.save_result") + + +@pytest.fixture +def test_file_existing_schema(schemas_path): + return os.path.join(schemas_path, "schema_test_file_exist.yaml") + + +@pytest.fixture +def test_file_existing_pep(peps_path): + return os.path.join(peps_path, "test_file_existing", "project_config.yaml") + + +@pytest.fixture +def test_schema_value_check(schemas_path): + return os.path.join(schemas_path, "value_check_schema.yaml") + + +@pytest.fixture +def test_file_value_check(peps_path): + return os.path.join(peps_path, "value_check_pep", "project_config.yaml") + + +@pytest.fixture +def test_multiple_subs(peps_path): + return os.path.join(peps_path, "multiple_subsamples", "project_config.yaml") diff --git a/tests/test_conversions.py b/tests/test_conversions.py new file mode 100644 index 00000000..a27310b0 --- /dev/null +++ b/tests/test_conversions.py @@ -0,0 +1,106 @@ +from peppy.eido.conversion import ( + convert_project, + get_available_pep_filters, + pep_conversion_plugins, + run_filter, +) +from peppy.project import Project + + +class TestConversionInfrastructure: + def test_plugins_are_read(self): + avail_filters = get_available_pep_filters() + assert isinstance(avail_filters, list) + + def test_plugins_contents(self): + avail_plugins = pep_conversion_plugins() + avail_filters = get_available_pep_filters() + assert all( + [plugin_name in avail_filters for plugin_name in avail_plugins.keys()] + ) + + def test_plugins_are_callable(self): + avail_plugins = pep_conversion_plugins() + assert all( + [callable(plugin_fun) for plugin_name, plugin_fun in avail_plugins.items()] + ) + + def test_basic_filter(self, save_result_mock, project_object): + conv_result = run_filter( + project_object, + "basic", + verbose=False, + plugin_kwargs={"paths": {"project": "out/basic_prj.txt"}}, + ) + + assert save_result_mock.called + assert conv_result["project"] == str(project_object) + + def test_csv_filter( + self, save_result_mock, taxprofiler_project, taxprofiler_csv_multiline_output + ): + conv_result = run_filter( + taxprofiler_project, + "csv", + verbose=False, + plugin_kwargs={"paths": {"samples": "out/basic_prj.txt"}}, + ) + + assert save_result_mock.called + assert conv_result["samples"] == taxprofiler_csv_multiline_output + + def test_csv_filter_handles_empty_fasta_correclty( + self, + project_pep_with_fasta_column, + output_pep_with_fasta_column, + save_result_mock, + ): + conv_result = run_filter( + project_pep_with_fasta_column, + "csv", + verbose=False, + plugin_kwargs={"paths": {"samples": "out/basic_prj.txt"}}, + ) + + assert save_result_mock.called + assert conv_result == {"samples": output_pep_with_fasta_column} + + def test_eido_csv_filter_filters_nextflow_taxprofiler_input_correctly( + self, + project_pep_nextflow_taxprofiler, + output_pep_nextflow_taxprofiler, + save_result_mock, + ): + conv_result = run_filter( + project_pep_nextflow_taxprofiler, + "csv", + verbose=False, + plugin_kwargs={"paths": {"samples": "out/basic_prj.txt"}}, + ) + + assert save_result_mock.called + assert conv_result == {"samples": output_pep_nextflow_taxprofiler} + + def test_multiple_subsamples(self, test_multiple_subs): + project = Project(test_multiple_subs, sample_table_index="sample_id") + + conversion = convert_project( + project, + "csv", + ) + assert isinstance(conversion["samples"], str) + conversion = convert_project( + project, + "basic", + ) + assert isinstance(conversion["project"], str) + conversion = convert_project( + project, + "yaml", + ) + assert isinstance(conversion["project"], str) + conversion = convert_project( + project, + "yaml-samples", + ) + assert isinstance(conversion["samples"], str) diff --git a/tests/test_schema_operations.py b/tests/test_schema_operations.py new file mode 100644 index 00000000..af0beb87 --- /dev/null +++ b/tests/test_schema_operations.py @@ -0,0 +1,15 @@ +from peppy.eido.schema import read_schema +from yaml import safe_load + + +class TestSchemaReading: + def test_imports_file_schema(self, schema_imports_file_path): + s = read_schema(schema_imports_file_path) + assert isinstance(s, list) + assert len(s) == 2 + + def test_imports_dict_schema(self, schema_imports_file_path): + with open(schema_imports_file_path, "r") as f: + s = read_schema(safe_load(f)) + assert isinstance(s, list) + assert len(s) == 2 diff --git a/tests/test_validations.py b/tests/test_validations.py index 3ffb6fc0..46fc41bb 100644 --- a/tests/test_validations.py +++ b/tests/test_validations.py @@ -3,7 +3,12 @@ import pytest from peppy import Project from peppy.eido.exceptions import EidoValidationError, PathAttrNotFoundError -from peppy.eido.validation import validate_project +from peppy.eido.validation import ( + validate_config, + validate_input_files, + validate_project, + validate_sample, +) from peppy.utils import load_yaml @@ -51,3 +56,102 @@ def test_validate_raises_error_for_incorrect_schema_type( ): with pytest.raises(TypeError): validate_project(project=project_object, schema=schema_arg) + + +class TestSampleValidation: + @pytest.mark.parametrize("sample_name", [0, 1, "GSM1558746"]) + def test_validate_works( + self, project_object, sample_name, schema_samples_file_path + ): + validate_sample( + project=project_object, + sample_name=sample_name, + schema=schema_samples_file_path, + ) + + @pytest.mark.parametrize("sample_name", [22, "bogus_sample_name"]) + def test_validate_raises_error_for_incorrect_sample_name( + self, project_object, sample_name, schema_samples_file_path + ): + with pytest.raises((ValueError, IndexError)): + validate_sample( + project=project_object, + sample_name=sample_name, + schema=schema_samples_file_path, + ) + + @pytest.mark.parametrize("sample_name", [0, 1, "GSM1558746"]) + def test_validate_detects_invalid( + self, project_object, sample_name, schema_sample_invalid_file_path + ): + with pytest.raises(EidoValidationError): + validate_sample( + project=project_object, + sample_name=sample_name, + schema=schema_sample_invalid_file_path, + ) + + +class TestConfigValidation: + def test_validate_succeeds_on_invalid_sample( + self, project_object, schema_sample_invalid_file_path + ): + validate_config(project=project_object, schema=schema_sample_invalid_file_path) + + +class TestRemoteValidation: + @pytest.mark.parametrize("schema_url", ["http://schema.databio.org/pep/2.0.0.yaml"]) + def test_validate_works_with_remote_schemas(self, project_object, schema_url): + _check_remote_file_accessible(schema_url) + validate_project(project=project_object, schema=schema_url) + validate_config(project=project_object, schema=schema_url) + validate_sample(project=project_object, schema=schema_url, sample_name=0) + + +class TestImportsValidation: + def test_validate(self, project_object, schema_file_path): + validate_project(project=project_object, schema=schema_file_path) + + +class TestProjectWithoutConfigValidation: + @pytest.mark.parametrize( + "remote_pep_cfg", + [ + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/sample_table.csv" + ], + ) + def test_validate_works(self, schema_file_path, remote_pep_cfg): + _check_remote_file_accessible(remote_pep_cfg) + validate_project( + project=Project( + remote_pep_cfg + ), # create Project object from a remote sample table + schema=schema_file_path, + ) + + @pytest.mark.parametrize( + "remote_pep_cfg", + [ + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/sample_table.csv" + ], + ) + def test_validate_detects_invalid(self, schema_invalid_file_path, remote_pep_cfg): + _check_remote_file_accessible(remote_pep_cfg) + with pytest.raises(EidoValidationError): + validate_project( + project=Project(remote_pep_cfg), schema=schema_invalid_file_path + ) + + def test_validate_file_existance( + self, test_file_existing_pep, test_file_existing_schema + ): + schema_path = test_file_existing_schema + prj = Project(test_file_existing_pep) + with pytest.raises(PathAttrNotFoundError): + validate_input_files(prj, schema_path) + + def test_validation_values(self, test_schema_value_check, test_file_value_check): + schema_path = test_schema_value_check + prj = Project(test_file_value_check) + with pytest.raises(EidoValidationError): + validate_project(project=prj, schema=schema_path) From b2898daed28a4eca320f6565a27f5a6b23e43861 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Oct 2025 13:22:47 -0400 Subject: [PATCH 106/165] validate original sample & pre-processed project --- peppy/eido/validation.py | 34 +++++++++++++++++++++++++++++++++- tests/conftest.py | 5 +++++ tests/test_validations.py | 11 +++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py index fd604952..4cac91af 100644 --- a/peppy/eido/validation.py +++ b/peppy/eido/validation.py @@ -4,11 +4,13 @@ from typing import Mapping, NoReturn, Union from warnings import warn +import pandas as pd from jsonschema import Draft7Validator from pandas.core.common import flatten from ..project import Project from ..sample import Sample +from ..utils import load_yaml from .const import PROP_KEY, SAMPLES_KEY, SIZING_KEY, TANGIBLE_KEY from .exceptions import EidoValidationError, PathAttrNotFoundError from .schema import preprocess_schema, read_schema @@ -120,7 +122,7 @@ def validate_sample( def validate_config( - project: Union[Project, dict], schema: Union[str, dict] + project: Union[Project, dict, str], schema: Union[str, dict] ) -> NoReturn: """ Validate the config part of the Project object against a schema @@ -143,6 +145,14 @@ def validate_config( if isinstance(project, dict): _validate_object({"project": project}, schema_cpy) + elif isinstance(project, str): + try: + project_dict = load_yaml(project) + except: + raise ValueError( + f"Please provide a valid yaml config of PEP project; invalid config path: {project}" + ) + _validate_object({"project": project_dict}, schema_cpy) else: project_dict = project.to_dict() _validate_object(project_dict, schema_cpy) @@ -235,3 +245,25 @@ def validate_input_files( f"For sample '{getattr(sample, project.sample_table_index)}'. " f"Required inputs not found: {required_inputs}" ) + + +def validate_original_samples( + samples: Union[str, pd.DataFrame], schema: Union[str, dict] +): + """ + Validate the original samples from the csv table against a schema + + :param samples: the path to the sample table csv or the dataframe fom the table + :param str | dict schema: schema dict to validate against or a path to one + + :raises EidoValidationError: if validation is unsuccessful + """ + if isinstance(samples, str): + samples = pd.read_csv(samples) + + assist_project = Project.from_pandas(samples) + for s in assist_project.samples: + _validate_sample_object( + sample=s, + schemas=read_schema(schema=schema), + ) diff --git a/tests/conftest.py b/tests/conftest.py index b67d02b1..669d336b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -91,6 +91,11 @@ def project_file_path(peps_path): return os.path.join(peps_path, "test_pep", "test_cfg.yaml") +@pytest.fixture +def project_table_path(peps_path): + return os.path.join(peps_path, "test_pep", "test_sample_table.csv") + + @pytest.fixture def project_object(project_file_path): return Project(project_file_path) diff --git a/tests/test_validations.py b/tests/test_validations.py index 46fc41bb..ecdbf0d4 100644 --- a/tests/test_validations.py +++ b/tests/test_validations.py @@ -6,6 +6,7 @@ from peppy.eido.validation import ( validate_config, validate_input_files, + validate_original_samples, validate_project, validate_sample, ) @@ -91,6 +92,9 @@ def test_validate_detects_invalid( schema=schema_sample_invalid_file_path, ) + def test_original_sample(self, project_table_path, schema_samples_file_path): + validate_original_samples(project_table_path, schema_samples_file_path) + class TestConfigValidation: def test_validate_succeeds_on_invalid_sample( @@ -98,6 +102,13 @@ def test_validate_succeeds_on_invalid_sample( ): validate_config(project=project_object, schema=schema_sample_invalid_file_path) + def test_validate_on_yaml_dict( + self, project_file_path, schema_sample_invalid_file_path + ): + validate_config( + project=project_file_path, schema=schema_sample_invalid_file_path + ) + class TestRemoteValidation: @pytest.mark.parametrize("schema_url", ["http://schema.databio.org/pep/2.0.0.yaml"]) From fe20e869c90bf68dda7e2af419259e9eff25220f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Oct 2025 16:50:41 -0400 Subject: [PATCH 107/165] merge CLI of eido into peppy --- peppy/cli.py | 175 ++++++++++++++++++++++++++++++ peppy/eido/argparser.py | 176 +++++++++++++++++++++++++++++++ peppy/eido/conversion_plugins.py | 85 +++++++++++++++ peppy/eido/inspection.py | 97 +++++++++++++++++ peppy/eido/output_formatters.py | 126 ++++++++++++++++++++++ setup.py | 9 ++ 6 files changed, 668 insertions(+) create mode 100644 peppy/cli.py create mode 100644 peppy/eido/argparser.py create mode 100644 peppy/eido/conversion_plugins.py create mode 100644 peppy/eido/inspection.py create mode 100644 peppy/eido/output_formatters.py diff --git a/peppy/cli.py b/peppy/cli.py new file mode 100644 index 00000000..349e81f9 --- /dev/null +++ b/peppy/cli.py @@ -0,0 +1,175 @@ +import logging +import sys + +from logmuse import init_logger + +from .const import PKG_NAME +from .eido.argparser import LEVEL_BY_VERBOSITY, build_argparser +from .eido.const import CONVERT_CMD, INSPECT_CMD, LOGGING_LEVEL, VALIDATE_CMD +from .eido.conversion import ( + convert_project, + get_available_pep_filters, + pep_conversion_plugins, +) +from .eido.exceptions import EidoFilterError, EidoValidationError +from .eido.inspection import inspect_project +from .eido.validation import validate_config, validate_project, validate_sample +from .project import Project + + +def _parse_filter_args_str(input): + """ + Parse user input specification. + + :param Iterable[Iterable[str]] input: user command line input, + formatted as follows: [[arg=txt, arg1=txt]] + :return dict: mapping of keys, which are input names and values + """ + lst = [] + for i in input or []: + lst.extend(i) + return ( + {x.split("=")[0]: x.split("=")[1] for x in lst if "=" in x} + if lst is not None + else lst + ) + + +def print_error_summary(errors_by_type): + """Print a summary of errors, organized by error type""" + n_error_types = len(errors_by_type) + print(f"Found {n_error_types} types of error:") + for type in errors_by_type: + n = len(errors_by_type[type]) + msg = f" - {type}: ({n} samples) " + if n < 50: + msg += ", ".join([x["sample_name"] for x in errors_by_type[type]]) + print(msg) + + if len(errors_by_type) > 1: + final_msg = f"Validation unsuccessful. {len(errors_by_type)} error types found." + else: + final_msg = f"Validation unsuccessful. {len(errors_by_type)} error type found." + + print(final_msg) + return final_msg + + +def main(): + """Primary workflow""" + parser, sps = build_argparser() + args, remaining_args = parser.parse_known_args() + + if args.command is None: + parser.print_help(sys.stderr) + sys.exit(1) + + # Set the logging level. + if args.dbg: + # Debug mode takes precedence and will listen for all messages. + level = args.logging_level or logging.DEBUG + elif args.verbosity is not None: + # Verbosity-framed specification trumps logging_level. + level = LEVEL_BY_VERBOSITY[args.verbosity] + else: + # Normally, we're not in debug mode, and there's not verbosity. + level = LOGGING_LEVEL + + logger_kwargs = {"level": level, "devmode": args.dbg} + # init_logger(name="peppy", **logger_kwargs) + global _LOGGER + _LOGGER = init_logger(name=PKG_NAME, **logger_kwargs) + + if args.command == CONVERT_CMD: + filters = get_available_pep_filters() + if args.list: + _LOGGER.info("Available filters:") + if len(filters) < 1: + _LOGGER.info("No available filters") + for filter_name in filters: + _LOGGER.info(f" - {filter_name}") + sys.exit(0) + if not "format" in args: + _LOGGER.info("The following arguments are required: --format") + sps[CONVERT_CMD].print_help(sys.stderr) + sys.exit(1) + if args.describe: + if args.format not in filters: + raise EidoFilterError( + f"'{args.format}' filter not found. Available filters: {', '.join(filters)}" + ) + filter_functions_by_name = pep_conversion_plugins() + print(filter_functions_by_name[args.format].__doc__) + sys.exit(0) + if args.pep is None: + sps[CONVERT_CMD].print_help(sys.stderr) + _LOGGER.info("The following arguments are required: PEP") + sys.exit(1) + if args.paths: + paths = {y[0]: y[1] for y in [x.split("=") for x in args.paths]} + else: + paths = None + + p = Project( + args.pep, + sample_table_index=args.st_index, + subsample_table_index=args.sst_index, + amendments=args.amendments, + ) + plugin_kwargs = _parse_filter_args_str(args.args) + + # append paths + plugin_kwargs["paths"] = paths + + convert_project(p, args.format, plugin_kwargs) + _LOGGER.info("Conversion successful") + sys.exit(0) + + _LOGGER.debug(f"Creating a Project object from: {args.pep}") + if args.command == VALIDATE_CMD: + p = Project( + args.pep, + sample_table_index=args.st_index, + subsample_table_index=args.sst_index, + amendments=args.amendments, + ) + if args.sample_name: + try: + args.sample_name = int(args.sample_name) + except ValueError: + pass + _LOGGER.debug( + f"Comparing Sample ('{args.pep}') in Project ('{args.pep}') " + f"against a schema: {args.schema}" + ) + validator = validate_sample + arguments = [p, args.sample_name, args.schema] + elif args.just_config: + _LOGGER.debug( + f"Comparing Project ('{args.pep}') against a schema: {args.schema}" + ) + validator = validate_config + arguments = [p, args.schema] + else: + _LOGGER.debug( + f"Comparing Project ('{args.pep}') against a schema: {args.schema}" + ) + validator = validate_project + arguments = [p, args.schema] + try: + validator(*arguments) + except EidoValidationError as e: + print_error_summary(e.errors_by_type) + return False + _LOGGER.info("Validation successful") + sys.exit(0) + + if args.command == INSPECT_CMD: + p = Project( + args.pep, + sample_table_index=args.st_index, + subsample_table_index=args.sst_index, + amendments=args.amendments, + ) + inspect_project(p, args.sample_name, args.attr_limit) + sys.exit(0) diff --git a/peppy/eido/argparser.py b/peppy/eido/argparser.py new file mode 100644 index 00000000..47c1420c --- /dev/null +++ b/peppy/eido/argparser.py @@ -0,0 +1,176 @@ +from logging import CRITICAL, DEBUG, ERROR, INFO, WARN + +from ubiquerg import VersionInHelpParser + +from .._version import __version__ +from ..const import PKG_NAME, SAMPLE_NAME_ATTR +from .const import CONVERT_CMD, INSPECT_CMD, SUBPARSER_MSGS, VALIDATE_CMD + +LEVEL_BY_VERBOSITY = [ERROR, CRITICAL, WARN, INFO, DEBUG] + + +def build_argparser(): + banner = "%(prog)s - Interact with PEPs" + additional_description = "\nhttp://eido.databio.org/" + + parser = VersionInHelpParser( + prog=PKG_NAME, + description=banner, + epilog=additional_description, + version=__version__, + ) + + subparsers = parser.add_subparsers(dest="command") + parser.add_argument( + "--verbosity", + dest="verbosity", + type=int, + choices=range(len(LEVEL_BY_VERBOSITY)), + help="Choose level of verbosity (default: %(default)s)", + ) + parser.add_argument("--logging-level", dest="logging_level", help="logging level") + parser.add_argument( + "--dbg", + dest="dbg", + action="store_true", + help="Turn on debug mode (default: %(default)s)", + ) + sps = {} + for cmd, desc in SUBPARSER_MSGS.items(): + subparser = subparsers.add_parser(cmd, description=desc, help=desc) + subparser.add_argument( + "--st-index", + required=False, + type=str, + default=SAMPLE_NAME_ATTR, + help=f"Sample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.", + ) + subparser.add_argument( + "--sst-index", + required=False, + type=str, + default=SAMPLE_NAME_ATTR, + help=f"Subsample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.", + ) + subparser.add_argument( + "--amendments", + required=False, + type=str, + nargs="+", + help=f"Names of the amendments to activate.", + ) + if cmd != CONVERT_CMD: + subparser.add_argument( + "pep", + metavar="PEP", + help="Path to a PEP configuration file in yaml format.", + default=None, + ) + else: + subparser.add_argument( + "pep", + metavar="PEP", + nargs="?", + help="Path to a PEP configuration file in yaml format.", + default=None, + ) + + sps[cmd] = subparser + + sps[VALIDATE_CMD].add_argument( + "-s", + "--schema", + required=True, + help="Path to a PEP schema file in yaml format.", + metavar="S", + ) + + sps[INSPECT_CMD].add_argument( + "-n", + "--sample-name", + required=False, + nargs="+", + help="Name of the samples to inspect.", + metavar="SN", + ) + + sps[INSPECT_CMD].add_argument( + "-l", + "--attr-limit", + required=False, + type=int, + default=10, + help="Number of sample attributes to display.", + ) + + group = sps[VALIDATE_CMD].add_mutually_exclusive_group() + + group.add_argument( + "-n", + "--sample-name", + required=False, + help="Name or index of the sample to validate. " + "Only this sample will be validated.", + metavar="S", + ) + + group.add_argument( + "-c", + "--just-config", + required=False, + action="store_true", + default=False, + help="Whether samples should be excluded from the validation.", + ) + + sps[CONVERT_CMD].add_argument( + "-f", + "--format", + required=False, + default="yaml", + help="Output format (name of filter; use -l to see available).", + ) + + sps[CONVERT_CMD].add_argument( + "-n", + "--sample-name", + required=False, + nargs="+", + help="Name of the samples to inspect.", + ) + + sps[CONVERT_CMD].add_argument( + "-a", + "--args", + nargs="+", + action="append", + required=False, + default=None, + help="Provide arguments to the filter function (e.g. arg1=val1 arg2=val2).", + ) + + sps[CONVERT_CMD].add_argument( + "-l", + "--list", + required=False, + default=False, + action="store_true", + help="List available filters.", + ) + + sps[CONVERT_CMD].add_argument( + "-d", + "--describe", + required=False, + default=False, + action="store_true", + help="Show description for a given filter.", + ) + + sps[CONVERT_CMD].add_argument( + "-p", + "--paths", + nargs="+", + help="Paths to dump conversion result as key=value pairs.", + ) + return parser, sps diff --git a/peppy/eido/conversion_plugins.py b/peppy/eido/conversion_plugins.py new file mode 100644 index 00000000..b1c199c2 --- /dev/null +++ b/peppy/eido/conversion_plugins.py @@ -0,0 +1,85 @@ +""" built-in PEP filters """ + +from typing import Dict + +from .output_formatters import MultilineOutputFormatter + + +def basic_pep_filter(p, **kwargs) -> Dict[str, str]: + """ + Basic PEP filter, that does not convert the Project object. + + This filter can save the PEP representation to file, if kwargs include `path`. + + :param peppy.Project p: a Project to run filter on + """ + return {"project": str(p)} + + +def yaml_samples_pep_filter(p, **kwargs) -> Dict[str, str]: + """ + YAML samples PEP filter, that returns only Sample object representations. + + This filter can save the YAML to file, if kwargs include `path`. + + :param peppy.Project p: a Project to run filter on + """ + from yaml import dump + + samples_yaml = [] + for s in p.samples: + samples_yaml.append(s.to_dict()) + + return {"samples": dump(samples_yaml, default_flow_style=False)} + + +def yaml_pep_filter(p, **kwargs) -> Dict[str, str]: + """ + YAML PEP filter, that returns Project object representation. + + This filter can save the YAML to file, if kwargs include `path`. + + :param peppy.Project p: a Project to run filter on + """ + from yaml import dump + + return {"project": dump(p.config, default_flow_style=False)} + + +def csv_pep_filter(p, **kwargs) -> Dict[str, str]: + """ + CSV PEP filter, that returns Sample object representations + + This filter can save the CSVs to files, if kwargs include + `sample_table_path` and/or `subsample_table_path`. + + :param peppy.Project p: a Project to run filter on + """ + return {"samples": MultilineOutputFormatter.format(p.samples)} + + +def processed_pep_filter(p, **kwargs) -> Dict[str, str]: + """ + Processed PEP filter, that returns the converted sample and subsample tables. + This filter can return the tables as a table or a document. + :param peppy.Project p: a Project to run filter on + :param bool samples_as_objects: Flag to write as a table + :param bool subsamples_as_objects: Flag to write as a table + """ + # get params + samples_as_objects = kwargs.get("samples_as_objects") + subsamples_as_objects = kwargs.get("subsamples_as_objects") + + prj_repr = p.config + + return { + "project": str(prj_repr), + "samples": ( + str(p.samples) if samples_as_objects else str(p.sample_table.to_csv()) + ), + "subsamples": ( + str(p.subsamples) + if subsamples_as_objects + else str(p.subsample_table.to_csv()) + ), + } diff --git a/peppy/eido/inspection.py b/peppy/eido/inspection.py new file mode 100644 index 00000000..1545362f --- /dev/null +++ b/peppy/eido/inspection.py @@ -0,0 +1,97 @@ +import os +from logging import getLogger +from warnings import catch_warnings + +from ubiquerg import size + +from .const import ( + ALL_INPUTS_KEY, + INPUT_FILE_SIZE_KEY, + MISSING_KEY, + PROP_KEY, + REQUIRED_INPUTS_KEY, + SAMPLES_KEY, + SIZING_KEY, + TANGIBLE_KEY, +) +from .schema import read_schema +from .validation import _get_attr_values, _validate_sample_object + +_LOGGER = getLogger(__name__) + + +def inspect_project(p, sample_names=None, max_attr=10): + """ + Print inspection info: Project or, + if sample_names argument is provided, matched samples + + :param peppy.Project p: project to inspect + :param Iterable[str] sample_names: list of samples to inspect + :param int max_attr: max number of sample attributes to display + """ + if sample_names: + samples = p.get_samples(sample_names) + if not samples: + print("No samples matched by names: {}".format(sample_names)) + return + for s in samples: + print(s.__str__(max_attr=max_attr)) + print("\n") + return + print(p) + return + + +def get_input_files_size(sample, schema): + """ + Determine which of this Sample's required attributes/files are missing + and calculate sizes of the files (inputs). + + The names of the attributes that are required and/or deemed as inputs + are sourced from the schema, more specifically from required_input_attrs + and input_attrs sections in samples section. Note, this function does + perform actual Sample object validation with jsonschema. + + :param peppy.Sample sample: sample to investigate + :param list[dict] | str schema: schema dict to validate against or a path to one + :return dict: dictionary with validation data, i.e missing, + required_inputs, all_inputs, input_file_size + :raise ValidationError: if any required sample attribute is missing + """ + if isinstance(schema, str): + schema = read_schema(schema) + + # first, validate attrs existence using jsonschema + _validate_sample_object(schemas=schema, sample=sample) + + all_inputs = set() + required_inputs = set() + schema = schema[-1] # use only first schema, in case there are imports + sample_schema_dict = schema[PROP_KEY][SAMPLES_KEY]["items"] + if SIZING_KEY in sample_schema_dict: + all_inputs.update(_get_attr_values(sample, sample_schema_dict[SIZING_KEY])) + if TANGIBLE_KEY in sample_schema_dict: + required_inputs = set( + _get_attr_values(sample, sample_schema_dict[TANGIBLE_KEY]) + ) + all_inputs.update(required_inputs) + with catch_warnings(record=True) as w: + input_file_size = sum( + [ + size(f, size_str=False) or 0.0 + for f in all_inputs + if f != "" and f != None + ] + ) / (1024 ** 3) + if w: + _LOGGER.warning( + f"{len(w)} input files missing, job input size was " + f"not calculated accurately" + ) + + return { + MISSING_KEY: [i for i in required_inputs if not os.path.exists(i)], + REQUIRED_INPUTS_KEY: required_inputs, + ALL_INPUTS_KEY: all_inputs, + INPUT_FILE_SIZE_KEY: input_file_size, + } diff --git a/peppy/eido/output_formatters.py b/peppy/eido/output_formatters.py new file mode 100644 index 00000000..2cce30c5 --- /dev/null +++ b/peppy/eido/output_formatters.py @@ -0,0 +1,126 @@ +from abc import ABC, abstractmethod +from typing import Iterable, List, Union + +from ..sample import Sample + + +class BaseOutputFormatter(ABC): + @staticmethod + @abstractmethod + def format(samples: List[Sample]): + """ + Convert the samples to correct format. + """ + pass + + +class MultilineOutputFormatter(BaseOutputFormatter): + @staticmethod + def format(samples: List[Sample]) -> str: + output_rows = [] + sample_attributes = [ + attribute + for attribute in samples[0].keys() + if not attribute.startswith("_") and not attribute == "subsample_name" + ] + header = MultilineOutputFormatter._get_header(sample_attributes) + + for sample in samples: + attribute_with_multiple_properties = MultilineOutputFormatter._get_the_name_of_the_first_attribute_with_multiple_properties( + sample, sample_attributes + ) + if attribute_with_multiple_properties: + sample_rows = MultilineOutputFormatter._split_sample_to_multiple_rows( + sample, sample_attributes, attribute_with_multiple_properties + ) + output_rows.extend(sample_rows) + else: + one_sample_row = MultilineOutputFormatter._convert_sample_to_row( + sample, sample_attributes + ) + output_rows.append(one_sample_row) + + return "\n".join(header + output_rows) + "\n" + + @staticmethod + def _get_header(header_column_names: List[str]): + return [",".join(header_column_names)] + + @staticmethod + def _get_the_name_of_the_first_attribute_with_multiple_properties( + sample: Sample, sample_attributes: List[str] + ) -> Union[str, None]: + for attribute in sample_attributes: + if MultilineOutputFormatter._sample_attribute_is_list(sample, attribute): + return attribute + + @staticmethod + def _split_sample_to_multiple_rows( + sample: Sample, sample_attributes: List, attribute_with_multiple_properties: str + ) -> Iterable[str]: + """ + If one sample object contains array properties instead of single value, then it will be converted + to multiple rows. + + Args: + sample: Sample from project. + sample_attributes: List of all sample properties names (name of columns from sample_table). + + Returns: + List of rows created from given sample object. + """ + number_of_samples_after_split = len( + getattr(sample, attribute_with_multiple_properties) + ) + sample_rows_after_split = [] + + for sample_index in range(number_of_samples_after_split): + sample_row = MultilineOutputFormatter._convert_sample_to_row( + sample, sample_attributes, sample_index + ) + sample_rows_after_split.append(sample_row) + + return sample_rows_after_split + + @staticmethod + def _convert_sample_to_row( + sample: Sample, sample_attributes: List, sample_index: int = 0 + ) -> str: + """ + Converts single sample object to CSV row. + + Some samples have a list of values instead of single value for given attribute (column), and + sample_index indicates index of the value that will be used to create a row. For samples that don't + have any attributes with given names this will always be zero. + + Args: + sample: Single sample object. + sample_attributes: Array of all attributes names (column names) for given sample. + sample_index: Number indicating which value will be used to create row. Some samples + + Returns: + Representation of sample as a CSV row. + """ + sample_row = [] + + for attribute in sample_attributes: + if ( + MultilineOutputFormatter._sample_attribute_is_list(sample, attribute) + and sample[attribute] + ): + value = sample[attribute][sample_index] + else: + value = sample.get(attribute) + + sample_row.append(value or "") + + return ",".join(sample_row) + + @staticmethod + def _sample_attribute_is_list(sample: Sample, attribute: str) -> bool: + return isinstance(getattr(sample, attribute, ""), list) + + +class SampleSubsampleOutputFormatter(BaseOutputFormatter): + def format(self, samples: List[Sample]): + pass diff --git a/setup.py b/setup.py index 3d0619c9..02140117 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,15 @@ def get_static(name, condition=None): url="https://github.com/pepkit/peppy/", author="Michal Stolarczyk, Nathan Sheffield, Vince Reuter, Andre Rendeiro, Oleksandr Khoroshevskyi", license="BSD2", + entry_points={ + "console_scripts": ["peppy = peppy.cli.main"], + "pep.filters": [ + "basic=peppy.eido.conversion_plugins:basic_pep_filter", + "yaml=peppy.eido.conversion_plugins:yaml_pep_filter", + "csv=peppy.eido.conversion_plugins:csv_pep_filter", + "yaml-samples=peppy.eido.conversion_plugins:yaml_samples_pep_filter", + ], + }, include_package_data=True, tests_require=(["pytest"]), setup_requires=( From 7b3c891fd4b8a197fb76ccf988b24af74c928e40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Oct 2025 18:06:20 -0400 Subject: [PATCH 108/165] correction for package installing --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 02140117..9dd76309 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ def get_static(name, condition=None): setup( name=PACKAGE_NAME, - packages=[PACKAGE_NAME], + packages=[PACKAGE_NAME, "peppy.eido"], version=version, description="A python-based project metadata manager for portable encapsulated projects", long_description=long_description, @@ -60,7 +60,7 @@ def get_static(name, condition=None): author="Michal Stolarczyk, Nathan Sheffield, Vince Reuter, Andre Rendeiro, Oleksandr Khoroshevskyi", license="BSD2", entry_points={ - "console_scripts": ["peppy = peppy.cli.main"], + "console_scripts": ["peppy = peppy.cli:main"], "pep.filters": [ "basic=peppy.eido.conversion_plugins:basic_pep_filter", "yaml=peppy.eido.conversion_plugins:yaml_pep_filter", From 6055056caf35804a9bc2c236da3588bb2293edca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Oct 2025 14:35:56 -0400 Subject: [PATCH 109/165] importing relative paths in schema --- peppy/eido/schema.py | 18 ++++- tests/conftest.py | 10 +++ .../common/schemas/common_pep_validation.yaml | 69 +++++++++++++++++++ .../data/peps/pep_schema_rel_path/config.yaml | 3 + .../peps/pep_schema_rel_path/sample_sheet.csv | 3 + .../schemas/test_schema_imports_rel_path.yaml | 13 ++++ tests/test_validations.py | 6 ++ 7 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 tests/data/common/schemas/common_pep_validation.yaml create mode 100644 tests/data/peps/pep_schema_rel_path/config.yaml create mode 100644 tests/data/peps/pep_schema_rel_path/sample_sheet.csv create mode 100644 tests/data/schemas/test_schema_imports_rel_path.yaml diff --git a/peppy/eido/schema.py b/peppy/eido/schema.py index 229ab973..c0bbba98 100644 --- a/peppy/eido/schema.py +++ b/peppy/eido/schema.py @@ -1,4 +1,8 @@ +import os from logging import getLogger +from typing import Union + +from ubiquerg import is_url from ..utils import load_yaml from .const import PROP_KEY, SAMPLES_KEY @@ -52,10 +56,18 @@ def read_schema(schema): if the 'imports' sections in any of the schemas is not a list """ - def _recursively_read_schemas(x, lst): + def _recursively_read_schemas(x, lst, parent_folder: Union[str, None]): if "imports" in x: if isinstance(x["imports"], list): for sch in x["imports"]: + if (not is_url(sch)) and (not os.path.isabs(sch)): + # resolve relative path + if parent_folder is not None: + sch = os.path.normpath(os.path.join(parent_folder, sch)) + else: + _LOGGER.warning( + f"The schema contains relative path without known parent folder: {sch}" + ) lst.extend(read_schema(sch)) else: raise TypeError("In schema the 'imports' section has to be a list") @@ -63,12 +75,14 @@ def _recursively_read_schemas(x, lst): return lst schema_list = [] + schema_folder = None if isinstance(schema, str): _LOGGER.debug(f"Reading schema: {schema}") + schema_folder = os.path.split(schema)[0] schema = load_yaml(schema) if not isinstance(schema, dict): raise TypeError( f"schema has to be a dict, path to an existing file or URL to a remote one. " f"Got: {type(schema)}" ) - return _recursively_read_schemas(schema, schema_list) + return _recursively_read_schemas(schema, schema_list, schema_folder) diff --git a/tests/conftest.py b/tests/conftest.py index 669d336b..5380f9ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -126,6 +126,11 @@ def schema_imports_file_path(schemas_path): return os.path.join(schemas_path, "test_schema_imports.yaml") +@pytest.fixture +def schema_rel_path_imports_file_path(schemas_path): + return os.path.join(schemas_path, "test_schema_imports_rel_path.yaml") + + @pytest.fixture def taxprofiler_project_path(peps_path): return os.path.join(peps_path, "multiline_output", "config.yaml") @@ -141,6 +146,11 @@ def path_to_taxprofiler_csv_multiline_output(peps_path): return os.path.join(peps_path, "multiline_output", "multiline_output.csv") +@pytest.fixture +def path_pep_for_schema_with_rel_path(peps_path): + return os.path.join(peps_path, "pep_schema_rel_path", "config.yaml") + + @pytest.fixture def path_pep_with_fasta_column(peps_path): return os.path.join(peps_path, "pep_with_fasta_column", "config.yaml") diff --git a/tests/data/common/schemas/common_pep_validation.yaml b/tests/data/common/schemas/common_pep_validation.yaml new file mode 100644 index 00000000..78ff9a3c --- /dev/null +++ b/tests/data/common/schemas/common_pep_validation.yaml @@ -0,0 +1,69 @@ +description: "Schema for a minimal PEP" +version: "2.0.0" +properties: + config: + properties: + name: + type: string + pattern: "^\\S*$" + description: "Project name with no whitespace" + pep_version: + description: "Version of the PEP Schema this PEP follows" + type: string + sample_table: + type: string + description: "Path to the sample annotation table with one row per sample" + subsample_table: + type: string + description: "Path to the subsample annotation table with one row per subsample and sample_name attribute matching an entry in the sample table" + sample_modifiers: + type: object + properties: + append: + type: object + duplicate: + type: object + imply: + type: array + items: + type: object + properties: + if: + type: object + then: + type: object + derive: + type: object + properties: + attributes: + type: array + items: + type: string + sources: + type: object + project_modifiers: + type: object + properties: + amend: + description: "Object overwriting original project attributes" + type: object + import: + description: "List of external PEP project config files to import" + type: array + items: + type: string + required: + - pep_version + samples: + type: array + items: + type: object + properties: + sample_name: + type: string + pattern: "^\\S*$" + description: "Unique name of the sample with no whitespace" + required: + - sample_name +required: + - samples \ No newline at end of file diff --git a/tests/data/peps/pep_schema_rel_path/config.yaml b/tests/data/peps/pep_schema_rel_path/config.yaml new file mode 100644 index 00000000..2783c73d --- /dev/null +++ b/tests/data/peps/pep_schema_rel_path/config.yaml @@ -0,0 +1,3 @@ +description: "Example PEP for this particular pipeline." +pep_version: 2.0.0 +sample_table: sample_sheet.csv diff --git a/tests/data/peps/pep_schema_rel_path/sample_sheet.csv b/tests/data/peps/pep_schema_rel_path/sample_sheet.csv new file mode 100644 index 00000000..18840dd6 --- /dev/null +++ b/tests/data/peps/pep_schema_rel_path/sample_sheet.csv @@ -0,0 +1,3 @@ +"sample_name","patient" +"a","Test" +"b", "Also Test" diff --git a/tests/data/schemas/test_schema_imports_rel_path.yaml b/tests/data/schemas/test_schema_imports_rel_path.yaml new file mode 100644 index 00000000..527fb341 --- /dev/null +++ b/tests/data/schemas/test_schema_imports_rel_path.yaml @@ -0,0 +1,13 @@ +description: "PEP validation schema for this particular pipeline." +version: "2.0.0" +imports: + - "../common/schemas/common_pep_validation.yaml" +properties: + samples: + items: + properties: + patient: + type: string + pattern: "\\S+" + description: >- + Unique identifier of the patient a sample has been taken from. \ No newline at end of file diff --git a/tests/test_validations.py b/tests/test_validations.py index ecdbf0d4..28fdae33 100644 --- a/tests/test_validations.py +++ b/tests/test_validations.py @@ -37,6 +37,12 @@ def test_validate_detects_invalid_imports( with pytest.raises(EidoValidationError): validate_project(project=project_object, schema=schema_imports_file_path) + def test_validate_imports_with_rel_path( + self, path_pep_for_schema_with_rel_path, schema_rel_path_imports_file_path + ): + pep_project = Project(path_pep_for_schema_with_rel_path) + validate_project(project=pep_project, schema=schema_rel_path_imports_file_path) + def test_validate_converts_samples_to_private_attr( self, project_object, schema_samples_file_path ): From ff038e14b7cdd6698e78ad4f6d5e6e713029704a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Oct 2025 16:45:23 -0400 Subject: [PATCH 110/165] default args in CLI conflict with yaml config --- peppy/eido/argparser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/peppy/eido/argparser.py b/peppy/eido/argparser.py index 47c1420c..fd3a7770 100644 --- a/peppy/eido/argparser.py +++ b/peppy/eido/argparser.py @@ -42,14 +42,14 @@ def build_argparser(): "--st-index", required=False, type=str, - default=SAMPLE_NAME_ATTR, + # default=SAMPLE_NAME_ATTR, help=f"Sample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.", ) subparser.add_argument( "--sst-index", required=False, type=str, - default=SAMPLE_NAME_ATTR, + # default=SAMPLE_NAME_ATTR, help=f"Subsample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.", ) subparser.add_argument( From dbfedcdf7ed4f7f4544e911406b33aad29ae877a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 17:21:28 -0400 Subject: [PATCH 111/165] modify warning message for unpopulated env vars --- peppy/utils.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/peppy/utils.py b/peppy/utils.py index 59090cd8..79404fa4 100644 --- a/peppy/utils.py +++ b/peppy/utils.py @@ -235,11 +235,16 @@ def unpopulated_env_var(paths: Set[str]): common_dir = psp.commonpath(tails) or "." # Ensure it's a directory; commonpath is component-wise, so it's fine. + warning_message = "Not all environment variables were populated in derived attribute source: $%s/{" + + in_env = [] + for t in tails: + rel = psp.relpath(t, start=common_dir or ".") + in_env.append(rel) + + warning_message += ", ".join(in_env) + warning_message += "}" _LOGGER.warning( - "Not all environment variables were populated in derived attribute source: $%s", + warning_message, var, ) - for t in tails: - rel = psp.relpath(t, start=common_dir or ".") - # show with leading "./" per your example - _LOGGER.warning(" ./%s", rel) From 1afef2f387c14bc5f7b745e30a4db9cb5f6d1eb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 18:30:58 -0400 Subject: [PATCH 112/165] code formatting --- peppy/eido/inspection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/peppy/eido/inspection.py b/peppy/eido/inspection.py index 1545362f..35351959 100644 --- a/peppy/eido/inspection.py +++ b/peppy/eido/inspection.py @@ -82,7 +82,7 @@ def get_input_files_size(sample, schema): for f in all_inputs if f != "" and f != None ] - ) / (1024 ** 3) + ) / (1024**3) if w: _LOGGER.warning( f"{len(w)} input files missing, job input size was " From f8692a78ab6cb590651345873c8696e2e0a195cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 18:34:38 -0400 Subject: [PATCH 113/165] formatting with black 25.9.0 --- peppy/const.py | 2 +- peppy/eido/conversion_plugins.py | 2 +- peppy/eido/exceptions.py | 2 +- peppy/exceptions.py | 2 +- peppy/utils.py | 2 +- tests/conftest.py | 2 +- tests/test_Project.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/peppy/const.py b/peppy/const.py index 35f62ccc..a6dd02b7 100644 --- a/peppy/const.py +++ b/peppy/const.py @@ -1,4 +1,4 @@ -""" Package constants """ +"""Package constants""" __author__ = "Michal Stolarczyk" __email__ = "michal.stolarczyk@nih.gov" diff --git a/peppy/eido/conversion_plugins.py b/peppy/eido/conversion_plugins.py index b1c199c2..e4405543 100644 --- a/peppy/eido/conversion_plugins.py +++ b/peppy/eido/conversion_plugins.py @@ -1,4 +1,4 @@ -""" built-in PEP filters """ +"""built-in PEP filters""" from typing import Dict diff --git a/peppy/eido/exceptions.py b/peppy/eido/exceptions.py index 36e47fb5..194c6edb 100644 --- a/peppy/eido/exceptions.py +++ b/peppy/eido/exceptions.py @@ -1,4 +1,4 @@ -""" Exceptions for specific eido issues. """ +"""Exceptions for specific eido issues.""" from abc import ABCMeta diff --git a/peppy/exceptions.py b/peppy/exceptions.py index 67b3e806..ec356f49 100644 --- a/peppy/exceptions.py +++ b/peppy/exceptions.py @@ -1,4 +1,4 @@ -""" Custom error types """ +"""Custom error types""" from abc import ABCMeta from collections.abc import Iterable diff --git a/peppy/utils.py b/peppy/utils.py index 79404fa4..e0609289 100644 --- a/peppy/utils.py +++ b/peppy/utils.py @@ -1,4 +1,4 @@ -""" Helpers without an obvious logical home. """ +"""Helpers without an obvious logical home.""" import logging import os diff --git a/tests/conftest.py b/tests/conftest.py index 5380f9ee..46d76fa3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -""" Configuration for modules with independent tests of models. """ +"""Configuration for modules with independent tests of models.""" import os diff --git a/tests/test_Project.py b/tests/test_Project.py index 60313259..2ba5c0e5 100644 --- a/tests/test_Project.py +++ b/tests/test_Project.py @@ -1,4 +1,4 @@ -""" Classes for peppy.Project smoketesting """ +"""Classes for peppy.Project smoketesting""" import os import pickle From 9d0904f7260685953b8a64fa075840d35c02d166 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 18:38:08 -0400 Subject: [PATCH 114/165] extend requirement from eido --- requirements/requirements-all.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index ace32606..fef8d2bd 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -4,3 +4,4 @@ rich>=10.3.0 ubiquerg>=0.6.2 numpy pephubclient>=0.4.2 +importlib-metadata; python_version < '3.10' \ No newline at end of file From 02d3284043d6f6aec859efe5d6ac4d1275427e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 18:44:59 -0400 Subject: [PATCH 115/165] check based on copilot --- peppy/eido/const.py | 4 ++-- peppy/eido/exceptions.py | 25 ++++++++++++++++--------- peppy/eido/inspection.py | 2 +- tests/test_conversions.py | 2 +- tests/test_validations.py | 2 +- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/peppy/eido/const.py b/peppy/eido/const.py index 2ec49428..9f3f1919 100644 --- a/peppy/eido/const.py +++ b/peppy/eido/const.py @@ -40,11 +40,11 @@ SCHEMA_SECTIONS = ["PROP_KEY", "TANGIBLE_KEY", "SIZING_KEY"] -SCHEMA_VALIDAION_KEYS = [ +SCHEMA_VALIDATION_KEYS = [ "MISSING_KEY", "REQUIRED_INPUTS_KEY", "ALL_INPUTS_KEY", "INPUT_FILE_SIZE_KEY", ] -__all__ = GENERAL + SCHEMA_SECTIONS + SCHEMA_VALIDAION_KEYS +__all__ = GENERAL + SCHEMA_SECTIONS + SCHEMA_VALIDATION_KEYS diff --git a/peppy/eido/exceptions.py b/peppy/eido/exceptions.py index 194c6edb..ddf3c51c 100644 --- a/peppy/eido/exceptions.py +++ b/peppy/eido/exceptions.py @@ -2,7 +2,7 @@ from abc import ABCMeta -_all__ = [ +__all__ = [ "EidoFilterError", "EidoSchemaInvalidError", "EidoValidationError", @@ -23,6 +23,20 @@ def __init__(self, key): super(PathAttrNotFoundError, self).__init__(key) +class EidoSchemaInvalidError(EidoException): + """Schema does not comply to eido-specific requirements.""" + + def __init__(self, key): + super(EidoSchemaInvalidError, self).__init__(key) + + +class EidoFilterError(EidoException): + """Issue with the PEP filter.""" + + def __init__(self, key): + super(EidoFilterError, self).__init__(key) + + class EidoValidationError(EidoException): """Object was not validated successfully according to schema.""" @@ -32,11 +46,4 @@ def __init__(self, message, errors_by_type): self.message = message def __str__(self): - return f"EidoValidationError ({self.message}): {self.errors_by_type}" - - -class EidoFilterError(EidoException): - """Issue with the PEP filter.""" - - def __init__(self, key): - super(EidoFilterError, self).__init__(key) + return f"EidoValidationError ({self.message}): {self.errors_by_type}" \ No newline at end of file diff --git a/peppy/eido/inspection.py b/peppy/eido/inspection.py index 35351959..cb52a507 100644 --- a/peppy/eido/inspection.py +++ b/peppy/eido/inspection.py @@ -80,7 +80,7 @@ def get_input_files_size(sample, schema): [ size(f, size_str=False) or 0.0 for f in all_inputs - if f != "" and f != None + if f != "" and f is not None ] ) / (1024**3) if w: diff --git a/tests/test_conversions.py b/tests/test_conversions.py index a27310b0..e821030b 100644 --- a/tests/test_conversions.py +++ b/tests/test_conversions.py @@ -49,7 +49,7 @@ def test_csv_filter( assert save_result_mock.called assert conv_result["samples"] == taxprofiler_csv_multiline_output - def test_csv_filter_handles_empty_fasta_correclty( + def test_csv_filter_handles_empty_fasta_correctly( self, project_pep_with_fasta_column, output_pep_with_fasta_column, diff --git a/tests/test_validations.py b/tests/test_validations.py index 28fdae33..dcb391db 100644 --- a/tests/test_validations.py +++ b/tests/test_validations.py @@ -159,7 +159,7 @@ def test_validate_detects_invalid(self, schema_invalid_file_path, remote_pep_cfg project=Project(remote_pep_cfg), schema=schema_invalid_file_path ) - def test_validate_file_existance( + def test_validate_file_existence( self, test_file_existing_pep, test_file_existing_schema ): schema_path = test_file_existing_schema From ae23b56e9aa75abe0607cde51ef12fa9404c0c23 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 18:46:38 -0400 Subject: [PATCH 116/165] linting --- peppy/eido/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/peppy/eido/exceptions.py b/peppy/eido/exceptions.py index ddf3c51c..6a77fd2b 100644 --- a/peppy/eido/exceptions.py +++ b/peppy/eido/exceptions.py @@ -46,4 +46,4 @@ def __init__(self, message, errors_by_type): self.message = message def __str__(self): - return f"EidoValidationError ({self.message}): {self.errors_by_type}" \ No newline at end of file + return f"EidoValidationError ({self.message}): {self.errors_by_type}" From 9ad823b8513488b3e7ca7c8da39f54d4643d1059 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 18:48:03 -0400 Subject: [PATCH 117/165] requirements for eido --- requirements/requirements-all.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index fef8d2bd..0dab7941 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -4,4 +4,6 @@ rich>=10.3.0 ubiquerg>=0.6.2 numpy pephubclient>=0.4.2 -importlib-metadata; python_version < '3.10' \ No newline at end of file +# eido +importlib-metadata; python_version < '3.10' +jsonschema>=3.0.1 \ No newline at end of file From 6af5e64fe657ff9b970c078821839d315ae78999 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Oct 2025 18:51:17 -0400 Subject: [PATCH 118/165] eido test requirements --- requirements/requirements-test.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index 2461d2eb..151fb618 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -2,3 +2,6 @@ mock pytest pytest-cov pytest-remotedata +# eido +coveralls +pytest-mock==3.6.1 \ No newline at end of file From d6e1fc10de823e91a47af988a1d2f561cd5ca25f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Nov 2025 14:10:01 -0500 Subject: [PATCH 119/165] complete missing type annotations --- peppy/cli.py | 3 ++- peppy/eido/conversion.py | 13 ++++++++----- peppy/eido/exceptions.py | 3 ++- peppy/eido/inspection.py | 10 ++++++++-- peppy/eido/schema.py | 10 ++++++---- peppy/eido/validation.py | 8 +++++--- 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/peppy/cli.py b/peppy/cli.py index 349e81f9..a61201e2 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -1,5 +1,6 @@ import logging import sys +from typing import Dict, List from logmuse import init_logger @@ -35,7 +36,7 @@ def _parse_filter_args_str(input): ) -def print_error_summary(errors_by_type): +def print_error_summary(errors_by_type: Dict[str, List[Dict[str, str]]]): """Print a summary of errors, organized by error type""" n_error_types = len(errors_by_type) print(f"Found {n_error_types} types of error:") diff --git a/peppy/eido/conversion.py b/peppy/eido/conversion.py index a55f83e4..262a6282 100644 --- a/peppy/eido/conversion.py +++ b/peppy/eido/conversion.py @@ -8,14 +8,15 @@ import inspect import os from logging import getLogger -from typing import NoReturn +from typing import Callable, Dict, List, NoReturn +from ..project import Project from .exceptions import EidoFilterError _LOGGER = getLogger(__name__) -def pep_conversion_plugins(): +def pep_conversion_plugins() -> Dict[str, Callable]: """ Plugins registered by entry points in the current Python env @@ -36,7 +37,7 @@ def pep_conversion_plugins(): return plugins -def convert_project(prj, target_format, plugin_kwargs=None): +def convert_project(prj: Project, target_format: str, plugin_kwargs=None): """ Convert a `peppy.Project` object to a selected format @@ -48,7 +49,9 @@ def convert_project(prj, target_format, plugin_kwargs=None): return run_filter(prj, target_format, plugin_kwargs=plugin_kwargs or dict()) -def run_filter(prj, filter_name, verbose=True, plugin_kwargs=None): +def run_filter( + prj: Project, filter_name: str, verbose=True, plugin_kwargs=None +) -> Dict[str, str]: """ Run a selected filter on a peppy.Project object @@ -113,7 +116,7 @@ def save_result(result_path: str, content: str) -> NoReturn: f.write(content) -def get_available_pep_filters(): +def get_available_pep_filters() -> List[str]: """ Get a list of available target formats diff --git a/peppy/eido/exceptions.py b/peppy/eido/exceptions.py index 6a77fd2b..ac9a9c9e 100644 --- a/peppy/eido/exceptions.py +++ b/peppy/eido/exceptions.py @@ -1,6 +1,7 @@ """Exceptions for specific eido issues.""" from abc import ABCMeta +from typing import Dict, List __all__ = [ "EidoFilterError", @@ -40,7 +41,7 @@ def __init__(self, key): class EidoValidationError(EidoException): """Object was not validated successfully according to schema.""" - def __init__(self, message, errors_by_type): + def __init__(self, message: str, errors_by_type: Dict[str, List[Dict[str, str]]]): super().__init__(message) self.errors_by_type = errors_by_type self.message = message diff --git a/peppy/eido/inspection.py b/peppy/eido/inspection.py index cb52a507..76ad24fb 100644 --- a/peppy/eido/inspection.py +++ b/peppy/eido/inspection.py @@ -1,9 +1,11 @@ import os from logging import getLogger +from typing import Dict, Iterable, List, Set, Union from warnings import catch_warnings from ubiquerg import size +from .. import Project, Sample from .const import ( ALL_INPUTS_KEY, INPUT_FILE_SIZE_KEY, @@ -20,7 +22,9 @@ _LOGGER = getLogger(__name__) -def inspect_project(p, sample_names=None, max_attr=10): +def inspect_project( + p: Project, sample_names: Union[None, List[str]] = None, max_attr=10 +) -> None: """ Print inspection info: Project or, if sample_names argument is provided, matched samples @@ -42,7 +46,9 @@ def inspect_project(p, sample_names=None, max_attr=10): return -def get_input_files_size(sample, schema): +def get_input_files_size( + sample: Sample, schema: Union[str, List[Dict]] +) -> Dict[str, Union[List[str], Set[str], float]]: """ Determine which of this Sample's required attributes/files are missing and calculate sizes of the files (inputs). diff --git a/peppy/eido/schema.py b/peppy/eido/schema.py index c0bbba98..14f55775 100644 --- a/peppy/eido/schema.py +++ b/peppy/eido/schema.py @@ -1,6 +1,6 @@ import os from logging import getLogger -from typing import Union +from typing import Dict, List, Union from ubiquerg import is_url @@ -10,7 +10,7 @@ _LOGGER = getLogger(__name__) -def preprocess_schema(schema_dict): +def preprocess_schema(schema_dict: Dict) -> Dict: """ Preprocess schema before validation for user's convenience @@ -43,7 +43,7 @@ def preprocess_schema(schema_dict): return schema_dict -def read_schema(schema): +def read_schema(schema: Union[str, Dict]) -> list[Dict]: """ Safely read schema from YAML-formatted file. @@ -56,7 +56,9 @@ def read_schema(schema): if the 'imports' sections in any of the schemas is not a list """ - def _recursively_read_schemas(x, lst, parent_folder: Union[str, None]): + def _recursively_read_schemas( + x: Dict, lst: List[Dict], parent_folder: Union[str, None] + ) -> List[Dict]: if "imports" in x: if isinstance(x["imports"], list): for sch in x["imports"]: diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py index 4cac91af..552ab0f6 100644 --- a/peppy/eido/validation.py +++ b/peppy/eido/validation.py @@ -1,7 +1,7 @@ import os from copy import deepcopy as dpcpy from logging import getLogger -from typing import Mapping, NoReturn, Union +from typing import Dict, List, Mapping, NoReturn, Union from warnings import warn import pandas as pd @@ -81,7 +81,7 @@ def validate_project(project: Project, schema: Union[str, dict]) -> NoReturn: _LOGGER.debug("Project validation successful") -def _validate_sample_object(sample: Sample, schemas): +def _validate_sample_object(sample: Sample, schemas: List[Dict]): """ Internal function that allows to validate a peppy.Sample object without requiring a reference to peppy.Project. @@ -159,7 +159,9 @@ def validate_config( _LOGGER.debug("Config validation successful") -def _get_attr_values(obj, attrlist): +def _get_attr_values( + obj: Mapping, attrlist: Union[str, List[str]] +) -> Union[None, List[str]]: """ Get value corresponding to each given attribute. From 12965a6a2323a3b4d52d9a0971a7813ad2b595ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 01:29:28 +0000 Subject: [PATCH 120/165] Add type hints to eido module functions Added clear and explicit type hints to main functions in the eido codebase: - validation.py: Added return types (None) and improved parameter types - conversion.py: Added Dict return types and parameter types for plugin_kwargs - output_formatters.py: Added str return types to format methods - argparser.py: Added return type tuple for build_argparser function These additions improve code clarity and enable better IDE support. --- peppy/eido/argparser.py | 4 +++- peppy/eido/conversion.py | 4 ++-- peppy/eido/output_formatters.py | 6 +++--- peppy/eido/validation.py | 10 +++++----- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/peppy/eido/argparser.py b/peppy/eido/argparser.py index fd3a7770..917bd9d6 100644 --- a/peppy/eido/argparser.py +++ b/peppy/eido/argparser.py @@ -1,4 +1,6 @@ +from argparse import ArgumentParser from logging import CRITICAL, DEBUG, ERROR, INFO, WARN +from typing import Dict, Tuple from ubiquerg import VersionInHelpParser @@ -9,7 +11,7 @@ LEVEL_BY_VERBOSITY = [ERROR, CRITICAL, WARN, INFO, DEBUG] -def build_argparser(): +def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: banner = "%(prog)s - Interact with PEPs" additional_description = "\nhttp://eido.databio.org/" diff --git a/peppy/eido/conversion.py b/peppy/eido/conversion.py index 262a6282..d747d7be 100644 --- a/peppy/eido/conversion.py +++ b/peppy/eido/conversion.py @@ -37,7 +37,7 @@ def pep_conversion_plugins() -> Dict[str, Callable]: return plugins -def convert_project(prj: Project, target_format: str, plugin_kwargs=None): +def convert_project(prj: Project, target_format: str, plugin_kwargs: Dict = None) -> Dict[str, str]: """ Convert a `peppy.Project` object to a selected format @@ -50,7 +50,7 @@ def convert_project(prj: Project, target_format: str, plugin_kwargs=None): def run_filter( - prj: Project, filter_name: str, verbose=True, plugin_kwargs=None + prj: Project, filter_name: str, verbose: bool = True, plugin_kwargs: Dict = None ) -> Dict[str, str]: """ Run a selected filter on a peppy.Project object diff --git a/peppy/eido/output_formatters.py b/peppy/eido/output_formatters.py index 2cce30c5..ad5e6f84 100644 --- a/peppy/eido/output_formatters.py +++ b/peppy/eido/output_formatters.py @@ -7,7 +7,7 @@ class BaseOutputFormatter(ABC): @staticmethod @abstractmethod - def format(samples: List[Sample]): + def format(samples: List[Sample]) -> str: """ Convert the samples to correct format. """ @@ -43,7 +43,7 @@ def format(samples: List[Sample]) -> str: return "\n".join(header + output_rows) + "\n" @staticmethod - def _get_header(header_column_names: List[str]): + def _get_header(header_column_names: List[str]) -> List[str]: return [",".join(header_column_names)] @staticmethod @@ -122,5 +122,5 @@ def _sample_attribute_is_list(sample: Sample, attribute: str) -> bool: class SampleSubsampleOutputFormatter(BaseOutputFormatter): - def format(self, samples: List[Sample]): + def format(self, samples: List[Sample]) -> str: pass diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py index 552ab0f6..7bbc7b0c 100644 --- a/peppy/eido/validation.py +++ b/peppy/eido/validation.py @@ -18,7 +18,7 @@ _LOGGER = getLogger(__name__) -def _validate_object(obj: Mapping, schema: Union[str, dict], sample_name_colname=False): +def _validate_object(obj: Mapping, schema: Union[str, dict], sample_name_colname: Union[str, bool] = False) -> None: """ Generic function to validate object against a schema @@ -81,7 +81,7 @@ def validate_project(project: Project, schema: Union[str, dict]) -> NoReturn: _LOGGER.debug("Project validation successful") -def _validate_sample_object(sample: Sample, schemas: List[Dict]): +def _validate_sample_object(sample: Sample, schemas: List[Dict]) -> None: """ Internal function that allows to validate a peppy.Sample object without requiring a reference to peppy.Project. @@ -185,8 +185,8 @@ def _get_attr_values( def validate_input_files( project: Project, schemas: Union[str, dict], - sample_name: Union[str, int] = None, -): + sample_name: Union[str, int, None] = None, +) -> None: """ Determine which of the required and optional files are missing. @@ -251,7 +251,7 @@ def validate_input_files( def validate_original_samples( samples: Union[str, pd.DataFrame], schema: Union[str, dict] -): +) -> None: """ Validate the original samples from the csv table against a schema From 58dab8744e2329854eb783645374d0422f433d17 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 01:31:26 +0000 Subject: [PATCH 121/165] Convert all eido docstrings to Google-style format Converted all docstrings in the eido module from reStructuredText style to Google-style docstrings for improved readability and consistency: - validation.py: All function docstrings converted - conversion.py: All function docstrings converted - schema.py: All function docstrings converted - inspection.py: All function docstrings converted Google-style provides clearer structure with Args/Returns/Raises sections. --- peppy/eido/conversion.py | 54 ++++++++++++-------- peppy/eido/inspection.py | 32 ++++++------ peppy/eido/schema.py | 27 +++++----- peppy/eido/validation.py | 103 ++++++++++++++++++++------------------- 4 files changed, 119 insertions(+), 97 deletions(-) diff --git a/peppy/eido/conversion.py b/peppy/eido/conversion.py index d747d7be..95365674 100644 --- a/peppy/eido/conversion.py +++ b/peppy/eido/conversion.py @@ -17,13 +17,14 @@ def pep_conversion_plugins() -> Dict[str, Callable]: - """ - Plugins registered by entry points in the current Python env + """Plugins registered by entry points in the current Python env. + + Returns: + Dict which keys are names of all possible hooks and values are dicts + mapping registered functions names to their values - :return dict[dict[function(peppy.Project)]]: dict which keys - are names of all possible hooks and values are dicts mapping - registered functions names to their values - :raise EidoFilterError: if any of the filters has an invalid signature. + Raises: + EidoFilterError: If any of the filters has an invalid signature """ plugins = {} for ep in entry_points(group="pep.filters"): @@ -38,13 +39,18 @@ def pep_conversion_plugins() -> Dict[str, Callable]: def convert_project(prj: Project, target_format: str, plugin_kwargs: Dict = None) -> Dict[str, str]: - """ - Convert a `peppy.Project` object to a selected format + """Convert a `peppy.Project` object to a selected format. + + Args: + prj: A Project object to convert + target_format: The format to convert the Project object to + plugin_kwargs: Kwargs to pass to the plugin function + + Returns: + Dictionary with conversion results - :param peppy.Project prj: a Project object to convert - :param dict plugin_kwargs: kwargs to pass to the plugin function - :param str target_format: the format to convert the Project object to - :raise EidoFilterError: if the requested filter is not defined + Raises: + EidoFilterError: If the requested filter is not defined """ return run_filter(prj, target_format, plugin_kwargs=plugin_kwargs or dict()) @@ -52,13 +58,19 @@ def convert_project(prj: Project, target_format: str, plugin_kwargs: Dict = None def run_filter( prj: Project, filter_name: str, verbose: bool = True, plugin_kwargs: Dict = None ) -> Dict[str, str]: - """ - Run a selected filter on a peppy.Project object + """Run a selected filter on a peppy.Project object. + + Args: + prj: A Project to run filter on + filter_name: Name of the filter to run + verbose: Whether to print output to stdout + plugin_kwargs: Kwargs to pass to the plugin function - :param peppy.Project prj: a Project to run filter on - :param str filter_name: name of the filter to run - :param dict plugin_kwargs: kwargs to pass to the plugin function - :raise EidoFilterError: if the requested filter is not defined + Returns: + Dictionary with conversion results + + Raises: + EidoFilterError: If the requested filter is not defined """ # convert to empty dictionary if no plugin_kwargs are passed plugin_kwargs = plugin_kwargs or dict() @@ -117,9 +129,9 @@ def save_result(result_path: str, content: str) -> NoReturn: def get_available_pep_filters() -> List[str]: - """ - Get a list of available target formats + """Get a list of available target formats. - :return List[str]: a list of available formats + Returns: + A list of available formats """ return list(pep_conversion_plugins().keys()) diff --git a/peppy/eido/inspection.py b/peppy/eido/inspection.py index 76ad24fb..dcd52c8b 100644 --- a/peppy/eido/inspection.py +++ b/peppy/eido/inspection.py @@ -23,15 +23,14 @@ def inspect_project( - p: Project, sample_names: Union[None, List[str]] = None, max_attr=10 + p: Project, sample_names: Union[None, List[str]] = None, max_attr: int = 10 ) -> None: - """ - Print inspection info: Project or, - if sample_names argument is provided, matched samples + """Print inspection info: Project or, if sample_names argument is provided, matched samples. - :param peppy.Project p: project to inspect - :param Iterable[str] sample_names: list of samples to inspect - :param int max_attr: max number of sample attributes to display + Args: + p: Project to inspect + sample_names: List of samples to inspect + max_attr: Max number of sample attributes to display """ if sample_names: samples = p.get_samples(sample_names) @@ -49,20 +48,23 @@ def inspect_project( def get_input_files_size( sample: Sample, schema: Union[str, List[Dict]] ) -> Dict[str, Union[List[str], Set[str], float]]: - """ - Determine which of this Sample's required attributes/files are missing - and calculate sizes of the files (inputs). + """Determine which of this Sample's required attributes/files are missing and calculate sizes. The names of the attributes that are required and/or deemed as inputs are sourced from the schema, more specifically from required_input_attrs and input_attrs sections in samples section. Note, this function does perform actual Sample object validation with jsonschema. - :param peppy.Sample sample: sample to investigate - :param list[dict] | str schema: schema dict to validate against or a path to one - :return dict: dictionary with validation data, i.e missing, - required_inputs, all_inputs, input_file_size - :raise ValidationError: if any required sample attribute is missing + Args: + sample: Sample to investigate + schema: Schema dict to validate against or a path to one + + Returns: + Dictionary with validation data, i.e missing, required_inputs, + all_inputs, input_file_size + + Raises: + ValidationError: If any required sample attribute is missing """ if isinstance(schema, str): schema = read_schema(schema) diff --git a/peppy/eido/schema.py b/peppy/eido/schema.py index 14f55775..3e618392 100644 --- a/peppy/eido/schema.py +++ b/peppy/eido/schema.py @@ -11,8 +11,7 @@ def preprocess_schema(schema_dict: Dict) -> Dict: - """ - Preprocess schema before validation for user's convenience + """Preprocess schema before validation for user's convenience. Preprocessing includes: - renaming 'samples' to '_samples' since in the peppy.Project object @@ -20,8 +19,11 @@ def preprocess_schema(schema_dict: Dict) -> Dict: - adding array of strings entry for every string specified to accommodate subsamples in peppy.Project - :param dict schema_dict: schema dictionary to preprocess - :return dict: preprocessed schema + Args: + schema_dict: Schema dictionary to preprocess + + Returns: + Preprocessed schema """ _LOGGER.debug(f"schema ori: {schema_dict}") if "project" not in schema_dict[PROP_KEY]: @@ -44,16 +46,19 @@ def preprocess_schema(schema_dict: Dict) -> Dict: def read_schema(schema: Union[str, Dict]) -> list[Dict]: - """ - Safely read schema from YAML-formatted file. + """Safely read schema from YAML-formatted file. If the schema imports any other schemas, they will be read recursively. - :param str | Mapping schema: path to the schema file - or schema in a dict form - :return list[dict]: read schemas - :raise TypeError: if the schema arg is neither a Mapping nor a file path or - if the 'imports' sections in any of the schemas is not a list + Args: + schema: Path to the schema file or schema in a dict form + + Returns: + Read schemas + + Raises: + TypeError: If the schema arg is neither a Mapping nor a file path or + if the 'imports' sections in any of the schemas is not a list """ def _recursively_read_schemas( diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py index 7bbc7b0c..cb01bc58 100644 --- a/peppy/eido/validation.py +++ b/peppy/eido/validation.py @@ -19,14 +19,15 @@ def _validate_object(obj: Mapping, schema: Union[str, dict], sample_name_colname: Union[str, bool] = False) -> None: - """ - Generic function to validate object against a schema + """Generic function to validate object against a schema. - :param Mapping obj: an object to validate - :param str | dict schema: schema dict to validate against or a path to one - from the error. Useful when used ith large projects + Args: + obj: An object to validate + schema: Schema dict to validate against or a path to one + sample_name_colname: Column name for sample names in error reporting - :raises EidoValidationError: if validation is unsuccessful + Raises: + EidoValidationError: If validation is unsuccessful """ validator = Draft7Validator(schema) _LOGGER.debug(f"{obj},\n {schema}") @@ -61,15 +62,14 @@ def _validate_object(obj: Mapping, schema: Union[str, dict], sample_name_colname def validate_project(project: Project, schema: Union[str, dict]) -> NoReturn: - """ - Validate a project object against a schema + """Validate a project object against a schema. - :param peppy.Project project: a project object to validate - :param str | dict schema: schema dict to validate against or a path to one - from the error. Useful when used ith large projects + Args: + project: A project object to validate + schema: Schema dict to validate against or a path to one - :return: NoReturn - :raises EidoValidationError: if validation is unsuccessful + Raises: + EidoValidationError: If validation is unsuccessful """ sample_name_colname = project.sample_name_colname schema_dicts = read_schema(schema=schema) @@ -82,12 +82,11 @@ def validate_project(project: Project, schema: Union[str, dict]) -> NoReturn: def _validate_sample_object(sample: Sample, schemas: List[Dict]) -> None: - """ - Internal function that allows to validate a peppy.Sample object without - requiring a reference to peppy.Project. + """Validate a peppy.Sample object without requiring a reference to peppy.Project. - :param peppy.Sample sample: a sample object to validate - :param list[dict] schemas: list of schemas to validate against or a path to one + Args: + sample: A sample object to validate + schemas: List of schemas to validate against or a path to one """ for schema_dict in schemas: schema_dict = preprocess_schema(schema_dict) @@ -101,14 +100,15 @@ def _validate_sample_object(sample: Sample, schemas: List[Dict]) -> None: def validate_sample( project: Project, sample_name: Union[str, int], schema: Union[str, dict] ) -> NoReturn: - """ - Validate the selected sample object against a schema + """Validate the selected sample object against a schema. - :param peppy.Project project: a project object to validate - :param str | int sample_name: name or index of the sample to validate - :param str | dict schema: schema dict to validate against or a path to one + Args: + project: A project object to validate + sample_name: Name or index of the sample to validate + schema: Schema dict to validate against or a path to one - :raises EidoValidationError: if validation is unsuccessful + Raises: + EidoValidationError: If validation is unsuccessful """ sample = ( project.samples[sample_name] @@ -124,11 +124,11 @@ def validate_sample( def validate_config( project: Union[Project, dict, str], schema: Union[str, dict] ) -> NoReturn: - """ - Validate the config part of the Project object against a schema + """Validate the config part of the Project object against a schema. - :param peppy.Project project: a project object to validate - :param str | dict schema: schema dict to validate against or a path to one + Args: + project: A project object, dict, or path to config file to validate + schema: Schema dict to validate against or a path to one """ schema_dicts = read_schema(schema=schema) for schema_dict in schema_dicts: @@ -162,16 +162,16 @@ def validate_config( def _get_attr_values( obj: Mapping, attrlist: Union[str, List[str]] ) -> Union[None, List[str]]: - """ - Get value corresponding to each given attribute. - - :param Mapping obj: an object to get the attributes from - :param str | Iterable[str] attrlist: names of attributes to - retrieve values for - :return dict: value corresponding to - each named attribute; null if this Sample's value for the - attribute given by the argument to the "attrlist" parameter is - empty/null, or if this Sample lacks the indicated attribute + """Get value corresponding to each given attribute. + + Args: + obj: An object to get the attributes from + attrlist: Names of attributes to retrieve values for + + Returns: + Value corresponding to each named attribute; None if this Sample's + value for the attribute is empty/null, or if this Sample lacks the + indicated attribute """ # If attribute is None, then value is also None. if not attrlist: @@ -187,8 +187,7 @@ def validate_input_files( schemas: Union[str, dict], sample_name: Union[str, int, None] = None, ) -> None: - """ - Determine which of the required and optional files are missing. + """Determine which of the required and optional files are missing. The names of the attributes that are required and/or deemed as inputs are sourced from the schema, more specifically from `required_files` @@ -199,11 +198,14 @@ def validate_input_files( Note, this function also performs Sample object validation with jsonschema. - :param peppy.Project project: project that defines the samples to validate - :param str | dict schema: schema dict to validate against or a path to one - :param str | int sample_name: name or index of the sample to validate. If None, - validate all samples in the project - :raise PathAttrNotFoundError: if any required sample attribute is missing + Args: + project: Project that defines the samples to validate + schemas: Schema dict to validate against or a path to one + sample_name: Name or index of the sample to validate. If None, + validate all samples in the project + + Raises: + PathAttrNotFoundError: If any required sample attribute is missing """ if sample_name is None: @@ -252,13 +254,14 @@ def validate_input_files( def validate_original_samples( samples: Union[str, pd.DataFrame], schema: Union[str, dict] ) -> None: - """ - Validate the original samples from the csv table against a schema + """Validate the original samples from the csv table against a schema. - :param samples: the path to the sample table csv or the dataframe fom the table - :param str | dict schema: schema dict to validate against or a path to one + Args: + samples: The path to the sample table csv or the dataframe from the table + schema: Schema dict to validate against or a path to one - :raises EidoValidationError: if validation is unsuccessful + Raises: + EidoValidationError: If validation is unsuccessful """ if isinstance(samples, str): samples = pd.read_csv(samples) From 6f3477b8f7ce921ea8e59c6284254b4bac492924 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 01:32:31 +0000 Subject: [PATCH 122/165] Fix exception handling in validation module Addressed PR #501 feedback on exception handling: - Replaced bare except clause with specific exceptions (FileNotFoundError, IOError, OSError) - Added explanatory comments to exception handlers with pass statements - Improved error chain preservation with 'from e' syntax These changes make error handling more explicit and help prevent catching unexpected system exceptions like KeyboardInterrupt or SystemExit. --- peppy/eido/validation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py index cb01bc58..d62cfd67 100644 --- a/peppy/eido/validation.py +++ b/peppy/eido/validation.py @@ -136,11 +136,13 @@ def validate_config( try: del schema_cpy[PROP_KEY][SAMPLES_KEY] except KeyError: + # Schema doesn't have samples key, which is fine for config-only validation pass if "required" in schema_cpy: try: schema_cpy["required"].remove(SAMPLES_KEY) except ValueError: + # SAMPLES_KEY is not in required list, no action needed pass if isinstance(project, dict): _validate_object({"project": project}, schema_cpy) @@ -148,10 +150,10 @@ def validate_config( elif isinstance(project, str): try: project_dict = load_yaml(project) - except: + except (FileNotFoundError, IOError, OSError) as e: raise ValueError( f"Please provide a valid yaml config of PEP project; invalid config path: {project}" - ) + ) from e _validate_object({"project": project_dict}, schema_cpy) else: project_dict = project.to_dict() From 991cc23d85fbff06165b4d3bd53d2068441d6019 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 01:33:03 +0000 Subject: [PATCH 123/165] Run black formatter on eido module Applied black code formatter to ensure consistent code style across the eido module. Reformatted conversion.py and validation.py. --- peppy/eido/conversion.py | 4 +++- peppy/eido/validation.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/peppy/eido/conversion.py b/peppy/eido/conversion.py index 95365674..c73054d7 100644 --- a/peppy/eido/conversion.py +++ b/peppy/eido/conversion.py @@ -38,7 +38,9 @@ def pep_conversion_plugins() -> Dict[str, Callable]: return plugins -def convert_project(prj: Project, target_format: str, plugin_kwargs: Dict = None) -> Dict[str, str]: +def convert_project( + prj: Project, target_format: str, plugin_kwargs: Dict = None +) -> Dict[str, str]: """Convert a `peppy.Project` object to a selected format. Args: diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py index d62cfd67..cadef703 100644 --- a/peppy/eido/validation.py +++ b/peppy/eido/validation.py @@ -18,7 +18,11 @@ _LOGGER = getLogger(__name__) -def _validate_object(obj: Mapping, schema: Union[str, dict], sample_name_colname: Union[str, bool] = False) -> None: +def _validate_object( + obj: Mapping, + schema: Union[str, dict], + sample_name_colname: Union[str, bool] = False, +) -> None: """Generic function to validate object against a schema. Args: From 5935be21293a4272a577b7a5522d86a5b62c2a04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 03:15:17 +0000 Subject: [PATCH 124/165] Fix type hints to use Optional[Dict] instead of Dict = None Changed plugin_kwargs parameter type hints from `Dict = None` to `Optional[Dict] = None` in conversion.py functions. Using Dict = None is misleading since None is not a valid Dict type. Optional[Dict] correctly indicates the parameter can be either a Dict or None. This follows Python type hinting best practices. --- peppy/eido/conversion.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/peppy/eido/conversion.py b/peppy/eido/conversion.py index c73054d7..817ccfd5 100644 --- a/peppy/eido/conversion.py +++ b/peppy/eido/conversion.py @@ -8,7 +8,7 @@ import inspect import os from logging import getLogger -from typing import Callable, Dict, List, NoReturn +from typing import Callable, Dict, List, NoReturn, Optional from ..project import Project from .exceptions import EidoFilterError @@ -39,7 +39,7 @@ def pep_conversion_plugins() -> Dict[str, Callable]: def convert_project( - prj: Project, target_format: str, plugin_kwargs: Dict = None + prj: Project, target_format: str, plugin_kwargs: Optional[Dict] = None ) -> Dict[str, str]: """Convert a `peppy.Project` object to a selected format. @@ -58,7 +58,10 @@ def convert_project( def run_filter( - prj: Project, filter_name: str, verbose: bool = True, plugin_kwargs: Dict = None + prj: Project, + filter_name: str, + verbose: bool = True, + plugin_kwargs: Optional[Dict] = None, ) -> Dict[str, str]: """Run a selected filter on a peppy.Project object. From b87d8471ca20c5b24f013290e3fd3208c6d36ea8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 02:57:47 +0000 Subject: [PATCH 125/165] Add type hints to main peppy modules Added clear and explicit type hints to core peppy modules: - utils.py: Added return types and parameter types to all functions - exceptions.py: Added type hints to exception __init__ methods - sample.py: Added type hints to Sample class methods including __init__, get_sheet_dict, to_dict These additions improve code clarity and enable better IDE support and static analysis. --- peppy/exceptions.py | 5 +++-- peppy/sample.py | 8 ++++---- peppy/utils.py | 18 +++++++++--------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/peppy/exceptions.py b/peppy/exceptions.py index ec356f49..b4ea657e 100644 --- a/peppy/exceptions.py +++ b/peppy/exceptions.py @@ -2,6 +2,7 @@ from abc import ABCMeta from collections.abc import Iterable +from typing import Optional __all__ = [ "IllegalStateException", @@ -19,7 +20,7 @@ class PeppyError(Exception): __metaclass__ = ABCMeta - def __init__(self, msg): + def __init__(self, msg: str) -> None: super(PeppyError, self).__init__(msg) @@ -50,7 +51,7 @@ class RemoteYAMLError(PeppyError): class MissingAmendmentError(PeppyError): """Error when project config lacks a requested subproject.""" - def __init__(self, amendment, defined=None): + def __init__(self, amendment: str, defined: Optional[Iterable[str]] = None) -> None: """ Create exception with missing amendment request. diff --git a/peppy/sample.py b/peppy/sample.py index 8b662f7b..07a0b3bc 100644 --- a/peppy/sample.py +++ b/peppy/sample.py @@ -4,7 +4,7 @@ from copy import copy as cp from logging import getLogger from string import Formatter -from typing import Optional, Union +from typing import Any, Dict, Optional, Union import pandas as pd import yaml @@ -39,7 +39,7 @@ class Sample(SimpleAttMap): :param Mapping | pandas.core.series.Series series: Sample's data. """ - def __init__(self, series, prj=None): + def __init__(self, series: Union[Mapping, Series], prj: Optional[Any] = None) -> None: super(Sample, self).__init__() data = dict(series) @@ -75,7 +75,7 @@ def __init__(self, series, prj=None): self._derived_cols_done = [] self._attributes = list(series.keys()) - def get_sheet_dict(self): + def get_sheet_dict(self) -> Dict: """ Create a K-V pairs for items originally passed in via the sample sheet. This is useful for summarizing; it provides a representation of the @@ -87,7 +87,7 @@ def get_sheet_dict(self): """ return dict([[k, self[k]] for k in self._attributes]) - def to_dict(self, add_prj_ref=False): + def to_dict(self, add_prj_ref: bool = False) -> Dict: """ Serializes itself as dict object. diff --git a/peppy/utils.py b/peppy/utils.py index e0609289..9da40006 100644 --- a/peppy/utils.py +++ b/peppy/utils.py @@ -5,7 +5,7 @@ import posixpath as psp import re from collections import defaultdict -from typing import Dict, Mapping, Set, Type, Union +from typing import Any, Callable, Dict, Mapping, Optional, Set, Type, Union from urllib.request import urlopen import yaml @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) -def copy(obj): +def copy(obj: Any) -> Any: def copy(self): """ Copy self to a new object. @@ -30,7 +30,7 @@ def copy(self): return obj -def make_abs_via_cfg(maybe_relpath, cfg_path, check_exists=False): +def make_abs_via_cfg(maybe_relpath: str, cfg_path: str, check_exists: bool = False) -> str: """Ensure that a possibly relative path is absolute.""" if not isinstance(maybe_relpath, str): raise TypeError( @@ -56,7 +56,7 @@ def make_abs_via_cfg(maybe_relpath, cfg_path, check_exists=False): return abs_path -def grab_project_data(prj): +def grab_project_data(prj: Any) -> Mapping: """ From the given Project, grab Sample-independent data. @@ -133,7 +133,7 @@ def expand_paths(x: dict) -> dict: return x -def load_yaml(filepath): +def load_yaml(filepath: str) -> dict: """ Load a local or remote YAML file into a Python dict @@ -159,7 +159,7 @@ def load_yaml(filepath): return expand_paths(data) -def is_cfg_or_anno(file_path, formats=None): +def is_cfg_or_anno(file_path: Optional[str], formats: Optional[dict] = None) -> Optional[bool]: """ Determine if the input file seems to be a project config file (based on the file extension). :param str file_path: file path to examine @@ -185,7 +185,7 @@ def is_cfg_or_anno(file_path, formats=None): ) -def extract_custom_index_for_sample_table(pep_dictionary: Dict): +def extract_custom_index_for_sample_table(pep_dictionary: Dict) -> Optional[str]: """Extracts a custom index for the sample table if it exists""" return ( pep_dictionary[SAMPLE_TABLE_INDEX_KEY] @@ -194,7 +194,7 @@ def extract_custom_index_for_sample_table(pep_dictionary: Dict): ) -def extract_custom_index_for_subsample_table(pep_dictionary: Dict): +def extract_custom_index_for_subsample_table(pep_dictionary: Dict) -> Optional[str]: """Extracts a custom index for the subsample table if it exists""" return ( pep_dictionary[SUBSAMPLE_TABLE_INDEX_KEY] @@ -203,7 +203,7 @@ def extract_custom_index_for_subsample_table(pep_dictionary: Dict): ) -def unpopulated_env_var(paths: Set[str]): +def unpopulated_env_var(paths: Set[str]) -> None: """ Given a set of paths that may contain env vars, group by env var and print a warning for each group with the deepest common directory and From 09950b6d3a67f2ae756793477eb72dbde7af24e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 02:59:22 +0000 Subject: [PATCH 126/165] Convert peppy module docstrings to Google-style format Converted all docstrings in main peppy modules from reStructuredText style to Google-style format for improved readability and consistency: - utils.py: 10 functions converted - exceptions.py: 1 function converted - sample.py: 3 methods converted Google-style provides clearer structure with Args/Returns/Raises sections. --- peppy/exceptions.py | 8 +-- peppy/sample.py | 42 ++++++++------- peppy/utils.py | 123 +++++++++++++++++++++++++++++++------------- 3 files changed, 115 insertions(+), 58 deletions(-) diff --git a/peppy/exceptions.py b/peppy/exceptions.py index b4ea657e..d5fd3459 100644 --- a/peppy/exceptions.py +++ b/peppy/exceptions.py @@ -52,11 +52,11 @@ class MissingAmendmentError(PeppyError): """Error when project config lacks a requested subproject.""" def __init__(self, amendment: str, defined: Optional[Iterable[str]] = None) -> None: - """ - Create exception with missing amendment request. + """Create exception with missing amendment request. - :param str amendment: the requested (and missing) amendment - :param Iterable[str] defined: collection of names of defined amendment + Args: + amendment: The requested (and missing) amendment + defined: Collection of names of defined amendments """ msg = "Amendment '{}' not found".format(amendment) if isinstance(defined, Iterable): diff --git a/peppy/sample.py b/peppy/sample.py index 07a0b3bc..0c08d3d3 100644 --- a/peppy/sample.py +++ b/peppy/sample.py @@ -76,24 +76,27 @@ def __init__(self, series: Union[Mapping, Series], prj: Optional[Any] = None) -> self._attributes = list(series.keys()) def get_sheet_dict(self) -> Dict: - """ - Create a K-V pairs for items originally passed in via the sample sheet. + """Create K-V pairs for items originally passed in via the sample sheet. + This is useful for summarizing; it provides a representation of the sample that excludes things like config files and derived entries. - :return OrderedDict: mapping from name to value for data elements - originally provided via the sample sheet (i.e., the a map-like - representation of the instance, excluding derived items) + Returns: + Mapping from name to value for data elements originally provided + via the sample sheet (i.e., a map-like representation of the + instance, excluding derived items) """ return dict([[k, self[k]] for k in self._attributes]) def to_dict(self, add_prj_ref: bool = False) -> Dict: - """ - Serializes itself as dict object. + """Serializes itself as dict object. + + Args: + add_prj_ref: Whether the project reference bound to the Sample + object should be included in the dict representation - :param bool add_prj_ref: whether the project reference bound do the - Sample object should be included in the YAML representation - :return dict: dict representation of this Sample + Returns: + Dict representation of this Sample """ def _obj2dict(obj, name=None): @@ -136,16 +139,19 @@ def _obj2dict(obj, name=None): return serial def to_yaml( - self, path: Optional[str] = None, add_prj_ref=False + self, path: Optional[str] = None, add_prj_ref: bool = False ) -> Union[str, None]: - """ - Serializes itself in YAML format. Writes to file if path is provided, else returns string representation. + """Serializes itself in YAML format. + + Writes to file if path is provided, else returns string representation. + + Args: + path: A file path to write YAML to; defaults to None + add_prj_ref: Whether the project reference bound to the Sample + object should be included in the YAML representation - :param str path: A file path to write yaml to; provide this or - the subs_folder_path, defaults to None - :param bool add_prj_ref: whether the project reference bound do the - Sample object should be included in the YAML representation - :return str | None: returns string representation of sample yaml or None + Returns: + String representation of sample YAML or None if written to file """ serial = self.to_dict(add_prj_ref=add_prj_ref) if path: diff --git a/peppy/utils.py b/peppy/utils.py index 9da40006..4aaf5812 100644 --- a/peppy/utils.py +++ b/peppy/utils.py @@ -31,7 +31,20 @@ def copy(self): def make_abs_via_cfg(maybe_relpath: str, cfg_path: str, check_exists: bool = False) -> str: - """Ensure that a possibly relative path is absolute.""" + """Ensure that a possibly relative path is absolute. + + Args: + maybe_relpath: Path that may be relative + cfg_path: Path to configuration file + check_exists: Whether to verify the resulting path exists + + Returns: + Absolute path + + Raises: + TypeError: If maybe_relpath is not a string + OSError: If check_exists is True and path doesn't exist + """ if not isinstance(maybe_relpath, str): raise TypeError( "Attempting to ensure non-text value is absolute path: {} ({})".format( @@ -57,18 +70,23 @@ def make_abs_via_cfg(maybe_relpath: str, cfg_path: str, check_exists: bool = Fal def grab_project_data(prj: Any) -> Mapping: - """ - From the given Project, grab Sample-independent data. + """From the given Project, grab Sample-independent data. There are some aspects of a Project of which it's beneficial for a Sample to be aware, particularly for post-hoc analysis. Since Sample objects within a Project are mutually independent, though, each doesn't need to - know about any of the others. A Project manages its, Sample instances, + know about any of the others. A Project manages its Sample instances, so for each Sample knowledge of Project data is limited. This method facilitates adoption of that conceptual model. - :param Project prj: Project from which to grab data - :return Mapping: Sample-independent data sections from given Project + Args: + prj: Project from which to grab data + + Returns: + Sample-independent data sections from given Project + + Raises: + KeyError: If project lacks required config section """ if not prj: return {} @@ -80,16 +98,17 @@ def grab_project_data(prj: Any) -> Mapping: def make_list(arg: Union[list, str], obj_class: Type) -> list: - """ - Convert an object of predefined class to a list of objects of that class or - ensure a list is a list of objects of that class + """Convert an object of predefined class to a list or ensure list contains correct type. - :param list[obj] | obj arg: string or a list of strings to listify - :param str obj_class: name of the class of intrest + Args: + arg: Object or list of objects to listify + obj_class: Class that objects should be instances of - :return list: list of objects of the predefined class + Returns: + List of objects of the predefined class - :raise TypeError: if a faulty argument was provided + Raises: + TypeError: If a faulty argument was provided """ def _raise_faulty_arg(): @@ -109,22 +128,26 @@ def _raise_faulty_arg(): _raise_faulty_arg() -def _expandpath(path: str): - """ - Expand a filesystem path that may or may not contain user/env vars. +def _expandpath(path: str) -> str: + """Expand a filesystem path that may or may not contain user/env vars. - :param str path: path to expand - :return str: expanded version of input path + Args: + path: Path to expand + + Returns: + Expanded version of input path """ return os.path.expandvars(os.path.expanduser(path)) def expand_paths(x: dict) -> dict: - """ - Recursively expand paths in a dict. + """Recursively expand paths in a dict. - :param dict x: dict to expand - :return dict: dict with expanded paths + Args: + x: Dict to expand + + Returns: + Dict with expanded paths """ if isinstance(x, str): return expandpath(x) @@ -134,12 +157,16 @@ def expand_paths(x: dict) -> dict: def load_yaml(filepath: str) -> dict: - """ - Load a local or remote YAML file into a Python dict + """Load a local or remote YAML file into a Python dict. + + Args: + filepath: Path to the file to read - :param str filepath: path to the file to read - :raises RemoteYAMLError: if the remote YAML file reading fails - :return dict: read data + Returns: + Read data + + Raises: + RemoteYAMLError: If the remote YAML file reading fails """ if is_url(filepath): _LOGGER.debug(f"Got URL: {filepath}") @@ -160,12 +187,18 @@ def load_yaml(filepath: str) -> dict: def is_cfg_or_anno(file_path: Optional[str], formats: Optional[dict] = None) -> Optional[bool]: - """ - Determine if the input file seems to be a project config file (based on the file extension). - :param str file_path: file path to examine - :param dict formats: formats dict to use. Must include 'config' and 'annotation' keys. - :raise ValueError: if the file seems to be neither a config nor an annotation - :return bool: True if the file is a config, False if the file is an annotation + """Determine if the input file seems to be a project config file (based on extension). + + Args: + file_path: File path to examine + formats: Formats dict to use. Must include 'config' and 'annotation' keys + + Returns: + True if the file is a config, False if the file is an annotation, + None if file_path is None + + Raises: + ValueError: If the file seems to be neither a config nor an annotation """ formats_dict = formats or { "config": (".yaml", ".yml"), @@ -186,7 +219,14 @@ def is_cfg_or_anno(file_path: Optional[str], formats: Optional[dict] = None) -> def extract_custom_index_for_sample_table(pep_dictionary: Dict) -> Optional[str]: - """Extracts a custom index for the sample table if it exists""" + """Extracts a custom index for the sample table if it exists. + + Args: + pep_dictionary: PEP configuration dictionary + + Returns: + Custom index name or None if not specified + """ return ( pep_dictionary[SAMPLE_TABLE_INDEX_KEY] if SAMPLE_TABLE_INDEX_KEY in pep_dictionary @@ -195,7 +235,14 @@ def extract_custom_index_for_sample_table(pep_dictionary: Dict) -> Optional[str] def extract_custom_index_for_subsample_table(pep_dictionary: Dict) -> Optional[str]: - """Extracts a custom index for the subsample table if it exists""" + """Extracts a custom index for the subsample table if it exists. + + Args: + pep_dictionary: PEP configuration dictionary + + Returns: + Custom index name or None if not specified + """ return ( pep_dictionary[SUBSAMPLE_TABLE_INDEX_KEY] if SUBSAMPLE_TABLE_INDEX_KEY in pep_dictionary @@ -204,10 +251,14 @@ def extract_custom_index_for_subsample_table(pep_dictionary: Dict) -> Optional[s def unpopulated_env_var(paths: Set[str]) -> None: - """ + """Print warnings for unpopulated environment variables in paths. + Given a set of paths that may contain env vars, group by env var and print a warning for each group with the deepest common directory and the paths relative to that directory. + + Args: + paths: Set of paths that may contain environment variables """ _VAR_RE = re.compile(r"^\$(\w+)/(.*)$") groups: dict[str, list[str]] = defaultdict(list) From 44e663f2ed4ba771de1acd9a10df6047f9eab6e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Nov 2025 02:59:51 +0000 Subject: [PATCH 127/165] Run black formatter on peppy modules Applied black code formatter to ensure consistent code style across the peppy modules. Reformatted utils.py and sample.py. --- peppy/sample.py | 4 +++- peppy/utils.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/peppy/sample.py b/peppy/sample.py index 0c08d3d3..432cf41d 100644 --- a/peppy/sample.py +++ b/peppy/sample.py @@ -39,7 +39,9 @@ class Sample(SimpleAttMap): :param Mapping | pandas.core.series.Series series: Sample's data. """ - def __init__(self, series: Union[Mapping, Series], prj: Optional[Any] = None) -> None: + def __init__( + self, series: Union[Mapping, Series], prj: Optional[Any] = None + ) -> None: super(Sample, self).__init__() data = dict(series) diff --git a/peppy/utils.py b/peppy/utils.py index 4aaf5812..0b389a4c 100644 --- a/peppy/utils.py +++ b/peppy/utils.py @@ -30,7 +30,9 @@ def copy(self): return obj -def make_abs_via_cfg(maybe_relpath: str, cfg_path: str, check_exists: bool = False) -> str: +def make_abs_via_cfg( + maybe_relpath: str, cfg_path: str, check_exists: bool = False +) -> str: """Ensure that a possibly relative path is absolute. Args: @@ -186,7 +188,9 @@ def load_yaml(filepath: str) -> dict: return expand_paths(data) -def is_cfg_or_anno(file_path: Optional[str], formats: Optional[dict] = None) -> Optional[bool]: +def is_cfg_or_anno( + file_path: Optional[str], formats: Optional[dict] = None +) -> Optional[bool]: """Determine if the input file seems to be a project config file (based on extension). Args: From ad3c725f309dc28066fb0e34afed7230f6c56ecb Mon Sep 17 00:00:00 2001 From: "Ziyang \"Claude\" Hu" <33562602+ClaudeHu@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:50:39 -0500 Subject: [PATCH 128/165] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- peppy/eido/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/peppy/eido/schema.py b/peppy/eido/schema.py index 3e618392..851ddd29 100644 --- a/peppy/eido/schema.py +++ b/peppy/eido/schema.py @@ -45,7 +45,7 @@ def preprocess_schema(schema_dict: Dict) -> Dict: return schema_dict -def read_schema(schema: Union[str, Dict]) -> list[Dict]: +def read_schema(schema: Union[str, Dict]) -> List[Dict]: """Safely read schema from YAML-formatted file. If the schema imports any other schemas, they will be read recursively. From e8dc24c339d8d7b12c6d2ee386ce08dc79968fe7 Mon Sep 17 00:00:00 2001 From: "Ziyang \"Claude\" Hu" <33562602+ClaudeHu@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:51:25 -0500 Subject: [PATCH 129/165] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- peppy/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/peppy/utils.py b/peppy/utils.py index 0b389a4c..40600ef0 100644 --- a/peppy/utils.py +++ b/peppy/utils.py @@ -5,7 +5,7 @@ import posixpath as psp import re from collections import defaultdict -from typing import Any, Callable, Dict, Mapping, Optional, Set, Type, Union +from typing import Any, Dict, Mapping, Optional, Set, Type, Union from urllib.request import urlopen import yaml From a1116cdf2177e7eb90599990f81f596490123437 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Nov 2025 17:08:44 -0500 Subject: [PATCH 130/165] update based on feedbacks from Nathan and copilot --- peppy/cli.py | 25 ++++++++++++++----------- peppy/eido/inspection.py | 33 ++++++++++++++++++++++++++------- peppy/eido/validation.py | 1 + tests/conftest.py | 6 +++--- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/peppy/cli.py b/peppy/cli.py index a61201e2..bf54dbcd 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -36,24 +36,26 @@ def _parse_filter_args_str(input): ) -def print_error_summary(errors_by_type: Dict[str, List[Dict[str, str]]]): +def print_error_summary( + errors_by_type: Dict[str, List[Dict[str, str]]], _LOGGER: logging.Logger +): """Print a summary of errors, organized by error type""" n_error_types = len(errors_by_type) - print(f"Found {n_error_types} types of error:") - for type in errors_by_type: - n = len(errors_by_type[type]) - msg = f" - {type}: ({n} samples) " + _LOGGER.error(f"Found {n_error_types} types of error:") + for err_type, items in errors_by_type.items(): + n = len(items) + msg = f" - {err_type}: ({n} samples) " if n < 50: - msg += ", ".join([x["sample_name"] for x in errors_by_type[type]]) - print(msg) + msg += ", ".join(x["sample_name"] for x in items) + _LOGGER.error(msg) if len(errors_by_type) > 1: final_msg = f"Validation unsuccessful. {len(errors_by_type)} error types found." else: final_msg = f"Validation unsuccessful. {len(errors_by_type)} error type found." - print(final_msg) - return final_msg + _LOGGER.error(final_msg) + # return final_msg def main(): @@ -91,7 +93,7 @@ def main(): _LOGGER.info(f" - {filter_name}") sys.exit(0) if not "format" in args: - _LOGGER.info("The following arguments are required: --format") + _LOGGER.error("The following arguments are required: --format") sps[CONVERT_CMD].print_help(sys.stderr) sys.exit(1) if args.describe: @@ -138,6 +140,7 @@ def main(): try: args.sample_name = int(args.sample_name) except ValueError: + # If sample_name is not an integer, leave it as a string. pass _LOGGER.debug( f"Comparing Sample ('{args.pep}') in Project ('{args.pep}') " @@ -160,7 +163,7 @@ def main(): try: validator(*arguments) except EidoValidationError as e: - print_error_summary(e.errors_by_type) + print_error_summary(e.errors_by_type, _LOGGER) return False _LOGGER.info("Validation successful") sys.exit(0) diff --git a/peppy/eido/inspection.py b/peppy/eido/inspection.py index dcd52c8b..ecaf8fdc 100644 --- a/peppy/eido/inspection.py +++ b/peppy/eido/inspection.py @@ -66,6 +66,24 @@ def get_input_files_size( Raises: ValidationError: If any required sample attribute is missing """ + + def _compute_input_file_size(inputs: Iterable[str]) -> float: + """ + Compute total size of input files. + """ + with catch_warnings(record=True) as w: + total_bytes = sum( + size(f, size_str=False) or 0.0 + for f in inputs + if f != "" and f is not None + ) + if w: + _LOGGER.warning( + f"{len(w)} input files missing, job input size was " + f"not calculated accurately" + ) + return total_bytes / (1024**3) + if isinstance(schema, str): schema = read_schema(schema) @@ -84,13 +102,14 @@ def get_input_files_size( ) all_inputs.update(required_inputs) with catch_warnings(record=True) as w: - input_file_size = sum( - [ - size(f, size_str=False) or 0.0 - for f in all_inputs - if f != "" and f is not None - ] - ) / (1024**3) + # input_file_size = sum( + # [ + # size(f, size_str=False) or 0.0 + # for f in all_inputs + # if f != "" and f is not None + # ] + # ) / (1024**3) + input_file_size = _compute_input_file_size(all_inputs) if w: _LOGGER.warning( f"{len(w)} input files missing, job input size was " diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py index cadef703..01f3fe24 100644 --- a/peppy/eido/validation.py +++ b/peppy/eido/validation.py @@ -140,6 +140,7 @@ def validate_config( try: del schema_cpy[PROP_KEY][SAMPLES_KEY] except KeyError: + # It's fine if SAMPLES_KEY is not present; nothing to remove. # Schema doesn't have samples key, which is fine for config-only validation pass if "required" in schema_cpy: diff --git a/tests/conftest.py b/tests/conftest.py index 46d76fa3..ac9884d5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -176,9 +176,9 @@ def taxprofiler_csv_multiline_output(path_to_taxprofiler_csv_multiline_output): return data # This is broken unless I add na_filter=False. But it's a bad idea anyway, since # we're just using this for string comparison anyway... - return pd.read_csv( - path_to_taxprofiler_csv_multiline_output, na_filter=False - ).to_csv(path_or_buf=None, index=None) + # return pd.read_csv( + # path_to_taxprofiler_csv_multiline_output, na_filter=False + # ).to_csv(path_or_buf=None, index=None) @pytest.fixture From 7728b0c5ca023c43bb997add232f0bedc5bc2eca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Nov 2025 17:42:29 -0500 Subject: [PATCH 131/165] organize pytest into peppy and eido separately --- .../common/schemas/common_pep_validation.yaml | 0 .../peps/multiline_output/config.yaml | 0 .../multiline_output/multiline_output.csv | 0 .../peps/multiline_output}/samplesheet.csv | 0 .../peps/multiline_output}/subsamplesheet.csv | 0 .../multiple_subsamples/project_config.yaml | 0 .../multiple_subsamples}/sample_table.csv | 0 .../multiple_subsamples}/subsample_table1.csv | 0 .../multiple_subsamples}/subsample_table2.csv | 0 .../peps/pep_nextflow_taxprofiler/config.yaml | 0 .../peps/pep_nextflow_taxprofiler/output.csv | 0 .../pep_nextflow_taxprofiler}/samplesheet.csv | 0 .../peps/pep_schema_rel_path/config.yaml | 0 .../peps/pep_schema_rel_path/sample_sheet.csv | 0 .../peps/pep_with_fasta_column/config.yaml | 0 .../peps/pep_with_fasta_column/output.csv | 0 .../pep_with_fasta_column/samplesheet.csv | 0 .../pep_with_fasta_column/subsamplesheet.csv | 0 .../test_file_existing/project_config.yaml | 0 .../peps/test_file_existing/sample_table.csv | 0 .../test_file_existing/subsample_table.csv | 0 .../peps/test_pep/test_cfg.yaml | 0 .../peps/test_pep/test_sample_table.csv | 0 .../peps/value_check_pep/project_config.yaml | 0 .../peps/value_check_pep/sample_table.csv | 0 .../schemas/schema_test_file_exist.yaml | 0 .../{ => eidodata}/schemas/test_schema.yaml | 0 .../schemas/test_schema_imports.yaml | 0 .../schemas/test_schema_imports_rel_path.yaml | 0 .../schemas/test_schema_invalid.yaml | 0 .../test_schema_invalid_with_type.yaml | 0 .../schemas/test_schema_sample_invalid.yaml | 0 .../schemas/test_schema_samples.yaml | 0 .../schemas/value_check_schema.yaml | 0 .../example_peps-master/.gitignore | 0 .../.pre-commit-config.yaml | 0 .../example_peps-master/README.md | 0 .../example_peps-master/data/frog1_data.txt | 0 .../example_peps-master/data/frog1a_data.txt | 0 .../example_peps-master/data/frog1a_data2.txt | 0 .../example_peps-master/data/frog1b_data.txt | 0 .../example_peps-master/data/frog1b_data2.txt | 0 .../example_peps-master/data/frog1c_data.txt | 0 .../example_peps-master/data/frog1c_data2.txt | 0 .../example_peps-master/data/frog2_data.txt | 0 .../example_peps-master/data/frog2a_data.txt | 0 .../example_peps-master/data/frog2b_data.txt | 0 .../example_peps-master/data/frog3_data.txt | 0 .../example_peps-master/data/frog4_data.txt | 0 .../example_BiocProject/data/laminB1Lads.bed | 0 .../data/vistaEnhancers.bed | 0 .../example_BiocProject/project_config.yaml | 0 .../project_config_resize.yaml | 0 .../example_BiocProject/readBedFiles.R | 0 .../example_BiocProject/readBedFiles_resize.R | 0 .../example_BiocProject/sample_table.csv | 0 .../project_config.yaml | 0 .../readBedFilesExceptions.R | 0 .../sample_table.csv | 0 .../project_config.yaml | 0 .../project_config_resize.yaml | 0 .../readRemoteData.R | 0 .../readRemoteData_resize.R | 0 .../sample_table.csv | 0 .../example_amendments1/project_config.yaml | 0 .../example_amendments1/sample_table.csv | 0 .../sample_table_newLib.csv | 0 .../sample_table_newLib2.csv | 0 .../example_amendments1/sample_table_pre.csv | 0 .../example_amendments2/project_config.yaml | 0 .../example_amendments2/sample_table.csv | 0 .../sample_table_noFrog.csv | 0 .../example_amendments2/sample_table_pre.csv | 0 .../example_append/project_config.yaml | 0 .../example_append/sample_table.csv | 0 .../example_append/sample_table_pre.csv | 0 .../example_automerge/project_config.yaml | 0 .../example_automerge/sample_table.csv | 0 .../example_basic/project_config.yaml | 0 .../example_basic/sample_table.csv | 0 .../example_basic_sample_yaml/sample.yaml | 0 .../example_custom_index/project_config.yaml | 0 .../example_custom_index/sample_table.csv | 0 .../example_derive/project_config.yaml | 0 .../example_derive/sample_table.csv | 0 .../example_derive/sample_table_pre.csv | 0 .../example_derive_imply/project_config.yaml | 0 .../example_derive_imply/sample_table.csv | 0 .../example_derive_imply/sample_table_pre.csv | 0 .../example_duplicate/project_config.yaml | 0 .../example_duplicate/sample_table.csv | 0 .../example_imply/project_config.yaml | 0 .../example_imply/sample_table.csv | 0 .../example_imply/sample_table_pre.csv | 0 .../example_imports/project_config.yaml | 0 .../example_imports/project_config1.yaml | 0 .../example_imports/sample_table.csv | 0 .../project_config.yaml | 0 .../example_incorrect_index/sample_table.csv | 0 .../example_issue499/project_config.yaml | 0 .../example_issue499/sample_table.csv | 0 .../example_issue499/sample_table_pre.csv | 0 .../project_config.yaml | 0 .../example_missing_version/sample_table.csv | 0 .../project_config.yaml | 0 .../sample_table.csv | 0 .../subsample_table1.csv | 0 .../subsample_table2.csv | 0 .../project_config.yaml | 0 .../example_nextflow_config}/samplesheet.csv | 0 .../sample_table.csv | 0 .../project_config.yaml | 0 .../samplesheet.csv | 0 .../subsamplesheet.csv | 0 .../config.yaml | 0 .../samplesheet.csv | 0 .../samplesheet_schema.yaml | 0 .../test_nextflow_original_samplesheet.csv | 0 .../example_node_alias/README.md | 0 .../example_node_alias/project_config.yaml | 0 .../example_node_alias/project_config1.yaml | 0 .../example_node_alias/sample_table.csv | 0 .../example_noname/project_config.yaml | 0 .../example_noname/project_config_noname.yaml | 0 .../example_noname/sample_table.csv | 0 .../example_old/project_config.yaml | 0 .../example_old/sample_table.csv | 0 .../example_piface/annotation_sheet.csv | 0 .../example_piface/output_schema.yaml | 0 .../example_piface/output_schema_project.yaml | 0 .../example_piface/output_schema_sample.yaml | 0 .../pipeline_interface1_project.yaml | 0 .../pipeline_interface1_sample.yaml | 0 .../pipeline_interface2_project.yaml | 0 .../pipeline_interface2_sample.yaml | 0 .../example_piface/project_config.yaml | 0 .../example_piface/readData.R | 0 .../project.json | 0 .../example_remove/project_config.yaml | 0 .../example_remove/sample_table.csv | 0 .../project_config.yaml | 0 .../example_subsamples_none/sample_table.csv | 0 .../example_subtable1/project_config.yaml | 0 .../example_subtable1/sample_table.csv | 0 .../example_subtable1/subsample_table.csv | 0 .../example_subtable2/project_config.yaml | 0 .../example_subtable2/sample_table.csv | 0 .../example_subtable2/subsample_table.csv | 0 .../example_subtable3/project_config.yaml | 0 .../example_subtable3/sample_table.csv | 0 .../example_subtable3/subsample_table.csv | 0 .../example_subtable4/project_config.yaml | 0 .../example_subtable4/sample_table.csv | 0 .../example_subtable4/subsample_table.csv | 0 .../example_subtable5/project_config.yaml | 0 .../example_subtable5/sample_table.csv | 0 .../example_subtable5/subsample_table.csv | 0 .../project_config.yaml | 0 .../sample_table.csv | 0 .../subsample_table.csv | 0 .../example_subtables/project_config.yaml | 0 .../example_subtables/sample_table.csv | 0 .../example_subtables/subsample_table.csv | 0 .../example_subtables/subsample_table1.csv | 0 .../other_pipeline1/sample1_GSM2471255_1.bw | 0 .../other_pipeline1/sample1_GSM2471255_2.bw | 0 .../sample1/pipeline1/sample1_GSM2471255_1.bw | 0 .../sample1/pipeline1/sample1_GSM2471255_2.bw | 0 .../other_pipeline1/sample2_GSM2471300_1.bw | 0 .../other_pipeline1/sample2_GSM2471300_2.bw | 0 .../sample2/pipeline1/sample2_GSM2471300_1.bw | 0 .../sample2/pipeline1/sample2_GSM2471300_2.bw | 0 .../other_pipeline2/sample3_GSM2471249_1.bw | 0 .../other_pipeline2/sample3_GSM2471249_2.bw | 0 .../sample3/pipeline2/sample3_GSM2471249_1.bw | 0 .../sample3/pipeline2/sample3_GSM2471249_2.bw | 0 .../example_peps-master/subannotation.ipynb | 0 tests/{ => eidotests}/conftest.py | 78 ++----------------- tests/{ => eidotests}/test_conversions.py | 0 .../{ => eidotests}/test_schema_operations.py | 0 tests/{ => eidotests}/test_validations.py | 0 tests/peppytests/conftest.py | 70 +++++++++++++++++ tests/{ => peppytests}/test_Project.py | 68 +++++++++++++++- .../{smoketests => peppytests}/test_Sample.py | 1 - tests/smoketests/__init__.py | 0 tests/smoketests/test_Project.py | 69 ---------------- 186 files changed, 143 insertions(+), 143 deletions(-) rename tests/data/{ => eidodata}/common/schemas/common_pep_validation.yaml (100%) rename tests/data/{ => eidodata}/peps/multiline_output/config.yaml (100%) rename tests/data/{ => eidodata}/peps/multiline_output/multiline_output.csv (100%) rename tests/data/{example_peps-master/example_nextflow_subsamples => eidodata/peps/multiline_output}/samplesheet.csv (100%) rename tests/data/{example_peps-master/example_nextflow_subsamples => eidodata/peps/multiline_output}/subsamplesheet.csv (100%) rename tests/data/{ => eidodata}/peps/multiple_subsamples/project_config.yaml (100%) rename tests/data/{example_peps-master/example_multiple_subsamples => eidodata/peps/multiple_subsamples}/sample_table.csv (100%) rename tests/data/{example_peps-master/example_multiple_subsamples => eidodata/peps/multiple_subsamples}/subsample_table1.csv (100%) rename tests/data/{example_peps-master/example_multiple_subsamples => eidodata/peps/multiple_subsamples}/subsample_table2.csv (100%) rename tests/data/{ => eidodata}/peps/pep_nextflow_taxprofiler/config.yaml (100%) rename tests/data/{ => eidodata}/peps/pep_nextflow_taxprofiler/output.csv (100%) rename tests/data/{example_peps-master/example_nextflow_config => eidodata/peps/pep_nextflow_taxprofiler}/samplesheet.csv (100%) rename tests/data/{ => eidodata}/peps/pep_schema_rel_path/config.yaml (100%) rename tests/data/{ => eidodata}/peps/pep_schema_rel_path/sample_sheet.csv (100%) rename tests/data/{ => eidodata}/peps/pep_with_fasta_column/config.yaml (100%) rename tests/data/{ => eidodata}/peps/pep_with_fasta_column/output.csv (100%) rename tests/data/{ => eidodata}/peps/pep_with_fasta_column/samplesheet.csv (100%) rename tests/data/{ => eidodata}/peps/pep_with_fasta_column/subsamplesheet.csv (100%) rename tests/data/{ => eidodata}/peps/test_file_existing/project_config.yaml (100%) rename tests/data/{ => eidodata}/peps/test_file_existing/sample_table.csv (100%) rename tests/data/{ => eidodata}/peps/test_file_existing/subsample_table.csv (100%) rename tests/data/{ => eidodata}/peps/test_pep/test_cfg.yaml (100%) rename tests/data/{ => eidodata}/peps/test_pep/test_sample_table.csv (100%) rename tests/data/{ => eidodata}/peps/value_check_pep/project_config.yaml (100%) rename tests/data/{ => eidodata}/peps/value_check_pep/sample_table.csv (100%) rename tests/data/{ => eidodata}/schemas/schema_test_file_exist.yaml (100%) rename tests/data/{ => eidodata}/schemas/test_schema.yaml (100%) rename tests/data/{ => eidodata}/schemas/test_schema_imports.yaml (100%) rename tests/data/{ => eidodata}/schemas/test_schema_imports_rel_path.yaml (100%) rename tests/data/{ => eidodata}/schemas/test_schema_invalid.yaml (100%) rename tests/data/{ => eidodata}/schemas/test_schema_invalid_with_type.yaml (100%) rename tests/data/{ => eidodata}/schemas/test_schema_sample_invalid.yaml (100%) rename tests/data/{ => eidodata}/schemas/test_schema_samples.yaml (100%) rename tests/data/{ => eidodata}/schemas/value_check_schema.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/.gitignore (100%) rename tests/data/{ => peppydata}/example_peps-master/.pre-commit-config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/README.md (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog1_data.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog1a_data.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog1a_data2.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog1b_data.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog1b_data2.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog1c_data.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog1c_data2.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog2_data.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog2a_data.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog2b_data.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog3_data.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/data/frog4_data.txt (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject/data/laminB1Lads.bed (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject/data/vistaEnhancers.bed (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject/project_config_resize.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject/readBedFiles.R (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject/readBedFiles_resize.R (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject_exceptions/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject_exceptions/readBedFilesExceptions.R (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject_exceptions/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject_remote/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject_remote/project_config_resize.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject_remote/readRemoteData.R (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject_remote/readRemoteData_resize.R (100%) rename tests/data/{ => peppydata}/example_peps-master/example_BiocProject_remote/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_amendments1/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_amendments1/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_amendments1/sample_table_newLib.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_amendments1/sample_table_newLib2.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_amendments1/sample_table_pre.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_amendments2/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_amendments2/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_amendments2/sample_table_noFrog.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_amendments2/sample_table_pre.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_append/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_append/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_append/sample_table_pre.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_automerge/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_automerge/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_basic/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_basic/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_basic_sample_yaml/sample.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_custom_index/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_custom_index/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_derive/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_derive/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_derive/sample_table_pre.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_derive_imply/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_derive_imply/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_derive_imply/sample_table_pre.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_duplicate/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_duplicate/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_imply/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_imply/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_imply/sample_table_pre.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_imports/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_imports/project_config1.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_imports/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_incorrect_index/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_incorrect_index/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_issue499/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_issue499/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_issue499/sample_table_pre.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_missing_version/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_missing_version/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_multiple_subsamples/project_config.yaml (100%) rename tests/data/{peps/multiple_subsamples => peppydata/example_peps-master/example_multiple_subsamples}/sample_table.csv (100%) rename tests/data/{peps/multiple_subsamples => peppydata/example_peps-master/example_multiple_subsamples}/subsample_table1.csv (100%) rename tests/data/{peps/multiple_subsamples => peppydata/example_peps-master/example_multiple_subsamples}/subsample_table2.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_nextflow_config/project_config.yaml (100%) rename tests/data/{example_peps-master/example_nextflow_taxprofiler_pep => peppydata/example_peps-master/example_nextflow_config}/samplesheet.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_nextflow_samplesheet/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_nextflow_subsamples/project_config.yaml (100%) rename tests/data/{peps/multiline_output => peppydata/example_peps-master/example_nextflow_subsamples}/samplesheet.csv (100%) rename tests/data/{peps/multiline_output => peppydata/example_peps-master/example_nextflow_subsamples}/subsamplesheet.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_nextflow_taxprofiler_pep/config.yaml (100%) rename tests/data/{peps/pep_nextflow_taxprofiler => peppydata/example_peps-master/example_nextflow_taxprofiler_pep}/samplesheet.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_nextflow_taxprofiler_pep/samplesheet_schema.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_nextflow_taxprofiler_pep/test_nextflow_original_samplesheet.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_node_alias/README.md (100%) rename tests/data/{ => peppydata}/example_peps-master/example_node_alias/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_node_alias/project_config1.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_node_alias/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_noname/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_noname/project_config_noname.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_noname/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_old/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_old/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/annotation_sheet.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/output_schema.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/output_schema_project.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/output_schema_sample.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/pipeline_interface1_project.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/pipeline_interface1_sample.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/pipeline_interface2_project.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/pipeline_interface2_sample.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_piface/readData.R (100%) rename tests/data/{ => peppydata}/example_peps-master/example_project_as_dictionary/project.json (100%) rename tests/data/{ => peppydata}/example_peps-master/example_remove/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_remove/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subsamples_none/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subsamples_none/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable1/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable1/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable1/subsample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable2/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable2/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable2/subsample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable3/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable3/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable3/subsample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable4/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable4/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable4/subsample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable5/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable5/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable5/subsample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable_automerge/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable_automerge/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtable_automerge/subsample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtables/project_config.yaml (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtables/sample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtables/subsample_table.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/example_subtables/subsample_table1.csv (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_1.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_2.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_1.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_2.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_1.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_2.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_1.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_2.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_1.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_2.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_1.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_2.bw (100%) rename tests/data/{ => peppydata}/example_peps-master/subannotation.ipynb (100%) rename tests/{ => eidotests}/conftest.py (70%) rename tests/{ => eidotests}/test_conversions.py (100%) rename tests/{ => eidotests}/test_schema_operations.py (100%) rename tests/{ => eidotests}/test_validations.py (100%) create mode 100644 tests/peppytests/conftest.py rename tests/{ => peppytests}/test_Project.py (91%) rename tests/{smoketests => peppytests}/test_Sample.py (99%) delete mode 100644 tests/smoketests/__init__.py delete mode 100644 tests/smoketests/test_Project.py diff --git a/tests/data/common/schemas/common_pep_validation.yaml b/tests/data/eidodata/common/schemas/common_pep_validation.yaml similarity index 100% rename from tests/data/common/schemas/common_pep_validation.yaml rename to tests/data/eidodata/common/schemas/common_pep_validation.yaml diff --git a/tests/data/peps/multiline_output/config.yaml b/tests/data/eidodata/peps/multiline_output/config.yaml similarity index 100% rename from tests/data/peps/multiline_output/config.yaml rename to tests/data/eidodata/peps/multiline_output/config.yaml diff --git a/tests/data/peps/multiline_output/multiline_output.csv b/tests/data/eidodata/peps/multiline_output/multiline_output.csv similarity index 100% rename from tests/data/peps/multiline_output/multiline_output.csv rename to tests/data/eidodata/peps/multiline_output/multiline_output.csv diff --git a/tests/data/example_peps-master/example_nextflow_subsamples/samplesheet.csv b/tests/data/eidodata/peps/multiline_output/samplesheet.csv similarity index 100% rename from tests/data/example_peps-master/example_nextflow_subsamples/samplesheet.csv rename to tests/data/eidodata/peps/multiline_output/samplesheet.csv diff --git a/tests/data/example_peps-master/example_nextflow_subsamples/subsamplesheet.csv b/tests/data/eidodata/peps/multiline_output/subsamplesheet.csv similarity index 100% rename from tests/data/example_peps-master/example_nextflow_subsamples/subsamplesheet.csv rename to tests/data/eidodata/peps/multiline_output/subsamplesheet.csv diff --git a/tests/data/peps/multiple_subsamples/project_config.yaml b/tests/data/eidodata/peps/multiple_subsamples/project_config.yaml similarity index 100% rename from tests/data/peps/multiple_subsamples/project_config.yaml rename to tests/data/eidodata/peps/multiple_subsamples/project_config.yaml diff --git a/tests/data/example_peps-master/example_multiple_subsamples/sample_table.csv b/tests/data/eidodata/peps/multiple_subsamples/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_multiple_subsamples/sample_table.csv rename to tests/data/eidodata/peps/multiple_subsamples/sample_table.csv diff --git a/tests/data/example_peps-master/example_multiple_subsamples/subsample_table1.csv b/tests/data/eidodata/peps/multiple_subsamples/subsample_table1.csv similarity index 100% rename from tests/data/example_peps-master/example_multiple_subsamples/subsample_table1.csv rename to tests/data/eidodata/peps/multiple_subsamples/subsample_table1.csv diff --git a/tests/data/example_peps-master/example_multiple_subsamples/subsample_table2.csv b/tests/data/eidodata/peps/multiple_subsamples/subsample_table2.csv similarity index 100% rename from tests/data/example_peps-master/example_multiple_subsamples/subsample_table2.csv rename to tests/data/eidodata/peps/multiple_subsamples/subsample_table2.csv diff --git a/tests/data/peps/pep_nextflow_taxprofiler/config.yaml b/tests/data/eidodata/peps/pep_nextflow_taxprofiler/config.yaml similarity index 100% rename from tests/data/peps/pep_nextflow_taxprofiler/config.yaml rename to tests/data/eidodata/peps/pep_nextflow_taxprofiler/config.yaml diff --git a/tests/data/peps/pep_nextflow_taxprofiler/output.csv b/tests/data/eidodata/peps/pep_nextflow_taxprofiler/output.csv similarity index 100% rename from tests/data/peps/pep_nextflow_taxprofiler/output.csv rename to tests/data/eidodata/peps/pep_nextflow_taxprofiler/output.csv diff --git a/tests/data/example_peps-master/example_nextflow_config/samplesheet.csv b/tests/data/eidodata/peps/pep_nextflow_taxprofiler/samplesheet.csv similarity index 100% rename from tests/data/example_peps-master/example_nextflow_config/samplesheet.csv rename to tests/data/eidodata/peps/pep_nextflow_taxprofiler/samplesheet.csv diff --git a/tests/data/peps/pep_schema_rel_path/config.yaml b/tests/data/eidodata/peps/pep_schema_rel_path/config.yaml similarity index 100% rename from tests/data/peps/pep_schema_rel_path/config.yaml rename to tests/data/eidodata/peps/pep_schema_rel_path/config.yaml diff --git a/tests/data/peps/pep_schema_rel_path/sample_sheet.csv b/tests/data/eidodata/peps/pep_schema_rel_path/sample_sheet.csv similarity index 100% rename from tests/data/peps/pep_schema_rel_path/sample_sheet.csv rename to tests/data/eidodata/peps/pep_schema_rel_path/sample_sheet.csv diff --git a/tests/data/peps/pep_with_fasta_column/config.yaml b/tests/data/eidodata/peps/pep_with_fasta_column/config.yaml similarity index 100% rename from tests/data/peps/pep_with_fasta_column/config.yaml rename to tests/data/eidodata/peps/pep_with_fasta_column/config.yaml diff --git a/tests/data/peps/pep_with_fasta_column/output.csv b/tests/data/eidodata/peps/pep_with_fasta_column/output.csv similarity index 100% rename from tests/data/peps/pep_with_fasta_column/output.csv rename to tests/data/eidodata/peps/pep_with_fasta_column/output.csv diff --git a/tests/data/peps/pep_with_fasta_column/samplesheet.csv b/tests/data/eidodata/peps/pep_with_fasta_column/samplesheet.csv similarity index 100% rename from tests/data/peps/pep_with_fasta_column/samplesheet.csv rename to tests/data/eidodata/peps/pep_with_fasta_column/samplesheet.csv diff --git a/tests/data/peps/pep_with_fasta_column/subsamplesheet.csv b/tests/data/eidodata/peps/pep_with_fasta_column/subsamplesheet.csv similarity index 100% rename from tests/data/peps/pep_with_fasta_column/subsamplesheet.csv rename to tests/data/eidodata/peps/pep_with_fasta_column/subsamplesheet.csv diff --git a/tests/data/peps/test_file_existing/project_config.yaml b/tests/data/eidodata/peps/test_file_existing/project_config.yaml similarity index 100% rename from tests/data/peps/test_file_existing/project_config.yaml rename to tests/data/eidodata/peps/test_file_existing/project_config.yaml diff --git a/tests/data/peps/test_file_existing/sample_table.csv b/tests/data/eidodata/peps/test_file_existing/sample_table.csv similarity index 100% rename from tests/data/peps/test_file_existing/sample_table.csv rename to tests/data/eidodata/peps/test_file_existing/sample_table.csv diff --git a/tests/data/peps/test_file_existing/subsample_table.csv b/tests/data/eidodata/peps/test_file_existing/subsample_table.csv similarity index 100% rename from tests/data/peps/test_file_existing/subsample_table.csv rename to tests/data/eidodata/peps/test_file_existing/subsample_table.csv diff --git a/tests/data/peps/test_pep/test_cfg.yaml b/tests/data/eidodata/peps/test_pep/test_cfg.yaml similarity index 100% rename from tests/data/peps/test_pep/test_cfg.yaml rename to tests/data/eidodata/peps/test_pep/test_cfg.yaml diff --git a/tests/data/peps/test_pep/test_sample_table.csv b/tests/data/eidodata/peps/test_pep/test_sample_table.csv similarity index 100% rename from tests/data/peps/test_pep/test_sample_table.csv rename to tests/data/eidodata/peps/test_pep/test_sample_table.csv diff --git a/tests/data/peps/value_check_pep/project_config.yaml b/tests/data/eidodata/peps/value_check_pep/project_config.yaml similarity index 100% rename from tests/data/peps/value_check_pep/project_config.yaml rename to tests/data/eidodata/peps/value_check_pep/project_config.yaml diff --git a/tests/data/peps/value_check_pep/sample_table.csv b/tests/data/eidodata/peps/value_check_pep/sample_table.csv similarity index 100% rename from tests/data/peps/value_check_pep/sample_table.csv rename to tests/data/eidodata/peps/value_check_pep/sample_table.csv diff --git a/tests/data/schemas/schema_test_file_exist.yaml b/tests/data/eidodata/schemas/schema_test_file_exist.yaml similarity index 100% rename from tests/data/schemas/schema_test_file_exist.yaml rename to tests/data/eidodata/schemas/schema_test_file_exist.yaml diff --git a/tests/data/schemas/test_schema.yaml b/tests/data/eidodata/schemas/test_schema.yaml similarity index 100% rename from tests/data/schemas/test_schema.yaml rename to tests/data/eidodata/schemas/test_schema.yaml diff --git a/tests/data/schemas/test_schema_imports.yaml b/tests/data/eidodata/schemas/test_schema_imports.yaml similarity index 100% rename from tests/data/schemas/test_schema_imports.yaml rename to tests/data/eidodata/schemas/test_schema_imports.yaml diff --git a/tests/data/schemas/test_schema_imports_rel_path.yaml b/tests/data/eidodata/schemas/test_schema_imports_rel_path.yaml similarity index 100% rename from tests/data/schemas/test_schema_imports_rel_path.yaml rename to tests/data/eidodata/schemas/test_schema_imports_rel_path.yaml diff --git a/tests/data/schemas/test_schema_invalid.yaml b/tests/data/eidodata/schemas/test_schema_invalid.yaml similarity index 100% rename from tests/data/schemas/test_schema_invalid.yaml rename to tests/data/eidodata/schemas/test_schema_invalid.yaml diff --git a/tests/data/schemas/test_schema_invalid_with_type.yaml b/tests/data/eidodata/schemas/test_schema_invalid_with_type.yaml similarity index 100% rename from tests/data/schemas/test_schema_invalid_with_type.yaml rename to tests/data/eidodata/schemas/test_schema_invalid_with_type.yaml diff --git a/tests/data/schemas/test_schema_sample_invalid.yaml b/tests/data/eidodata/schemas/test_schema_sample_invalid.yaml similarity index 100% rename from tests/data/schemas/test_schema_sample_invalid.yaml rename to tests/data/eidodata/schemas/test_schema_sample_invalid.yaml diff --git a/tests/data/schemas/test_schema_samples.yaml b/tests/data/eidodata/schemas/test_schema_samples.yaml similarity index 100% rename from tests/data/schemas/test_schema_samples.yaml rename to tests/data/eidodata/schemas/test_schema_samples.yaml diff --git a/tests/data/schemas/value_check_schema.yaml b/tests/data/eidodata/schemas/value_check_schema.yaml similarity index 100% rename from tests/data/schemas/value_check_schema.yaml rename to tests/data/eidodata/schemas/value_check_schema.yaml diff --git a/tests/data/example_peps-master/.gitignore b/tests/data/peppydata/example_peps-master/.gitignore similarity index 100% rename from tests/data/example_peps-master/.gitignore rename to tests/data/peppydata/example_peps-master/.gitignore diff --git a/tests/data/example_peps-master/.pre-commit-config.yaml b/tests/data/peppydata/example_peps-master/.pre-commit-config.yaml similarity index 100% rename from tests/data/example_peps-master/.pre-commit-config.yaml rename to tests/data/peppydata/example_peps-master/.pre-commit-config.yaml diff --git a/tests/data/example_peps-master/README.md b/tests/data/peppydata/example_peps-master/README.md similarity index 100% rename from tests/data/example_peps-master/README.md rename to tests/data/peppydata/example_peps-master/README.md diff --git a/tests/data/example_peps-master/data/frog1_data.txt b/tests/data/peppydata/example_peps-master/data/frog1_data.txt similarity index 100% rename from tests/data/example_peps-master/data/frog1_data.txt rename to tests/data/peppydata/example_peps-master/data/frog1_data.txt diff --git a/tests/data/example_peps-master/data/frog1a_data.txt b/tests/data/peppydata/example_peps-master/data/frog1a_data.txt similarity index 100% rename from tests/data/example_peps-master/data/frog1a_data.txt rename to tests/data/peppydata/example_peps-master/data/frog1a_data.txt diff --git a/tests/data/example_peps-master/data/frog1a_data2.txt b/tests/data/peppydata/example_peps-master/data/frog1a_data2.txt similarity index 100% rename from tests/data/example_peps-master/data/frog1a_data2.txt rename to tests/data/peppydata/example_peps-master/data/frog1a_data2.txt diff --git a/tests/data/example_peps-master/data/frog1b_data.txt b/tests/data/peppydata/example_peps-master/data/frog1b_data.txt similarity index 100% rename from tests/data/example_peps-master/data/frog1b_data.txt rename to tests/data/peppydata/example_peps-master/data/frog1b_data.txt diff --git a/tests/data/example_peps-master/data/frog1b_data2.txt b/tests/data/peppydata/example_peps-master/data/frog1b_data2.txt similarity index 100% rename from tests/data/example_peps-master/data/frog1b_data2.txt rename to tests/data/peppydata/example_peps-master/data/frog1b_data2.txt diff --git a/tests/data/example_peps-master/data/frog1c_data.txt b/tests/data/peppydata/example_peps-master/data/frog1c_data.txt similarity index 100% rename from tests/data/example_peps-master/data/frog1c_data.txt rename to tests/data/peppydata/example_peps-master/data/frog1c_data.txt diff --git a/tests/data/example_peps-master/data/frog1c_data2.txt b/tests/data/peppydata/example_peps-master/data/frog1c_data2.txt similarity index 100% rename from tests/data/example_peps-master/data/frog1c_data2.txt rename to tests/data/peppydata/example_peps-master/data/frog1c_data2.txt diff --git a/tests/data/example_peps-master/data/frog2_data.txt b/tests/data/peppydata/example_peps-master/data/frog2_data.txt similarity index 100% rename from tests/data/example_peps-master/data/frog2_data.txt rename to tests/data/peppydata/example_peps-master/data/frog2_data.txt diff --git a/tests/data/example_peps-master/data/frog2a_data.txt b/tests/data/peppydata/example_peps-master/data/frog2a_data.txt similarity index 100% rename from tests/data/example_peps-master/data/frog2a_data.txt rename to tests/data/peppydata/example_peps-master/data/frog2a_data.txt diff --git a/tests/data/example_peps-master/data/frog2b_data.txt b/tests/data/peppydata/example_peps-master/data/frog2b_data.txt similarity index 100% rename from tests/data/example_peps-master/data/frog2b_data.txt rename to tests/data/peppydata/example_peps-master/data/frog2b_data.txt diff --git a/tests/data/example_peps-master/data/frog3_data.txt b/tests/data/peppydata/example_peps-master/data/frog3_data.txt similarity index 100% rename from tests/data/example_peps-master/data/frog3_data.txt rename to tests/data/peppydata/example_peps-master/data/frog3_data.txt diff --git a/tests/data/example_peps-master/data/frog4_data.txt b/tests/data/peppydata/example_peps-master/data/frog4_data.txt similarity index 100% rename from tests/data/example_peps-master/data/frog4_data.txt rename to tests/data/peppydata/example_peps-master/data/frog4_data.txt diff --git a/tests/data/example_peps-master/example_BiocProject/data/laminB1Lads.bed b/tests/data/peppydata/example_peps-master/example_BiocProject/data/laminB1Lads.bed similarity index 100% rename from tests/data/example_peps-master/example_BiocProject/data/laminB1Lads.bed rename to tests/data/peppydata/example_peps-master/example_BiocProject/data/laminB1Lads.bed diff --git a/tests/data/example_peps-master/example_BiocProject/data/vistaEnhancers.bed b/tests/data/peppydata/example_peps-master/example_BiocProject/data/vistaEnhancers.bed similarity index 100% rename from tests/data/example_peps-master/example_BiocProject/data/vistaEnhancers.bed rename to tests/data/peppydata/example_peps-master/example_BiocProject/data/vistaEnhancers.bed diff --git a/tests/data/example_peps-master/example_BiocProject/project_config.yaml b/tests/data/peppydata/example_peps-master/example_BiocProject/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_BiocProject/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_BiocProject/project_config.yaml diff --git a/tests/data/example_peps-master/example_BiocProject/project_config_resize.yaml b/tests/data/peppydata/example_peps-master/example_BiocProject/project_config_resize.yaml similarity index 100% rename from tests/data/example_peps-master/example_BiocProject/project_config_resize.yaml rename to tests/data/peppydata/example_peps-master/example_BiocProject/project_config_resize.yaml diff --git a/tests/data/example_peps-master/example_BiocProject/readBedFiles.R b/tests/data/peppydata/example_peps-master/example_BiocProject/readBedFiles.R similarity index 100% rename from tests/data/example_peps-master/example_BiocProject/readBedFiles.R rename to tests/data/peppydata/example_peps-master/example_BiocProject/readBedFiles.R diff --git a/tests/data/example_peps-master/example_BiocProject/readBedFiles_resize.R b/tests/data/peppydata/example_peps-master/example_BiocProject/readBedFiles_resize.R similarity index 100% rename from tests/data/example_peps-master/example_BiocProject/readBedFiles_resize.R rename to tests/data/peppydata/example_peps-master/example_BiocProject/readBedFiles_resize.R diff --git a/tests/data/example_peps-master/example_BiocProject/sample_table.csv b/tests/data/peppydata/example_peps-master/example_BiocProject/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_BiocProject/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_BiocProject/sample_table.csv diff --git a/tests/data/example_peps-master/example_BiocProject_exceptions/project_config.yaml b/tests/data/peppydata/example_peps-master/example_BiocProject_exceptions/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_BiocProject_exceptions/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_BiocProject_exceptions/project_config.yaml diff --git a/tests/data/example_peps-master/example_BiocProject_exceptions/readBedFilesExceptions.R b/tests/data/peppydata/example_peps-master/example_BiocProject_exceptions/readBedFilesExceptions.R similarity index 100% rename from tests/data/example_peps-master/example_BiocProject_exceptions/readBedFilesExceptions.R rename to tests/data/peppydata/example_peps-master/example_BiocProject_exceptions/readBedFilesExceptions.R diff --git a/tests/data/example_peps-master/example_BiocProject_exceptions/sample_table.csv b/tests/data/peppydata/example_peps-master/example_BiocProject_exceptions/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_BiocProject_exceptions/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_BiocProject_exceptions/sample_table.csv diff --git a/tests/data/example_peps-master/example_BiocProject_remote/project_config.yaml b/tests/data/peppydata/example_peps-master/example_BiocProject_remote/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_BiocProject_remote/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_BiocProject_remote/project_config.yaml diff --git a/tests/data/example_peps-master/example_BiocProject_remote/project_config_resize.yaml b/tests/data/peppydata/example_peps-master/example_BiocProject_remote/project_config_resize.yaml similarity index 100% rename from tests/data/example_peps-master/example_BiocProject_remote/project_config_resize.yaml rename to tests/data/peppydata/example_peps-master/example_BiocProject_remote/project_config_resize.yaml diff --git a/tests/data/example_peps-master/example_BiocProject_remote/readRemoteData.R b/tests/data/peppydata/example_peps-master/example_BiocProject_remote/readRemoteData.R similarity index 100% rename from tests/data/example_peps-master/example_BiocProject_remote/readRemoteData.R rename to tests/data/peppydata/example_peps-master/example_BiocProject_remote/readRemoteData.R diff --git a/tests/data/example_peps-master/example_BiocProject_remote/readRemoteData_resize.R b/tests/data/peppydata/example_peps-master/example_BiocProject_remote/readRemoteData_resize.R similarity index 100% rename from tests/data/example_peps-master/example_BiocProject_remote/readRemoteData_resize.R rename to tests/data/peppydata/example_peps-master/example_BiocProject_remote/readRemoteData_resize.R diff --git a/tests/data/example_peps-master/example_BiocProject_remote/sample_table.csv b/tests/data/peppydata/example_peps-master/example_BiocProject_remote/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_BiocProject_remote/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_BiocProject_remote/sample_table.csv diff --git a/tests/data/example_peps-master/example_amendments1/project_config.yaml b/tests/data/peppydata/example_peps-master/example_amendments1/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_amendments1/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_amendments1/project_config.yaml diff --git a/tests/data/example_peps-master/example_amendments1/sample_table.csv b/tests/data/peppydata/example_peps-master/example_amendments1/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_amendments1/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_amendments1/sample_table.csv diff --git a/tests/data/example_peps-master/example_amendments1/sample_table_newLib.csv b/tests/data/peppydata/example_peps-master/example_amendments1/sample_table_newLib.csv similarity index 100% rename from tests/data/example_peps-master/example_amendments1/sample_table_newLib.csv rename to tests/data/peppydata/example_peps-master/example_amendments1/sample_table_newLib.csv diff --git a/tests/data/example_peps-master/example_amendments1/sample_table_newLib2.csv b/tests/data/peppydata/example_peps-master/example_amendments1/sample_table_newLib2.csv similarity index 100% rename from tests/data/example_peps-master/example_amendments1/sample_table_newLib2.csv rename to tests/data/peppydata/example_peps-master/example_amendments1/sample_table_newLib2.csv diff --git a/tests/data/example_peps-master/example_amendments1/sample_table_pre.csv b/tests/data/peppydata/example_peps-master/example_amendments1/sample_table_pre.csv similarity index 100% rename from tests/data/example_peps-master/example_amendments1/sample_table_pre.csv rename to tests/data/peppydata/example_peps-master/example_amendments1/sample_table_pre.csv diff --git a/tests/data/example_peps-master/example_amendments2/project_config.yaml b/tests/data/peppydata/example_peps-master/example_amendments2/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_amendments2/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_amendments2/project_config.yaml diff --git a/tests/data/example_peps-master/example_amendments2/sample_table.csv b/tests/data/peppydata/example_peps-master/example_amendments2/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_amendments2/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_amendments2/sample_table.csv diff --git a/tests/data/example_peps-master/example_amendments2/sample_table_noFrog.csv b/tests/data/peppydata/example_peps-master/example_amendments2/sample_table_noFrog.csv similarity index 100% rename from tests/data/example_peps-master/example_amendments2/sample_table_noFrog.csv rename to tests/data/peppydata/example_peps-master/example_amendments2/sample_table_noFrog.csv diff --git a/tests/data/example_peps-master/example_amendments2/sample_table_pre.csv b/tests/data/peppydata/example_peps-master/example_amendments2/sample_table_pre.csv similarity index 100% rename from tests/data/example_peps-master/example_amendments2/sample_table_pre.csv rename to tests/data/peppydata/example_peps-master/example_amendments2/sample_table_pre.csv diff --git a/tests/data/example_peps-master/example_append/project_config.yaml b/tests/data/peppydata/example_peps-master/example_append/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_append/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_append/project_config.yaml diff --git a/tests/data/example_peps-master/example_append/sample_table.csv b/tests/data/peppydata/example_peps-master/example_append/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_append/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_append/sample_table.csv diff --git a/tests/data/example_peps-master/example_append/sample_table_pre.csv b/tests/data/peppydata/example_peps-master/example_append/sample_table_pre.csv similarity index 100% rename from tests/data/example_peps-master/example_append/sample_table_pre.csv rename to tests/data/peppydata/example_peps-master/example_append/sample_table_pre.csv diff --git a/tests/data/example_peps-master/example_automerge/project_config.yaml b/tests/data/peppydata/example_peps-master/example_automerge/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_automerge/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_automerge/project_config.yaml diff --git a/tests/data/example_peps-master/example_automerge/sample_table.csv b/tests/data/peppydata/example_peps-master/example_automerge/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_automerge/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_automerge/sample_table.csv diff --git a/tests/data/example_peps-master/example_basic/project_config.yaml b/tests/data/peppydata/example_peps-master/example_basic/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_basic/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_basic/project_config.yaml diff --git a/tests/data/example_peps-master/example_basic/sample_table.csv b/tests/data/peppydata/example_peps-master/example_basic/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_basic/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_basic/sample_table.csv diff --git a/tests/data/example_peps-master/example_basic_sample_yaml/sample.yaml b/tests/data/peppydata/example_peps-master/example_basic_sample_yaml/sample.yaml similarity index 100% rename from tests/data/example_peps-master/example_basic_sample_yaml/sample.yaml rename to tests/data/peppydata/example_peps-master/example_basic_sample_yaml/sample.yaml diff --git a/tests/data/example_peps-master/example_custom_index/project_config.yaml b/tests/data/peppydata/example_peps-master/example_custom_index/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_custom_index/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_custom_index/project_config.yaml diff --git a/tests/data/example_peps-master/example_custom_index/sample_table.csv b/tests/data/peppydata/example_peps-master/example_custom_index/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_custom_index/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_custom_index/sample_table.csv diff --git a/tests/data/example_peps-master/example_derive/project_config.yaml b/tests/data/peppydata/example_peps-master/example_derive/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_derive/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_derive/project_config.yaml diff --git a/tests/data/example_peps-master/example_derive/sample_table.csv b/tests/data/peppydata/example_peps-master/example_derive/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_derive/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_derive/sample_table.csv diff --git a/tests/data/example_peps-master/example_derive/sample_table_pre.csv b/tests/data/peppydata/example_peps-master/example_derive/sample_table_pre.csv similarity index 100% rename from tests/data/example_peps-master/example_derive/sample_table_pre.csv rename to tests/data/peppydata/example_peps-master/example_derive/sample_table_pre.csv diff --git a/tests/data/example_peps-master/example_derive_imply/project_config.yaml b/tests/data/peppydata/example_peps-master/example_derive_imply/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_derive_imply/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_derive_imply/project_config.yaml diff --git a/tests/data/example_peps-master/example_derive_imply/sample_table.csv b/tests/data/peppydata/example_peps-master/example_derive_imply/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_derive_imply/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_derive_imply/sample_table.csv diff --git a/tests/data/example_peps-master/example_derive_imply/sample_table_pre.csv b/tests/data/peppydata/example_peps-master/example_derive_imply/sample_table_pre.csv similarity index 100% rename from tests/data/example_peps-master/example_derive_imply/sample_table_pre.csv rename to tests/data/peppydata/example_peps-master/example_derive_imply/sample_table_pre.csv diff --git a/tests/data/example_peps-master/example_duplicate/project_config.yaml b/tests/data/peppydata/example_peps-master/example_duplicate/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_duplicate/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_duplicate/project_config.yaml diff --git a/tests/data/example_peps-master/example_duplicate/sample_table.csv b/tests/data/peppydata/example_peps-master/example_duplicate/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_duplicate/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_duplicate/sample_table.csv diff --git a/tests/data/example_peps-master/example_imply/project_config.yaml b/tests/data/peppydata/example_peps-master/example_imply/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_imply/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_imply/project_config.yaml diff --git a/tests/data/example_peps-master/example_imply/sample_table.csv b/tests/data/peppydata/example_peps-master/example_imply/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_imply/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_imply/sample_table.csv diff --git a/tests/data/example_peps-master/example_imply/sample_table_pre.csv b/tests/data/peppydata/example_peps-master/example_imply/sample_table_pre.csv similarity index 100% rename from tests/data/example_peps-master/example_imply/sample_table_pre.csv rename to tests/data/peppydata/example_peps-master/example_imply/sample_table_pre.csv diff --git a/tests/data/example_peps-master/example_imports/project_config.yaml b/tests/data/peppydata/example_peps-master/example_imports/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_imports/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_imports/project_config.yaml diff --git a/tests/data/example_peps-master/example_imports/project_config1.yaml b/tests/data/peppydata/example_peps-master/example_imports/project_config1.yaml similarity index 100% rename from tests/data/example_peps-master/example_imports/project_config1.yaml rename to tests/data/peppydata/example_peps-master/example_imports/project_config1.yaml diff --git a/tests/data/example_peps-master/example_imports/sample_table.csv b/tests/data/peppydata/example_peps-master/example_imports/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_imports/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_imports/sample_table.csv diff --git a/tests/data/example_peps-master/example_incorrect_index/project_config.yaml b/tests/data/peppydata/example_peps-master/example_incorrect_index/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_incorrect_index/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_incorrect_index/project_config.yaml diff --git a/tests/data/example_peps-master/example_incorrect_index/sample_table.csv b/tests/data/peppydata/example_peps-master/example_incorrect_index/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_incorrect_index/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_incorrect_index/sample_table.csv diff --git a/tests/data/example_peps-master/example_issue499/project_config.yaml b/tests/data/peppydata/example_peps-master/example_issue499/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_issue499/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_issue499/project_config.yaml diff --git a/tests/data/example_peps-master/example_issue499/sample_table.csv b/tests/data/peppydata/example_peps-master/example_issue499/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_issue499/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_issue499/sample_table.csv diff --git a/tests/data/example_peps-master/example_issue499/sample_table_pre.csv b/tests/data/peppydata/example_peps-master/example_issue499/sample_table_pre.csv similarity index 100% rename from tests/data/example_peps-master/example_issue499/sample_table_pre.csv rename to tests/data/peppydata/example_peps-master/example_issue499/sample_table_pre.csv diff --git a/tests/data/example_peps-master/example_missing_version/project_config.yaml b/tests/data/peppydata/example_peps-master/example_missing_version/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_missing_version/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_missing_version/project_config.yaml diff --git a/tests/data/example_peps-master/example_missing_version/sample_table.csv b/tests/data/peppydata/example_peps-master/example_missing_version/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_missing_version/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_missing_version/sample_table.csv diff --git a/tests/data/example_peps-master/example_multiple_subsamples/project_config.yaml b/tests/data/peppydata/example_peps-master/example_multiple_subsamples/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_multiple_subsamples/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_multiple_subsamples/project_config.yaml diff --git a/tests/data/peps/multiple_subsamples/sample_table.csv b/tests/data/peppydata/example_peps-master/example_multiple_subsamples/sample_table.csv similarity index 100% rename from tests/data/peps/multiple_subsamples/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_multiple_subsamples/sample_table.csv diff --git a/tests/data/peps/multiple_subsamples/subsample_table1.csv b/tests/data/peppydata/example_peps-master/example_multiple_subsamples/subsample_table1.csv similarity index 100% rename from tests/data/peps/multiple_subsamples/subsample_table1.csv rename to tests/data/peppydata/example_peps-master/example_multiple_subsamples/subsample_table1.csv diff --git a/tests/data/peps/multiple_subsamples/subsample_table2.csv b/tests/data/peppydata/example_peps-master/example_multiple_subsamples/subsample_table2.csv similarity index 100% rename from tests/data/peps/multiple_subsamples/subsample_table2.csv rename to tests/data/peppydata/example_peps-master/example_multiple_subsamples/subsample_table2.csv diff --git a/tests/data/example_peps-master/example_nextflow_config/project_config.yaml b/tests/data/peppydata/example_peps-master/example_nextflow_config/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_nextflow_config/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_nextflow_config/project_config.yaml diff --git a/tests/data/example_peps-master/example_nextflow_taxprofiler_pep/samplesheet.csv b/tests/data/peppydata/example_peps-master/example_nextflow_config/samplesheet.csv similarity index 100% rename from tests/data/example_peps-master/example_nextflow_taxprofiler_pep/samplesheet.csv rename to tests/data/peppydata/example_peps-master/example_nextflow_config/samplesheet.csv diff --git a/tests/data/example_peps-master/example_nextflow_samplesheet/sample_table.csv b/tests/data/peppydata/example_peps-master/example_nextflow_samplesheet/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_nextflow_samplesheet/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_nextflow_samplesheet/sample_table.csv diff --git a/tests/data/example_peps-master/example_nextflow_subsamples/project_config.yaml b/tests/data/peppydata/example_peps-master/example_nextflow_subsamples/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_nextflow_subsamples/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_nextflow_subsamples/project_config.yaml diff --git a/tests/data/peps/multiline_output/samplesheet.csv b/tests/data/peppydata/example_peps-master/example_nextflow_subsamples/samplesheet.csv similarity index 100% rename from tests/data/peps/multiline_output/samplesheet.csv rename to tests/data/peppydata/example_peps-master/example_nextflow_subsamples/samplesheet.csv diff --git a/tests/data/peps/multiline_output/subsamplesheet.csv b/tests/data/peppydata/example_peps-master/example_nextflow_subsamples/subsamplesheet.csv similarity index 100% rename from tests/data/peps/multiline_output/subsamplesheet.csv rename to tests/data/peppydata/example_peps-master/example_nextflow_subsamples/subsamplesheet.csv diff --git a/tests/data/example_peps-master/example_nextflow_taxprofiler_pep/config.yaml b/tests/data/peppydata/example_peps-master/example_nextflow_taxprofiler_pep/config.yaml similarity index 100% rename from tests/data/example_peps-master/example_nextflow_taxprofiler_pep/config.yaml rename to tests/data/peppydata/example_peps-master/example_nextflow_taxprofiler_pep/config.yaml diff --git a/tests/data/peps/pep_nextflow_taxprofiler/samplesheet.csv b/tests/data/peppydata/example_peps-master/example_nextflow_taxprofiler_pep/samplesheet.csv similarity index 100% rename from tests/data/peps/pep_nextflow_taxprofiler/samplesheet.csv rename to tests/data/peppydata/example_peps-master/example_nextflow_taxprofiler_pep/samplesheet.csv diff --git a/tests/data/example_peps-master/example_nextflow_taxprofiler_pep/samplesheet_schema.yaml b/tests/data/peppydata/example_peps-master/example_nextflow_taxprofiler_pep/samplesheet_schema.yaml similarity index 100% rename from tests/data/example_peps-master/example_nextflow_taxprofiler_pep/samplesheet_schema.yaml rename to tests/data/peppydata/example_peps-master/example_nextflow_taxprofiler_pep/samplesheet_schema.yaml diff --git a/tests/data/example_peps-master/example_nextflow_taxprofiler_pep/test_nextflow_original_samplesheet.csv b/tests/data/peppydata/example_peps-master/example_nextflow_taxprofiler_pep/test_nextflow_original_samplesheet.csv similarity index 100% rename from tests/data/example_peps-master/example_nextflow_taxprofiler_pep/test_nextflow_original_samplesheet.csv rename to tests/data/peppydata/example_peps-master/example_nextflow_taxprofiler_pep/test_nextflow_original_samplesheet.csv diff --git a/tests/data/example_peps-master/example_node_alias/README.md b/tests/data/peppydata/example_peps-master/example_node_alias/README.md similarity index 100% rename from tests/data/example_peps-master/example_node_alias/README.md rename to tests/data/peppydata/example_peps-master/example_node_alias/README.md diff --git a/tests/data/example_peps-master/example_node_alias/project_config.yaml b/tests/data/peppydata/example_peps-master/example_node_alias/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_node_alias/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_node_alias/project_config.yaml diff --git a/tests/data/example_peps-master/example_node_alias/project_config1.yaml b/tests/data/peppydata/example_peps-master/example_node_alias/project_config1.yaml similarity index 100% rename from tests/data/example_peps-master/example_node_alias/project_config1.yaml rename to tests/data/peppydata/example_peps-master/example_node_alias/project_config1.yaml diff --git a/tests/data/example_peps-master/example_node_alias/sample_table.csv b/tests/data/peppydata/example_peps-master/example_node_alias/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_node_alias/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_node_alias/sample_table.csv diff --git a/tests/data/example_peps-master/example_noname/project_config.yaml b/tests/data/peppydata/example_peps-master/example_noname/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_noname/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_noname/project_config.yaml diff --git a/tests/data/example_peps-master/example_noname/project_config_noname.yaml b/tests/data/peppydata/example_peps-master/example_noname/project_config_noname.yaml similarity index 100% rename from tests/data/example_peps-master/example_noname/project_config_noname.yaml rename to tests/data/peppydata/example_peps-master/example_noname/project_config_noname.yaml diff --git a/tests/data/example_peps-master/example_noname/sample_table.csv b/tests/data/peppydata/example_peps-master/example_noname/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_noname/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_noname/sample_table.csv diff --git a/tests/data/example_peps-master/example_old/project_config.yaml b/tests/data/peppydata/example_peps-master/example_old/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_old/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_old/project_config.yaml diff --git a/tests/data/example_peps-master/example_old/sample_table.csv b/tests/data/peppydata/example_peps-master/example_old/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_old/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_old/sample_table.csv diff --git a/tests/data/example_peps-master/example_piface/annotation_sheet.csv b/tests/data/peppydata/example_peps-master/example_piface/annotation_sheet.csv similarity index 100% rename from tests/data/example_peps-master/example_piface/annotation_sheet.csv rename to tests/data/peppydata/example_peps-master/example_piface/annotation_sheet.csv diff --git a/tests/data/example_peps-master/example_piface/output_schema.yaml b/tests/data/peppydata/example_peps-master/example_piface/output_schema.yaml similarity index 100% rename from tests/data/example_peps-master/example_piface/output_schema.yaml rename to tests/data/peppydata/example_peps-master/example_piface/output_schema.yaml diff --git a/tests/data/example_peps-master/example_piface/output_schema_project.yaml b/tests/data/peppydata/example_peps-master/example_piface/output_schema_project.yaml similarity index 100% rename from tests/data/example_peps-master/example_piface/output_schema_project.yaml rename to tests/data/peppydata/example_peps-master/example_piface/output_schema_project.yaml diff --git a/tests/data/example_peps-master/example_piface/output_schema_sample.yaml b/tests/data/peppydata/example_peps-master/example_piface/output_schema_sample.yaml similarity index 100% rename from tests/data/example_peps-master/example_piface/output_schema_sample.yaml rename to tests/data/peppydata/example_peps-master/example_piface/output_schema_sample.yaml diff --git a/tests/data/example_peps-master/example_piface/pipeline_interface1_project.yaml b/tests/data/peppydata/example_peps-master/example_piface/pipeline_interface1_project.yaml similarity index 100% rename from tests/data/example_peps-master/example_piface/pipeline_interface1_project.yaml rename to tests/data/peppydata/example_peps-master/example_piface/pipeline_interface1_project.yaml diff --git a/tests/data/example_peps-master/example_piface/pipeline_interface1_sample.yaml b/tests/data/peppydata/example_peps-master/example_piface/pipeline_interface1_sample.yaml similarity index 100% rename from tests/data/example_peps-master/example_piface/pipeline_interface1_sample.yaml rename to tests/data/peppydata/example_peps-master/example_piface/pipeline_interface1_sample.yaml diff --git a/tests/data/example_peps-master/example_piface/pipeline_interface2_project.yaml b/tests/data/peppydata/example_peps-master/example_piface/pipeline_interface2_project.yaml similarity index 100% rename from tests/data/example_peps-master/example_piface/pipeline_interface2_project.yaml rename to tests/data/peppydata/example_peps-master/example_piface/pipeline_interface2_project.yaml diff --git a/tests/data/example_peps-master/example_piface/pipeline_interface2_sample.yaml b/tests/data/peppydata/example_peps-master/example_piface/pipeline_interface2_sample.yaml similarity index 100% rename from tests/data/example_peps-master/example_piface/pipeline_interface2_sample.yaml rename to tests/data/peppydata/example_peps-master/example_piface/pipeline_interface2_sample.yaml diff --git a/tests/data/example_peps-master/example_piface/project_config.yaml b/tests/data/peppydata/example_peps-master/example_piface/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_piface/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_piface/project_config.yaml diff --git a/tests/data/example_peps-master/example_piface/readData.R b/tests/data/peppydata/example_peps-master/example_piface/readData.R similarity index 100% rename from tests/data/example_peps-master/example_piface/readData.R rename to tests/data/peppydata/example_peps-master/example_piface/readData.R diff --git a/tests/data/example_peps-master/example_project_as_dictionary/project.json b/tests/data/peppydata/example_peps-master/example_project_as_dictionary/project.json similarity index 100% rename from tests/data/example_peps-master/example_project_as_dictionary/project.json rename to tests/data/peppydata/example_peps-master/example_project_as_dictionary/project.json diff --git a/tests/data/example_peps-master/example_remove/project_config.yaml b/tests/data/peppydata/example_peps-master/example_remove/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_remove/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_remove/project_config.yaml diff --git a/tests/data/example_peps-master/example_remove/sample_table.csv b/tests/data/peppydata/example_peps-master/example_remove/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_remove/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_remove/sample_table.csv diff --git a/tests/data/example_peps-master/example_subsamples_none/project_config.yaml b/tests/data/peppydata/example_peps-master/example_subsamples_none/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_subsamples_none/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_subsamples_none/project_config.yaml diff --git a/tests/data/example_peps-master/example_subsamples_none/sample_table.csv b/tests/data/peppydata/example_peps-master/example_subsamples_none/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subsamples_none/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_subsamples_none/sample_table.csv diff --git a/tests/data/example_peps-master/example_subtable1/project_config.yaml b/tests/data/peppydata/example_peps-master/example_subtable1/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_subtable1/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_subtable1/project_config.yaml diff --git a/tests/data/example_peps-master/example_subtable1/sample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable1/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable1/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable1/sample_table.csv diff --git a/tests/data/example_peps-master/example_subtable1/subsample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable1/subsample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable1/subsample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable1/subsample_table.csv diff --git a/tests/data/example_peps-master/example_subtable2/project_config.yaml b/tests/data/peppydata/example_peps-master/example_subtable2/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_subtable2/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_subtable2/project_config.yaml diff --git a/tests/data/example_peps-master/example_subtable2/sample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable2/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable2/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable2/sample_table.csv diff --git a/tests/data/example_peps-master/example_subtable2/subsample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable2/subsample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable2/subsample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable2/subsample_table.csv diff --git a/tests/data/example_peps-master/example_subtable3/project_config.yaml b/tests/data/peppydata/example_peps-master/example_subtable3/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_subtable3/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_subtable3/project_config.yaml diff --git a/tests/data/example_peps-master/example_subtable3/sample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable3/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable3/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable3/sample_table.csv diff --git a/tests/data/example_peps-master/example_subtable3/subsample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable3/subsample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable3/subsample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable3/subsample_table.csv diff --git a/tests/data/example_peps-master/example_subtable4/project_config.yaml b/tests/data/peppydata/example_peps-master/example_subtable4/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_subtable4/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_subtable4/project_config.yaml diff --git a/tests/data/example_peps-master/example_subtable4/sample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable4/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable4/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable4/sample_table.csv diff --git a/tests/data/example_peps-master/example_subtable4/subsample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable4/subsample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable4/subsample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable4/subsample_table.csv diff --git a/tests/data/example_peps-master/example_subtable5/project_config.yaml b/tests/data/peppydata/example_peps-master/example_subtable5/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_subtable5/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_subtable5/project_config.yaml diff --git a/tests/data/example_peps-master/example_subtable5/sample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable5/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable5/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable5/sample_table.csv diff --git a/tests/data/example_peps-master/example_subtable5/subsample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable5/subsample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable5/subsample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable5/subsample_table.csv diff --git a/tests/data/example_peps-master/example_subtable_automerge/project_config.yaml b/tests/data/peppydata/example_peps-master/example_subtable_automerge/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_subtable_automerge/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_subtable_automerge/project_config.yaml diff --git a/tests/data/example_peps-master/example_subtable_automerge/sample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable_automerge/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable_automerge/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable_automerge/sample_table.csv diff --git a/tests/data/example_peps-master/example_subtable_automerge/subsample_table.csv b/tests/data/peppydata/example_peps-master/example_subtable_automerge/subsample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtable_automerge/subsample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtable_automerge/subsample_table.csv diff --git a/tests/data/example_peps-master/example_subtables/project_config.yaml b/tests/data/peppydata/example_peps-master/example_subtables/project_config.yaml similarity index 100% rename from tests/data/example_peps-master/example_subtables/project_config.yaml rename to tests/data/peppydata/example_peps-master/example_subtables/project_config.yaml diff --git a/tests/data/example_peps-master/example_subtables/sample_table.csv b/tests/data/peppydata/example_peps-master/example_subtables/sample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtables/sample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtables/sample_table.csv diff --git a/tests/data/example_peps-master/example_subtables/subsample_table.csv b/tests/data/peppydata/example_peps-master/example_subtables/subsample_table.csv similarity index 100% rename from tests/data/example_peps-master/example_subtables/subsample_table.csv rename to tests/data/peppydata/example_peps-master/example_subtables/subsample_table.csv diff --git a/tests/data/example_peps-master/example_subtables/subsample_table1.csv b/tests/data/peppydata/example_peps-master/example_subtables/subsample_table1.csv similarity index 100% rename from tests/data/example_peps-master/example_subtables/subsample_table1.csv rename to tests/data/peppydata/example_peps-master/example_subtables/subsample_table1.csv diff --git a/tests/data/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_1.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_1.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_1.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_1.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_2.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_2.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_2.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample1/other_pipeline1/sample1_GSM2471255_2.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_1.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_1.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_1.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_1.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_2.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_2.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_2.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample1/pipeline1/sample1_GSM2471255_2.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_1.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_1.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_1.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_1.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_2.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_2.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_2.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample2/other_pipeline1/sample2_GSM2471300_2.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_1.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_1.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_1.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_1.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_2.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_2.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_2.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample2/pipeline1/sample2_GSM2471300_2.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_1.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_1.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_1.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_1.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_2.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_2.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_2.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample3/other_pipeline2/sample3_GSM2471249_2.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_1.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_1.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_1.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_1.bw diff --git a/tests/data/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_2.bw b/tests/data/peppydata/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_2.bw similarity index 100% rename from tests/data/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_2.bw rename to tests/data/peppydata/example_peps-master/output/results_pipeline/sample3/pipeline2/sample3_GSM2471249_2.bw diff --git a/tests/data/example_peps-master/subannotation.ipynb b/tests/data/peppydata/example_peps-master/subannotation.ipynb similarity index 100% rename from tests/data/example_peps-master/subannotation.ipynb rename to tests/data/peppydata/example_peps-master/subannotation.ipynb diff --git a/tests/conftest.py b/tests/eidotests/conftest.py similarity index 70% rename from tests/conftest.py rename to tests/eidotests/conftest.py index ac9884d5..4e16087f 100644 --- a/tests/conftest.py +++ b/tests/eidotests/conftest.py @@ -1,78 +1,14 @@ -"""Configuration for modules with independent tests of models.""" - import os import pandas as pd import pytest -from peppy.project import Project - -__author__ = "Michal Stolarczyk" -__email__ = "michal.stolarczyk@nih.gov" - -# example_peps branch, see: https://github.com/pepkit/example_peps -EPB = "master" +from peppy import Project @pytest.fixture def data_path(): - return os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") - - -def merge_paths(pep_branch, directory_name): return os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "tests", - "data", - "example_peps-{}".format(pep_branch), - "example_{}".format(directory_name), - ) - - -def get_path_to_example_file(branch, directory_name, file_name): - return os.path.join(merge_paths(branch, directory_name), file_name) - - -@pytest.fixture -def example_pep_cfg_path(request): - return get_path_to_example_file(EPB, request.param, "project_config.yaml") - - -@pytest.fixture -def example_pep_csv_path(request): - return get_path_to_example_file(EPB, request.param, "sample_table.csv") - - -@pytest.fixture -def example_yaml_sample_file(request): - return get_path_to_example_file(EPB, request.param, "sample.yaml") - - -@pytest.fixture -def example_pep_nextflow_csv_path(): - return get_path_to_example_file(EPB, "nextflow_taxprofiler_pep", "samplesheet.csv") - - -@pytest.fixture -def example_pep_cfg_noname_path(request): - return get_path_to_example_file(EPB, "noname", request.param) - - -@pytest.fixture -def example_peps_cfg_paths(request): - """ - This is the same as the ficture above, however, it lets - you return multiple paths (for comparing peps). Will return - list of paths. - """ - return [ - get_path_to_example_file(EPB, p, "project_config.yaml") for p in request.param - ] - - -@pytest.fixture -def config_with_pandas_obj(request): - return pd.read_csv( - get_path_to_example_file(EPB, request.param, "sample_table.csv"), dtype=str + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "eidodata" ) @@ -146,11 +82,6 @@ def path_to_taxprofiler_csv_multiline_output(peps_path): return os.path.join(peps_path, "multiline_output", "multiline_output.csv") -@pytest.fixture -def path_pep_for_schema_with_rel_path(peps_path): - return os.path.join(peps_path, "pep_schema_rel_path", "config.yaml") - - @pytest.fixture def path_pep_with_fasta_column(peps_path): return os.path.join(peps_path, "pep_with_fasta_column", "config.yaml") @@ -181,6 +112,11 @@ def taxprofiler_csv_multiline_output(path_to_taxprofiler_csv_multiline_output): # ).to_csv(path_or_buf=None, index=None) +@pytest.fixture +def path_pep_for_schema_with_rel_path(peps_path): + return os.path.join(peps_path, "pep_schema_rel_path", "config.yaml") + + @pytest.fixture def path_pep_nextflow_taxprofiler(peps_path): return os.path.join(peps_path, "pep_nextflow_taxprofiler", "config.yaml") diff --git a/tests/test_conversions.py b/tests/eidotests/test_conversions.py similarity index 100% rename from tests/test_conversions.py rename to tests/eidotests/test_conversions.py diff --git a/tests/test_schema_operations.py b/tests/eidotests/test_schema_operations.py similarity index 100% rename from tests/test_schema_operations.py rename to tests/eidotests/test_schema_operations.py diff --git a/tests/test_validations.py b/tests/eidotests/test_validations.py similarity index 100% rename from tests/test_validations.py rename to tests/eidotests/test_validations.py diff --git a/tests/peppytests/conftest.py b/tests/peppytests/conftest.py new file mode 100644 index 00000000..2d9260f8 --- /dev/null +++ b/tests/peppytests/conftest.py @@ -0,0 +1,70 @@ +"""Configuration for modules with independent tests of models.""" + +import os + +import pandas as pd +import pytest + +__author__ = "Michal Stolarczyk" +__email__ = "michal.stolarczyk@nih.gov" + +# example_peps branch, see: https://github.com/pepkit/example_peps +EPB = "master" + + +def merge_paths(pep_branch, directory_name): + return os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", + "peppydata", + "example_peps-{}".format(pep_branch), + "example_{}".format(directory_name), + ) + + +def get_path_to_example_file(branch, directory_name, file_name): + return os.path.join(merge_paths(branch, directory_name), file_name) + + +@pytest.fixture +def example_pep_cfg_path(request): + return get_path_to_example_file(EPB, request.param, "project_config.yaml") + + +@pytest.fixture +def example_pep_csv_path(request): + return get_path_to_example_file(EPB, request.param, "sample_table.csv") + + +@pytest.fixture +def example_yaml_sample_file(request): + return get_path_to_example_file(EPB, request.param, "sample.yaml") + + +@pytest.fixture +def example_pep_nextflow_csv_path(): + return get_path_to_example_file(EPB, "nextflow_taxprofiler_pep", "samplesheet.csv") + + +@pytest.fixture +def example_pep_cfg_noname_path(request): + return get_path_to_example_file(EPB, "noname", request.param) + + +@pytest.fixture +def example_peps_cfg_paths(request): + """ + This is the same as the ficture above, however, it lets + you return multiple paths (for comparing peps). Will return + list of paths. + """ + return [ + get_path_to_example_file(EPB, p, "project_config.yaml") for p in request.param + ] + + +@pytest.fixture +def config_with_pandas_obj(request): + return pd.read_csv( + get_path_to_example_file(EPB, request.param, "sample_table.csv"), dtype=str + ) diff --git a/tests/test_Project.py b/tests/peppytests/test_Project.py similarity index 91% rename from tests/test_Project.py rename to tests/peppytests/test_Project.py index 2ba5c0e5..91ccd45d 100644 --- a/tests/test_Project.py +++ b/tests/peppytests/test_Project.py @@ -1,5 +1,3 @@ -"""Classes for peppy.Project smoketesting""" - import os import pickle import socket @@ -753,3 +751,69 @@ def test_nextflow_subsamples(self, example_pep_cfg_path): """ p = Project(cfg=example_pep_cfg_path) assert isinstance(p, Project) + + +class TestSampleModifiers: + @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) + def test_append(self, example_pep_cfg_path): + """Verify that the appended attribute is added to the samples""" + p = Project(cfg=example_pep_cfg_path) + assert all([s["read_type"] == "SINGLE" for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["imports"], indirect=True) + def test_imports(self, example_pep_cfg_path): + """Verify that the imported attribute is added to the samples""" + p = Project(cfg=example_pep_cfg_path) + assert all([s["imported_attr"] == "imported_val" for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["imply"], indirect=True) + def test_imply(self, example_pep_cfg_path): + """ + Verify that the implied attribute is added to the correct samples + """ + p = Project(cfg=example_pep_cfg_path) + assert all( + [s["genome"] == "hg38" for s in p.samples if s["organism"] == "human"] + ) + assert all( + [s["genome"] == "mm10" for s in p.samples if s["organism"] == "mouse"] + ) + + @pytest.mark.parametrize("example_pep_cfg_path", ["duplicate"], indirect=True) + def test_duplicate(self, example_pep_cfg_path): + """ + Verify that the duplicated attribute is identical to the original + """ + p = Project(cfg=example_pep_cfg_path) + assert all([s["organism"] == s["animal"] for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["derive"], indirect=True) + def test_derive(self, example_pep_cfg_path): + """ + Verify that the declared attr derivation happened + """ + p = Project(cfg=example_pep_cfg_path) + assert all(["file_path" in s for s in p.samples]) + assert all(["file_path" in s._derived_cols_done for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["remove"], indirect=True) + def test_remove(self, example_pep_cfg_path): + """ + Verify that the declared attr was eliminated from every sample + """ + p = Project(cfg=example_pep_cfg_path) + assert all(["protocol" not in s for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["subtable2"], indirect=True) + def test_subtable(self, example_pep_cfg_path): + """ + Verify that the sample merging takes place + """ + p = Project(cfg=example_pep_cfg_path) + assert all( + [ + isinstance(s["file"], list) + for s in p.samples + if s["sample_name"] in ["frog_1", "frog2"] + ] + ) diff --git a/tests/smoketests/test_Sample.py b/tests/peppytests/test_Sample.py similarity index 99% rename from tests/smoketests/test_Sample.py rename to tests/peppytests/test_Sample.py index ba41b003..62b9bf0c 100644 --- a/tests/smoketests/test_Sample.py +++ b/tests/peppytests/test_Sample.py @@ -2,7 +2,6 @@ import tempfile import pytest - from peppy import Project __author__ = "Michal Stolarczyk" diff --git a/tests/smoketests/__init__.py b/tests/smoketests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/smoketests/test_Project.py b/tests/smoketests/test_Project.py deleted file mode 100644 index 197ba9b0..00000000 --- a/tests/smoketests/test_Project.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest - -from peppy.project import Project - - -class TestSampleModifiers: - @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) - def test_append(self, example_pep_cfg_path): - """Verify that the appended attribute is added to the samples""" - p = Project(cfg=example_pep_cfg_path) - assert all([s["read_type"] == "SINGLE" for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["imports"], indirect=True) - def test_imports(self, example_pep_cfg_path): - """Verify that the imported attribute is added to the samples""" - p = Project(cfg=example_pep_cfg_path) - assert all([s["imported_attr"] == "imported_val" for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["imply"], indirect=True) - def test_imply(self, example_pep_cfg_path): - """ - Verify that the implied attribute is added to the correct samples - """ - p = Project(cfg=example_pep_cfg_path) - assert all( - [s["genome"] == "hg38" for s in p.samples if s["organism"] == "human"] - ) - assert all( - [s["genome"] == "mm10" for s in p.samples if s["organism"] == "mouse"] - ) - - @pytest.mark.parametrize("example_pep_cfg_path", ["duplicate"], indirect=True) - def test_duplicate(self, example_pep_cfg_path): - """ - Verify that the duplicated attribute is identical to the original - """ - p = Project(cfg=example_pep_cfg_path) - assert all([s["organism"] == s["animal"] for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["derive"], indirect=True) - def test_derive(self, example_pep_cfg_path): - """ - Verify that the declared attr derivation happened - """ - p = Project(cfg=example_pep_cfg_path) - assert all(["file_path" in s for s in p.samples]) - assert all(["file_path" in s._derived_cols_done for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["remove"], indirect=True) - def test_remove(self, example_pep_cfg_path): - """ - Verify that the declared attr was eliminated from every sample - """ - p = Project(cfg=example_pep_cfg_path) - assert all(["protocol" not in s for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["subtable2"], indirect=True) - def test_subtable(self, example_pep_cfg_path): - """ - Verify that the sample merging takes place - """ - p = Project(cfg=example_pep_cfg_path) - assert all( - [ - isinstance(s["file"], list) - for s in p.samples - if s["sample_name"] in ["frog_1", "frog2"] - ] - ) From d49007cc675f2378c57a8057401940e970defa50 Mon Sep 17 00:00:00 2001 From: "Ziyang \"Claude\" Hu" <33562602+ClaudeHu@users.noreply.github.com> Date: Fri, 7 Nov 2025 17:43:48 -0500 Subject: [PATCH 132/165] Update peppy/eido/validation.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- peppy/eido/validation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py index 01f3fe24..bf44713e 100644 --- a/peppy/eido/validation.py +++ b/peppy/eido/validation.py @@ -140,6 +140,7 @@ def validate_config( try: del schema_cpy[PROP_KEY][SAMPLES_KEY] except KeyError: + # It's fine if SAMPLES_KEY is not present; nothing to remove. # It's fine if SAMPLES_KEY is not present; nothing to remove. # Schema doesn't have samples key, which is fine for config-only validation pass From 00a771c8c067c3339025169b87d69c02f0152da1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Nov 2025 17:48:40 -0500 Subject: [PATCH 133/165] fix based on suggestion from copilot --- tests/eidotests/test_validations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/eidotests/test_validations.py b/tests/eidotests/test_validations.py index dcb391db..c5ff3c92 100644 --- a/tests/eidotests/test_validations.py +++ b/tests/eidotests/test_validations.py @@ -16,7 +16,7 @@ def _check_remote_file_accessible(url): try: code = urllib.request.urlopen(url).getcode() - except: + except (urllib.error.URLError, OSError): pytest.skip(f"Remote file not found: {url}") else: if code != 200: From 189731eb11c52cda35292bce97130b9ca70aed47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Nov 2025 22:36:33 -0500 Subject: [PATCH 134/165] minor change --- peppy/eido/validation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/peppy/eido/validation.py b/peppy/eido/validation.py index bf44713e..cadef703 100644 --- a/peppy/eido/validation.py +++ b/peppy/eido/validation.py @@ -140,8 +140,6 @@ def validate_config( try: del schema_cpy[PROP_KEY][SAMPLES_KEY] except KeyError: - # It's fine if SAMPLES_KEY is not present; nothing to remove. - # It's fine if SAMPLES_KEY is not present; nothing to remove. # Schema doesn't have samples key, which is fine for config-only validation pass if "required" in schema_cpy: From 381ead5ec4e26f1e4700bac5cfdea07f92feec5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Nov 2025 22:44:38 -0500 Subject: [PATCH 135/165] edit based on copilot --- peppy/cli.py | 3 +-- tests/eidotests/conftest.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/peppy/cli.py b/peppy/cli.py index bf54dbcd..e55dc3bc 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -55,7 +55,6 @@ def print_error_summary( final_msg = f"Validation unsuccessful. {len(errors_by_type)} error type found." _LOGGER.error(final_msg) - # return final_msg def main(): @@ -164,7 +163,7 @@ def main(): validator(*arguments) except EidoValidationError as e: print_error_summary(e.errors_by_type, _LOGGER) - return False + sys.exit(1) _LOGGER.info("Validation successful") sys.exit(0) diff --git a/tests/eidotests/conftest.py b/tests/eidotests/conftest.py index 4e16087f..bb46a5b8 100644 --- a/tests/eidotests/conftest.py +++ b/tests/eidotests/conftest.py @@ -1,6 +1,5 @@ import os -import pandas as pd import pytest from peppy import Project From 2b2f744c6f361dd2ecb5b3b3d4ee3d8e3076e586 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Nov 2025 15:16:56 -0500 Subject: [PATCH 136/165] relocate pephubclient code --- {pephubclient => peppy/pephubclient}/__init__.py | 0 {pephubclient => peppy/pephubclient}/__main__.py | 0 {pephubclient => peppy/pephubclient}/cli.py | 0 {pephubclient => peppy/pephubclient}/constants.py | 0 {pephubclient => peppy/pephubclient}/exceptions.py | 0 {pephubclient => peppy/pephubclient}/files_manager.py | 0 {pephubclient => peppy/pephubclient}/helpers.py | 0 {pephubclient => peppy/pephubclient}/models.py | 0 {pephubclient => peppy/pephubclient}/modules/__init__.py | 0 {pephubclient => peppy/pephubclient}/modules/sample.py | 0 {pephubclient => peppy/pephubclient}/modules/view.py | 0 {pephubclient => peppy/pephubclient}/pephub_oauth/__init__.py | 0 {pephubclient => peppy/pephubclient}/pephub_oauth/const.py | 0 {pephubclient => peppy/pephubclient}/pephub_oauth/exceptions.py | 0 {pephubclient => peppy/pephubclient}/pephub_oauth/models.py | 0 {pephubclient => peppy/pephubclient}/pephub_oauth/pephub_oauth.py | 0 {pephubclient => peppy/pephubclient}/pephubclient.py | 0 17 files changed, 0 insertions(+), 0 deletions(-) rename {pephubclient => peppy/pephubclient}/__init__.py (100%) rename {pephubclient => peppy/pephubclient}/__main__.py (100%) rename {pephubclient => peppy/pephubclient}/cli.py (100%) rename {pephubclient => peppy/pephubclient}/constants.py (100%) rename {pephubclient => peppy/pephubclient}/exceptions.py (100%) rename {pephubclient => peppy/pephubclient}/files_manager.py (100%) rename {pephubclient => peppy/pephubclient}/helpers.py (100%) rename {pephubclient => peppy/pephubclient}/models.py (100%) rename {pephubclient => peppy/pephubclient}/modules/__init__.py (100%) rename {pephubclient => peppy/pephubclient}/modules/sample.py (100%) rename {pephubclient => peppy/pephubclient}/modules/view.py (100%) rename {pephubclient => peppy/pephubclient}/pephub_oauth/__init__.py (100%) rename {pephubclient => peppy/pephubclient}/pephub_oauth/const.py (100%) rename {pephubclient => peppy/pephubclient}/pephub_oauth/exceptions.py (100%) rename {pephubclient => peppy/pephubclient}/pephub_oauth/models.py (100%) rename {pephubclient => peppy/pephubclient}/pephub_oauth/pephub_oauth.py (100%) rename {pephubclient => peppy/pephubclient}/pephubclient.py (100%) diff --git a/pephubclient/__init__.py b/peppy/pephubclient/__init__.py similarity index 100% rename from pephubclient/__init__.py rename to peppy/pephubclient/__init__.py diff --git a/pephubclient/__main__.py b/peppy/pephubclient/__main__.py similarity index 100% rename from pephubclient/__main__.py rename to peppy/pephubclient/__main__.py diff --git a/pephubclient/cli.py b/peppy/pephubclient/cli.py similarity index 100% rename from pephubclient/cli.py rename to peppy/pephubclient/cli.py diff --git a/pephubclient/constants.py b/peppy/pephubclient/constants.py similarity index 100% rename from pephubclient/constants.py rename to peppy/pephubclient/constants.py diff --git a/pephubclient/exceptions.py b/peppy/pephubclient/exceptions.py similarity index 100% rename from pephubclient/exceptions.py rename to peppy/pephubclient/exceptions.py diff --git a/pephubclient/files_manager.py b/peppy/pephubclient/files_manager.py similarity index 100% rename from pephubclient/files_manager.py rename to peppy/pephubclient/files_manager.py diff --git a/pephubclient/helpers.py b/peppy/pephubclient/helpers.py similarity index 100% rename from pephubclient/helpers.py rename to peppy/pephubclient/helpers.py diff --git a/pephubclient/models.py b/peppy/pephubclient/models.py similarity index 100% rename from pephubclient/models.py rename to peppy/pephubclient/models.py diff --git a/pephubclient/modules/__init__.py b/peppy/pephubclient/modules/__init__.py similarity index 100% rename from pephubclient/modules/__init__.py rename to peppy/pephubclient/modules/__init__.py diff --git a/pephubclient/modules/sample.py b/peppy/pephubclient/modules/sample.py similarity index 100% rename from pephubclient/modules/sample.py rename to peppy/pephubclient/modules/sample.py diff --git a/pephubclient/modules/view.py b/peppy/pephubclient/modules/view.py similarity index 100% rename from pephubclient/modules/view.py rename to peppy/pephubclient/modules/view.py diff --git a/pephubclient/pephub_oauth/__init__.py b/peppy/pephubclient/pephub_oauth/__init__.py similarity index 100% rename from pephubclient/pephub_oauth/__init__.py rename to peppy/pephubclient/pephub_oauth/__init__.py diff --git a/pephubclient/pephub_oauth/const.py b/peppy/pephubclient/pephub_oauth/const.py similarity index 100% rename from pephubclient/pephub_oauth/const.py rename to peppy/pephubclient/pephub_oauth/const.py diff --git a/pephubclient/pephub_oauth/exceptions.py b/peppy/pephubclient/pephub_oauth/exceptions.py similarity index 100% rename from pephubclient/pephub_oauth/exceptions.py rename to peppy/pephubclient/pephub_oauth/exceptions.py diff --git a/pephubclient/pephub_oauth/models.py b/peppy/pephubclient/pephub_oauth/models.py similarity index 100% rename from pephubclient/pephub_oauth/models.py rename to peppy/pephubclient/pephub_oauth/models.py diff --git a/pephubclient/pephub_oauth/pephub_oauth.py b/peppy/pephubclient/pephub_oauth/pephub_oauth.py similarity index 100% rename from pephubclient/pephub_oauth/pephub_oauth.py rename to peppy/pephubclient/pephub_oauth/pephub_oauth.py diff --git a/pephubclient/pephubclient.py b/peppy/pephubclient/pephubclient.py similarity index 100% rename from pephubclient/pephubclient.py rename to peppy/pephubclient/pephubclient.py From 2d6e961eccf239c62e7c361973f707a41e9e2244 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 00:29:33 -0500 Subject: [PATCH 137/165] make sure all pytests passed --- peppy/pephubclient/__init__.py | 32 +- peppy/pephubclient/cli.py | 4 +- peppy/pephubclient/files_manager.py | 2 +- peppy/pephubclient/helpers.py | 16 +- peppy/pephubclient/models.py | 2 +- peppy/pephubclient/modules/sample.py | 6 +- peppy/pephubclient/modules/view.py | 15 +- peppy/pephubclient/pephub_oauth/const.py | 2 +- .../pephubclient/pephub_oauth/pephub_oauth.py | 8 +- peppy/pephubclient/pephubclient.py | 36 +- requirements/requirements-all.txt | 14 + requirements/requirements-test.txt | 15 +- setup.py | 42 +- .../{ => phcdata}/sample_pep/sample_table.csv | 0 .../sample_pep/subsamp_config.yaml | 0 .../sample_pep/subsample_table.csv | 0 tests/peppytests/conftest.py | 70 -- tests/peppytests/test_Project.py | 819 ------------------ tests/peppytests/test_Sample.py | 109 --- tests/{ => phctests}/__init__.py | 0 tests/{ => phctests}/conftest.py | 17 +- tests/{ => phctests}/test_manual.py | 2 +- tests/{ => phctests}/test_pephubclient.py | 52 +- 23 files changed, 169 insertions(+), 1094 deletions(-) rename tests/data/{ => phcdata}/sample_pep/sample_table.csv (100%) rename tests/data/{ => phcdata}/sample_pep/subsamp_config.yaml (100%) rename tests/data/{ => phcdata}/sample_pep/subsample_table.csv (100%) delete mode 100644 tests/peppytests/conftest.py delete mode 100644 tests/peppytests/test_Project.py delete mode 100644 tests/peppytests/test_Sample.py rename tests/{ => phctests}/__init__.py (100%) rename tests/{ => phctests}/conftest.py (77%) rename tests/{ => phctests}/test_manual.py (97%) rename tests/{ => phctests}/test_pephubclient.py (91%) diff --git a/peppy/pephubclient/__init__.py b/peppy/pephubclient/__init__.py index a099d01e..8e0037f7 100644 --- a/peppy/pephubclient/__init__.py +++ b/peppy/pephubclient/__init__.py @@ -8,19 +8,19 @@ __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" -__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", -) +# __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/peppy/pephubclient/cli.py b/peppy/pephubclient/cli.py index 7be8cfa7..4c5c1525 100644 --- a/peppy/pephubclient/cli.py +++ b/peppy/pephubclient/cli.py @@ -1,8 +1,8 @@ import typer from pephubclient import __app_name__, __version__ -from pephubclient.helpers import call_client_func -from pephubclient.pephubclient import PEPHubClient +from .helpers import call_client_func +from .pephubclient import PEPHubClient _client = PEPHubClient() diff --git a/peppy/pephubclient/files_manager.py b/peppy/pephubclient/files_manager.py index a3d9b56a..3ead97ee 100644 --- a/peppy/pephubclient/files_manager.py +++ b/peppy/pephubclient/files_manager.py @@ -6,7 +6,7 @@ import yaml import zipfile -from pephubclient.exceptions import PEPExistsError +from .exceptions import PEPExistsError class FilesManager: diff --git a/peppy/pephubclient/helpers.py b/peppy/pephubclient/helpers.py index dcd9b51c..9cd8a1c5 100644 --- a/peppy/pephubclient/helpers.py +++ b/peppy/pephubclient/helpers.py @@ -1,10 +1,10 @@ import json from typing import Any, Callable, Optional, Union -import peppy +from ..project import Project import yaml import os import pandas as pd -from peppy.const import ( +from ..const import ( NAME_KEY, DESC_KEY, CONFIG_KEY, @@ -21,10 +21,10 @@ 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 +from .exceptions import PEPExistsError, ResponseError +from .constants import RegistryPath +from .files_manager import FilesManager +from .models import ProjectDict class RequestManager: @@ -280,7 +280,7 @@ def full_path(fn: str) -> str: def save_pep( - project: Union[dict, peppy.Project], + project: Union[dict, Project], reg_path: str = None, force: bool = False, project_path: Optional[str] = None, @@ -297,7 +297,7 @@ def save_pep( :param bool zip: If True, save project as zip file :return: None """ - if isinstance(project, peppy.Project): + if isinstance(project, Project): project = project.to_dict(extended=True, orient="records") project = ProjectDict(**project).model_dump(by_alias=True) diff --git a/peppy/pephubclient/models.py b/peppy/pephubclient/models.py index 473dd0be..355d8517 100644 --- a/peppy/pephubclient/models.py +++ b/peppy/pephubclient/models.py @@ -2,7 +2,7 @@ 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 +from ..const import CONFIG_KEY, SUBSAMPLE_RAW_LIST_KEY, SAMPLE_RAW_DICT_KEY class ProjectDict(BaseModel): diff --git a/peppy/pephubclient/modules/sample.py b/peppy/pephubclient/modules/sample.py index c8208d10..a1ec7e8e 100644 --- a/peppy/pephubclient/modules/sample.py +++ b/peppy/pephubclient/modules/sample.py @@ -1,8 +1,8 @@ import logging -from pephubclient.helpers import RequestManager -from pephubclient.constants import PEPHUB_SAMPLE_URL, ResponseStatusCodes -from pephubclient.exceptions import ResponseError +from ..helpers import RequestManager +from ..constants import PEPHUB_SAMPLE_URL, ResponseStatusCodes +from ..exceptions import ResponseError _LOGGER = logging.getLogger("pephubclient") diff --git a/peppy/pephubclient/modules/view.py b/peppy/pephubclient/modules/view.py index 7c73722e..893c8e9c 100644 --- a/peppy/pephubclient/modules/view.py +++ b/peppy/pephubclient/modules/view.py @@ -1,15 +1,16 @@ from typing import Union -import peppy +# import peppy +from ...project import Project import logging -from pephubclient.helpers import RequestManager -from pephubclient.constants import ( +from ..helpers import RequestManager +from ..constants import ( PEPHUB_VIEW_URL, PEPHUB_VIEW_SAMPLE_URL, ResponseStatusCodes, ) -from pephubclient.exceptions import ResponseError -from pephubclient.models import ProjectDict +from ..exceptions import ResponseError +from ..models import ProjectDict _LOGGER = logging.getLogger("pephubclient") @@ -32,7 +33,7 @@ def __init__(self, jwt_data: str = None): def get( self, namespace: str, name: str, tag: str, view_name: str, raw: bool = False - ) -> Union[peppy.Project, dict]: + ) -> Union[Project, dict]: """ Get view from project in PEPhub. @@ -57,7 +58,7 @@ def get( if raw: return output output = ProjectDict(**output).model_dump(by_alias=True) - return peppy.Project.from_dict(output) + return Project.from_dict(output) elif response.status_code == ResponseStatusCodes.NOT_EXIST: raise ResponseError("View does not exist, or you are unauthorized.") else: diff --git a/peppy/pephubclient/pephub_oauth/const.py b/peppy/pephubclient/pephub_oauth/const.py index 68d78ed6..0cdfbc8e 100644 --- a/peppy/pephubclient/pephub_oauth/const.py +++ b/peppy/pephubclient/pephub_oauth/const.py @@ -1,6 +1,6 @@ # constants of pephub_auth -from pephubclient.constants import PEPHUB_BASE_URL +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" diff --git a/peppy/pephubclient/pephub_oauth/pephub_oauth.py b/peppy/pephubclient/pephub_oauth/pephub_oauth.py index 6023be3f..46243918 100644 --- a/peppy/pephubclient/pephub_oauth/pephub_oauth.py +++ b/peppy/pephubclient/pephub_oauth/pephub_oauth.py @@ -5,16 +5,16 @@ import requests from pydantic import BaseModel -from pephubclient.helpers import MessageHandler, RequestManager -from pephubclient.pephub_oauth.const import ( +from ..helpers import MessageHandler, RequestManager +from ..pephub_oauth.const import ( PEPHUB_DEVICE_INIT_URI, PEPHUB_DEVICE_TOKEN_URI, ) -from pephubclient.pephub_oauth.exceptions import ( +from ..pephub_oauth.exceptions import ( PEPHubResponseException, PEPHubTokenExchangeException, ) -from pephubclient.pephub_oauth.models import ( +from ..pephub_oauth.models import ( InitializeDeviceCodeResponse, PEPHubDeviceTokenResponse, ) diff --git a/peppy/pephubclient/pephubclient.py b/peppy/pephubclient/pephubclient.py index b09760a6..53149fca 100644 --- a/peppy/pephubclient/pephubclient.py +++ b/peppy/pephubclient/pephubclient.py @@ -1,13 +1,13 @@ from typing import NoReturn, Optional, Literal from typing_extensions import deprecated -import peppy -from peppy.const import NAME_KEY +from ..project import Project +from ..const import NAME_KEY import urllib3 from pydantic import ValidationError from ubiquerg import parse_registry_path -from pephubclient.constants import ( +from .constants import ( PEPHUB_PEP_API_BASE_URL, PEPHUB_PUSH_URL, RegistryPath, @@ -15,21 +15,21 @@ PEPHUB_PEP_SEARCH_URL, PATH_TO_FILE_WITH_JWT, ) -from pephubclient.exceptions import ( +from .exceptions import ( IncorrectQueryStringError, ResponseError, ) -from pephubclient.files_manager import FilesManager -from pephubclient.helpers import MessageHandler, RequestManager, save_pep -from pephubclient.models import ( +from .files_manager import FilesManager +from .helpers import MessageHandler, RequestManager, save_pep +from .models import ( ProjectDict, ProjectUploadData, SearchReturnModel, ProjectAnnotationModel, ) -from pephubclient.pephub_oauth.pephub_oauth import PEPHubAuth -from pephubclient.modules.view import PEPHubView -from pephubclient.modules.sample import PEPHubSample +from .pephub_oauth.pephub_oauth import PEPHubAuth +from .modules.view import PEPHubView +from .modules.sample import PEPHubSample urllib3.disable_warnings() @@ -97,16 +97,16 @@ def load_project( self, project_registry_path: str, query_param: Optional[dict] = None, - ) -> peppy.Project: + ) -> Project: """ - Load peppy project from PEPhub in peppy.Project object + Load peppy project from PEPhub in Project object :param project_registry_path: registry path of the project :param query_param: query parameters used in get request :return Project: peppy project. """ raw_pep = self.load_raw_pep(project_registry_path, query_param) - peppy_project = peppy.Project().from_dict(raw_pep) + peppy_project = Project().from_dict(raw_pep) return peppy_project def push( @@ -130,7 +130,7 @@ def push( :param bool force: Force push to the database. Use it to update, or upload project. [Default= False] :return: None """ - peppy_project = peppy.Project(cfg=cfg) + peppy_project = Project(cfg=cfg) self.upload( project=peppy_project, namespace=namespace, @@ -142,7 +142,7 @@ def push( def upload( self, - project: peppy.Project, + project: Project, namespace: str, name: str = None, tag: str = None, @@ -152,7 +152,7 @@ def upload( """ Upload peppy project to the PEPhub. - :param peppy.Project project: Project object that has to be uploaded to the DB + :param Project project: Project object that has to be uploaded to the DB :param namespace: namespace :param name: project name :param tag: project tag @@ -263,7 +263,7 @@ def _load_raw_pep( """ !!! This method is deprecated. Use load_raw_pep instead. !!! - Request PEPhub and return the requested project as peppy.Project object. + Request PEPhub and return the requested project as Project object. :param registry_path: Project namespace, eg. "geo/GSE124224:tag" :param query_param: Optional variables to be passed to PEPhub @@ -277,7 +277,7 @@ def load_raw_pep( query_param: Optional[dict] = None, ) -> dict: """ - Request PEPhub and return the requested project as peppy.Project object. + Request PEPhub and return the requested project as Project object. :param registry_path: Project namespace, eg. "geo/GSE124224:tag" :param query_param: Optional variables to be passed to PEPhub diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index e69de29b..55471dd4 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -0,0 +1,14 @@ +pandas>=0.24.2 +pyyaml +rich>=10.3.0 +ubiquerg>=0.6.2 +numpy +pephubclient>=0.4.2 +# eido +importlib-metadata; python_version < '3.10' +jsonschema>=3.0.1 +# pephubclient +typer>=0.7.0 +requests>=2.28.2 +pydantic>2.5.0 +coloredlogs>=15.0.1 \ No newline at end of file diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index 60551a53..56ac11e9 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -1,2 +1,15 @@ +mock pytest -coveralls \ No newline at end of file +pytest-cov +pytest-remotedata +# eido +coveralls +pytest-mock==3.6.1 +# pephubclient +black +ruff +python-dotenv +flake8 +pre-commit +coverage +smokeshow \ No newline at end of file diff --git a/setup.py b/setup.py index 446ae526..f587ca24 100644 --- a/setup.py +++ b/setup.py @@ -1,22 +1,52 @@ import os import sys +from setuptools import find_packages, setup +PACKAGE_NAME = "peppy" +# Ordinary dependencies +DEPENDENCIES = [] +with open("requirements/requirements-all.txt", "r") as reqs_file: + for line in reqs_file: + if not line.strip(): + continue + # DEPENDENCIES.append(line.split("=")[0].rstrip("<>")) + DEPENDENCIES.append(line) # Additional keyword arguments for setup(). +extra = {"install_requires": DEPENDENCIES} +# Additional files to include with package +def get_static(name, condition=None): + static = [ + os.path.join(name, f) + for f in os.listdir( + os.path.join(os.path.dirname(os.path.realpath(__file__)), name) + ) + ] + if condition is None: + return static + else: + return [i for i in filter(lambda x: eval(condition), static)] +with open(f"{PACKAGE_NAME}/_version.py", "r") as versionfile: + version = versionfile.readline().split()[-1].strip("\"'\n") with open("README.md") as f: long_description = f.read() setup( + name=PACKAGE_NAME, + packages=[PACKAGE_NAME, "peppy.eido", "peppy.pephubclient"], + version=version, + description="A python-based project metadata manager for portable encapsulated projects", long_description=long_description, long_description_content_type="text/markdown", classifiers=[ + "Development Status :: 4 - Beta", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -25,13 +55,23 @@ "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Bio-Informatics", ], + keywords="project, metadata, bioinformatics, sequencing, ngs, workflow", + url="https://github.com/pepkit/peppy/", + author="Michal Stolarczyk, Nathan Sheffield, Vince Reuter, Andre Rendeiro, Oleksandr Khoroshevskyi", license="BSD2", entry_points={ + "console_scripts": ["peppy = peppy.cli:main"], + "pep.filters": [ + "basic=peppy.eido.conversion_plugins:basic_pep_filter", + "yaml=peppy.eido.conversion_plugins:yaml_pep_filter", + "csv=peppy.eido.conversion_plugins:csv_pep_filter", + "yaml-samples=peppy.eido.conversion_plugins:yaml_samples_pep_filter", ], }, include_package_data=True, + tests_require=(["pytest"]), setup_requires=( ["pytest-runner"] if {"test", "pytest", "ptr"} & set(sys.argv) else [] ), **extra, -) +) \ No newline at end of file diff --git a/tests/data/sample_pep/sample_table.csv b/tests/data/phcdata/sample_pep/sample_table.csv similarity index 100% rename from tests/data/sample_pep/sample_table.csv rename to tests/data/phcdata/sample_pep/sample_table.csv diff --git a/tests/data/sample_pep/subsamp_config.yaml b/tests/data/phcdata/sample_pep/subsamp_config.yaml similarity index 100% rename from tests/data/sample_pep/subsamp_config.yaml rename to tests/data/phcdata/sample_pep/subsamp_config.yaml diff --git a/tests/data/sample_pep/subsample_table.csv b/tests/data/phcdata/sample_pep/subsample_table.csv similarity index 100% rename from tests/data/sample_pep/subsample_table.csv rename to tests/data/phcdata/sample_pep/subsample_table.csv diff --git a/tests/peppytests/conftest.py b/tests/peppytests/conftest.py deleted file mode 100644 index 2d9260f8..00000000 --- a/tests/peppytests/conftest.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Configuration for modules with independent tests of models.""" - -import os - -import pandas as pd -import pytest - -__author__ = "Michal Stolarczyk" -__email__ = "michal.stolarczyk@nih.gov" - -# example_peps branch, see: https://github.com/pepkit/example_peps -EPB = "master" - - -def merge_paths(pep_branch, directory_name): - return os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "data", - "peppydata", - "example_peps-{}".format(pep_branch), - "example_{}".format(directory_name), - ) - - -def get_path_to_example_file(branch, directory_name, file_name): - return os.path.join(merge_paths(branch, directory_name), file_name) - - -@pytest.fixture -def example_pep_cfg_path(request): - return get_path_to_example_file(EPB, request.param, "project_config.yaml") - - -@pytest.fixture -def example_pep_csv_path(request): - return get_path_to_example_file(EPB, request.param, "sample_table.csv") - - -@pytest.fixture -def example_yaml_sample_file(request): - return get_path_to_example_file(EPB, request.param, "sample.yaml") - - -@pytest.fixture -def example_pep_nextflow_csv_path(): - return get_path_to_example_file(EPB, "nextflow_taxprofiler_pep", "samplesheet.csv") - - -@pytest.fixture -def example_pep_cfg_noname_path(request): - return get_path_to_example_file(EPB, "noname", request.param) - - -@pytest.fixture -def example_peps_cfg_paths(request): - """ - This is the same as the ficture above, however, it lets - you return multiple paths (for comparing peps). Will return - list of paths. - """ - return [ - get_path_to_example_file(EPB, p, "project_config.yaml") for p in request.param - ] - - -@pytest.fixture -def config_with_pandas_obj(request): - return pd.read_csv( - get_path_to_example_file(EPB, request.param, "sample_table.csv"), dtype=str - ) diff --git a/tests/peppytests/test_Project.py b/tests/peppytests/test_Project.py deleted file mode 100644 index 91ccd45d..00000000 --- a/tests/peppytests/test_Project.py +++ /dev/null @@ -1,819 +0,0 @@ -import os -import pickle -import socket -import tempfile - -import numpy as np -import pytest -from pandas import DataFrame -from peppy import Project -from peppy.const import SAMPLE_NAME_ATTR, SAMPLE_TABLE_FILE_KEY -from peppy.exceptions import ( - IllegalStateException, - InvalidSampleTableFileException, - MissingAmendmentError, - RemoteYAMLError, -) -from yaml import dump, safe_load - -__author__ = "Michal Stolarczyk" -__email__ = "michal.stolarczyk@nih.gov" - -EXAMPLE_TYPES = [ - "basic", - "derive", - "imply", - "append", - "amendments1", - "amendments2", - "derive_imply", - "duplicate", - "imports", - "subtable1", - "subtable2", - "subtable3", - "subtable4", - "subtable5", - "remove", - "issue499", -] - - -def _get_pair_to_post_init_test(cfg_path): - """ - - :param cfg_path: path to the project config file - :type cfg_path: str - :return: list of two project objects to compare - :rtype: list[peppy.Project] - """ - p = Project(cfg=cfg_path) - pd = Project(cfg=cfg_path, defer_samples_creation=True) - pd.create_samples(modify=False if pd[SAMPLE_TABLE_FILE_KEY] is not None else True) - return [p, pd] - - -def _cmp_all_samples_attr(p1, p2, attr): - """ - Compare a selected attribute values for all samples in two Projects - - :param p1: project to comapre - :type p1: peppy.Project - :param p2: project to comapre - :type p2: peppy.Project - :param attr: attribute name to compare - :type attr: str - """ - assert [s1.get(attr, "") for s1 in p1.samples] == [ - s2.get(attr, "") for s2 in p2.samples - ] - - -class TestProjectConstructor: - def test_empty(self): - """Verify that an empty Project instance can be created""" - p = Project() - assert isinstance(p, Project) - assert len(p.samples) == 0 - - def test_nonexistent(self): - """Verify that OSError is thrown when config does not exist""" - with pytest.raises(OSError): - Project(cfg="nonexistentfile.yaml") - - @pytest.mark.parametrize("defer", [False, True]) - @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) - def test_instantiaion(self, example_pep_cfg_path, defer): - """ - Verify that Project object is successfully created for every example PEP - """ - p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) - assert isinstance(p, Project) - - @pytest.mark.parametrize("defer", [False, True]) - @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) - def test_expand_path(self, example_pep_cfg_path, defer): - """ - Verify output_path is expanded - """ - p = Project( - cfg=example_pep_cfg_path, - amendments="newLib", - defer_samples_creation=defer, - ) - assert not p.config["output_dir"].startswith("$") - - @pytest.mark.parametrize( - "config_path", - [ - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/project_config.yaml", - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_derive/project_config.yaml", - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imply/project_config.yaml", - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imports/project_config.yaml", - ], - ) - def test_remote(self, config_path): - """ - Verify that remote project configs are supported - """ - p = Project(cfg=config_path) - assert isinstance(p, Project) - - @pytest.mark.parametrize( - "config_path", - [ - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/project_config.yaml", - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_derive/project_config.yaml", - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imply/project_config.yaml", - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imports/project_config.yaml", - ], - ) - def test_remote_simulate_no_network(self, config_path): - """ - Verify correctness of the remote config reading behavior with no network - """ - - def guard(*args, **kwargs): - raise Exception("Block internet connection") - - ori_socket_val = socket.socket - socket.socket = guard - with pytest.raises(RemoteYAMLError): - Project(cfg=config_path) - socket.socket = ori_socket_val - - @pytest.mark.parametrize("example_pep_cfg_path", ["basic", "imply"], indirect=True) - def test_csv_init_autodetect(self, example_pep_cfg_path): - """ - Verify that a CSV file can be used to initialize a config file - """ - assert isinstance(Project(cfg=example_pep_cfg_path), Project) - - @pytest.mark.parametrize( - "csv_path", - [ - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/sample_table.csv", - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imply/sample_table.csv", - ], - ) - def test_remote_csv_init_autodetect(self, csv_path): - """ - Verify that a remote CSV file can be used to initialize a config file - """ - assert isinstance(Project(cfg=csv_path), Project) - - @pytest.mark.parametrize("example_pep_cfg_path", ["automerge"], indirect=True) - def test_automerge(self, example_pep_cfg_path): - """ - Verify that duplicated sample names lead to sample auto-merging - """ - p = Project(cfg=example_pep_cfg_path) - # there are 4 rows in the table, but 1 sample has a duplicate - assert len(p.samples) == 3 - - @pytest.mark.parametrize("example_pep_csv_path", ["automerge"], indirect=True) - def test_automerge_csv(self, example_pep_csv_path): - """ - Verify that duplicated sample names lead to sample auto-merging if object is initialized from a CSV - """ - p = Project(cfg=example_pep_csv_path) - # there are 4 rows in the table, but 1 sample has a duplicate - assert len(p.samples) == 3 - - @pytest.mark.parametrize( - "config_path", - [ - "https://raw.githubusercontent.com/pepkit/example_peps/master/example_automerge/project_config.yaml", - ], - ) - def test_automerge_remote(self, config_path): - """ - Verify that duplicated sample names lead to sample auto-merging from a remote config - """ - p = Project(cfg=config_path) - # there are 4 rows in the table, but 1 sample has a duplicate - assert len(p.samples) == 3 - - @pytest.mark.parametrize( - "example_pep_cfg_path", ["subtable_automerge"], indirect=True - ) - def test_automerge_disallowed_with_subsamples(self, example_pep_cfg_path): - """ - Verify that both duplicated sample names and subsample table specification is disallowed - """ - with pytest.raises(IllegalStateException): - Project(cfg=example_pep_cfg_path) - - @pytest.mark.parametrize("defer", [False, True]) - @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) - def test_amendments(self, example_pep_cfg_path, defer): - """ - Verify that the amendment is activate at object instantiation - """ - p = Project( - cfg=example_pep_cfg_path, amendments="newLib", defer_samples_creation=defer - ) - assert all([s["protocol"] == "ABCD" for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["subtable1"], indirect=True) - def test_subsample_table_works_when_no_sample_mods(self, example_pep_cfg_path): - """ - Verify that subsample table functionality is not - dependant on sample modifiers - """ - p = Project(cfg=example_pep_cfg_path) - assert any([s["file"] != "multi" for s in p.samples]) - - @pytest.mark.parametrize( - "example_pep_cfg_path", ["custom_index", "multiple_subsamples"], indirect=True - ) - def test_custom_sample_table_index_config(self, example_pep_cfg_path): - """ - Verify that custom sample table index is sourced from the config - """ - Project(cfg=example_pep_cfg_path) - - @pytest.mark.parametrize("example_pep_cfg_path", ["incorrect_index"], indirect=True) - def test_cutsom_sample_table_index_config_exception(self, example_pep_cfg_path): - """ - Verify that custom sample table index is sourced from the config - """ - with pytest.raises(InvalidSampleTableFileException): - Project(cfg=example_pep_cfg_path) - - @pytest.mark.parametrize("example_pep_cfg_path", ["custom_index"], indirect=True) - def test_cutsom_sample_table_index_constructor(self, example_pep_cfg_path): - """ - Verify that custom sample table index is sourced from the config - """ - with pytest.raises(InvalidSampleTableFileException): - Project(cfg=example_pep_cfg_path, sample_table_index="bogus_column") - - @pytest.mark.parametrize("example_pep_cfg_path", ["subtables"], indirect=True) - def test_subsample_table_multiple(self, example_pep_cfg_path): - """ - Verify that subsample table functionality in multi subsample context - """ - p = Project(cfg=example_pep_cfg_path) - assert any(["desc" in s for s in p.samples]) - - @pytest.mark.parametrize("defer", [False, True]) - @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) - def test_no_description(self, example_pep_cfg_path, defer): - """ - Verify that Project object is successfully created when no description - is specified in the config - """ - p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) - assert isinstance(p, Project) - assert p.description is None - - @pytest.mark.parametrize("defer", [False, True]) - @pytest.mark.parametrize("desc", ["desc1", "desc 2 123$!@#;11", 11, None]) - @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) - def test_description(self, example_pep_cfg_path, desc, defer): - """ - Verify that Project object contains description specified in the config - """ - td = tempfile.mkdtemp() - temp_path_cfg = os.path.join(td, "config.yaml") - with open(example_pep_cfg_path, "r") as f: - data = safe_load(f) - data["description"] = desc - del data["sample_table"] - with open(temp_path_cfg, "w") as f: - dump(data, f) - p = Project(cfg=temp_path_cfg, defer_samples_creation=defer) - assert isinstance(p, Project) - assert p.description == str(desc) - - @pytest.mark.parametrize( - "example_pep_cfg_noname_path", ["project_config.yaml"], indirect=True - ) - def test_missing_sample_name_derive(self, example_pep_cfg_noname_path): - """ - Verify that even if sample_name column is missing in the sample table, - it can be derived and no error is issued - """ - p = Project(cfg=example_pep_cfg_noname_path) - assert SAMPLE_NAME_ATTR in p.sample_table.columns - - @pytest.mark.parametrize( - "example_pep_cfg_noname_path", ["project_config_noname.yaml"], indirect=True - ) - def test_missing_sample_name(self, example_pep_cfg_noname_path): - """ - Verify that if sample_name column is missing in the sample table an - error is issued - """ - with pytest.raises(InvalidSampleTableFileException): - Project(cfg=example_pep_cfg_noname_path) - - @pytest.mark.parametrize( - "example_pep_cfg_noname_path", ["project_config_noname.yaml"], indirect=True - ) - def test_missing_sample_name_defer(self, example_pep_cfg_noname_path): - """ - Verify that if sample_name column is missing in the sample table an - error is not issued if sample creation is deferred - """ - Project(cfg=example_pep_cfg_noname_path, defer_samples_creation=True) - - @pytest.mark.parametrize( - "example_pep_cfg_noname_path", ["project_config_noname.yaml"], indirect=True - ) - def test_missing_sample_name_custom_index(self, example_pep_cfg_noname_path): - """ - Verify that if sample_name column is missing in the sample table an - error is not issued if a custom sample_table index is set - """ - p = Project(cfg=example_pep_cfg_noname_path, sample_table_index="id") - assert p.sample_name_colname == "id" - - @pytest.mark.parametrize("example_pep_cfg_path", ["custom_index"], indirect=True) - def test_sample_name_custom_index(self, example_pep_cfg_path): - """ - Verify that sample_name attribute becomes st_index from cfg - """ - p = Project(cfg=example_pep_cfg_path) - assert p.sample_name_colname == "NOT_SAMPLE_NAME" - assert p.samples[0].sample_name == "frog_1" - - @pytest.mark.parametrize( - "example_pep_cfg_path", - ["basic"], - indirect=True, - ) - def test_equality(self, example_pep_cfg_path): - p1 = Project(cfg=example_pep_cfg_path) - p2 = Project(cfg=example_pep_cfg_path) - - assert p1 == p2 - - @pytest.mark.parametrize( - "example_peps_cfg_paths", [["basic", "BiocProject"]], indirect=True - ) - def test_inequality(self, example_peps_cfg_paths): - cfg1, cfg2 = example_peps_cfg_paths - p1 = Project(cfg=cfg1) - p2 = Project(cfg=cfg2) - assert p1 != p2 - - @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) - def test_from_dict_instatiation(self, example_pep_cfg_path): - """ - Verify that we can accurately instiate a project from its dictionary - representation. - """ - p1 = Project(cfg=example_pep_cfg_path) - p2 = Project.from_dict(p1.to_dict(extended=True)) - assert p1 == p2 - - def test_to_dict_does_not_create_nans(self, example_pep_nextflow_csv_path): - wrong_values = ["NaN", np.nan, "nan"] - - p1 = Project( - cfg=example_pep_nextflow_csv_path, sample_table_index="sample" - ).to_dict() - for sample in p1.get("samples"): - for attribute, value in sample.items(): - assert value not in wrong_values - - @pytest.mark.parametrize("example_pep_cfg_path", ["missing_version"], indirect=True) - def test_missing_version(self, example_pep_cfg_path): - """ - Verify that peppy can load a config file with no pep version - """ - p = Project(cfg=example_pep_cfg_path) - assert isinstance(p.pep_version, str) - - @pytest.mark.parametrize("example_pep_csv_path", ["basic"], indirect=True) - def test_sample_table_version(self, example_pep_csv_path): - """ - Verify that peppy can load a config file with no pep version - """ - p = Project(cfg=example_pep_csv_path) - assert isinstance(p.pep_version, str) - - @pytest.mark.parametrize( - "example_pep_csv_path", ["nextflow_samplesheet"], indirect=True - ) - def test_auto_merge_duplicated_names_works_for_different_read_types( - self, example_pep_csv_path - ): - p = Project(example_pep_csv_path, sample_table_index="sample") - assert len(p.samples) == 4 - - @pytest.mark.parametrize( - "expected_attribute", - [ - "sample", - "instrument_platform", - "run_accession", - "fastq_1", - "fastq_2", - "fasta", - ], - ) - @pytest.mark.parametrize("example_pep_cfg_path", ["nextflow_config"], indirect=True) - def test_peppy_initializes_samples_with_correct_attributes( - self, example_pep_cfg_path, expected_attribute - ): - p = Project(example_pep_cfg_path, sample_table_index="sample") - assert all([expected_attribute in sample for sample in p.samples]) - - # @pytest.mark.skip( - # "skipping this test, because this functionality is unavailable now" - # ) - @pytest.mark.parametrize("example_pep_cfg_path", ["basic", "imply"], indirect=True) - def test_correct_pickle(self, example_pep_cfg_path): - proj = Project(example_pep_cfg_path) - pickled_data = pickle.dumps(proj) - unpickled_project = pickle.loads(pickled_data) - - assert proj == unpickled_project - - -class TestProjectManipulationTests: - @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) - def test_amendments_activation_interactive(self, example_pep_cfg_path): - """ - Verify that the amendment can be activated interactively - """ - p = Project(cfg=example_pep_cfg_path) - p.activate_amendments("newLib") - assert all([s["protocol"] == "ABCD" for s in p.samples]) - assert p.amendments is not None - - @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) - def test_amendments_deactivation_interactive(self, example_pep_cfg_path): - """ - Verify that the amendment can be activated interactively - """ - p = Project(cfg=example_pep_cfg_path) - p.deactivate_amendments() - assert all([s["protocol"] != "ABCD" for s in p.samples]) - p.activate_amendments("newLib") - p.deactivate_amendments() - assert all([s["protocol"] != "ABCD" for s in p.samples]) - assert p.amendments is None - - @pytest.mark.parametrize("defer", [False, True]) - @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) - def test_missing_amendment_raises_correct_exception( - self, example_pep_cfg_path, defer - ): - with pytest.raises(MissingAmendmentError): - Project( - cfg=example_pep_cfg_path, - amendments="nieznany", - defer_samples_creation=defer, - ) - - @pytest.mark.parametrize("defer", [False, True]) - @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) - def test_amendments_argument_cant_be_null(self, example_pep_cfg_path, defer): - p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) - with pytest.raises(TypeError): - p.activate_amendments(amendments=None) - - @pytest.mark.parametrize("defer", [False, True]) - @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) - def test_str_repr_correctness(self, example_pep_cfg_path, defer): - """ - Verify string representation correctness - """ - p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) - str_repr = p.__str__() - assert example_pep_cfg_path in str_repr - assert "{} samples".format(str(len(p.samples))) in str_repr - assert p.name in str_repr - - @pytest.mark.parametrize("defer", [False, True]) - @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) - def test_amendments_listing(self, example_pep_cfg_path, defer): - p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) - assert isinstance(p.list_amendments, list) - - @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) - def test_sample_updates_regenerate_df(self, example_pep_cfg_path): - """ - Verify that Sample modifications cause sample_table regeneration - """ - p = Project(cfg=example_pep_cfg_path) - s_ori = p.sample_table - p.samples[0].update({"witam": "i_o_zdrowie_pytam"}) - assert not p.sample_table.equals(s_ori) - - @pytest.mark.parametrize("example_pep_cfg_path", ["subtable1"], indirect=True) - def test_subsample_table_property(self, example_pep_cfg_path): - """ - Verify that Sample modifications cause sample_table regeneration - """ - p = Project(cfg=example_pep_cfg_path) - assert isinstance(p.subsample_table, DataFrame) or isinstance( - p.subsample_table, list - ) - - @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) - def test_get_sample(self, example_pep_cfg_path): - """Verify that sample getting method works""" - p = Project(cfg=example_pep_cfg_path) - p.get_sample(sample_name=p.samples[0]["sample_name"]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) - def test_get_sample_nonexistent(self, example_pep_cfg_path): - """Verify that sample getting returns ValueError if not sample found""" - p = Project(cfg=example_pep_cfg_path) - with pytest.raises(ValueError): - p.get_sample(sample_name="kdkdkdk") - - -class TestPostInitSampleCreation: - @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) - def test_append(self, example_pep_cfg_path): - """ - Verify that the appending works the same way in a post init - sample creation scenario - """ - p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) - _cmp_all_samples_attr(p, pd, "read_type") - - @pytest.mark.parametrize("example_pep_cfg_path", ["imports"], indirect=True) - def test_imports(self, example_pep_cfg_path): - """ - Verify that the importing works the same way in a post init - sample creation scenario - """ - p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) - _cmp_all_samples_attr(p, pd, "imported_attr") - - @pytest.mark.parametrize("example_pep_cfg_path", ["imply"], indirect=True) - def test_imply(self, example_pep_cfg_path): - """ - Verify that the implication the same way in a post init - sample creation scenario - """ - p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) - _cmp_all_samples_attr(p, pd, "genome") - - @pytest.mark.parametrize("example_pep_cfg_path", ["duplicate"], indirect=True) - def test_duplicate(self, example_pep_cfg_path): - """ - Verify that the duplication the same way in a post init - sample creation scenario - """ - p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) - _cmp_all_samples_attr(p, pd, "organism") - - @pytest.mark.parametrize("example_pep_cfg_path", ["derive"], indirect=True) - def test_derive(self, example_pep_cfg_path): - """ - Verify that the derivation the same way in a post init - sample creation scenario - """ - p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) - _cmp_all_samples_attr(p, pd, "file_path") - - @pytest.mark.parametrize("example_pep_cfg_path", ["issue499"], indirect=True) - def test_issue499(self, example_pep_cfg_path): - """ - Verify that the derivation the same way in a post init - sample creation scenario - """ - p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) - _cmp_all_samples_attr(p, pd, "file_path") - - @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) - def test_equality(self, example_pep_cfg_path): - """ - Test equality function of two projects - """ - p1 = Project(cfg=example_pep_cfg_path) - p2 = Project(cfg=example_pep_cfg_path) - assert p1 == p2 - - @pytest.mark.parametrize( - "example_pep_cfg_path, example_pep_csv_path", - [["append", "derive"]], - indirect=True, - ) - def test_unequality(self, example_pep_cfg_path, example_pep_csv_path): - """ - Test equality function of two projects - """ - p1 = Project(cfg=example_pep_cfg_path) - p2 = Project(cfg=example_pep_csv_path) - assert not p1 == p2 - - @pytest.mark.parametrize( - "example_pep_cfg_path", - ["append", "custom_index", "imply", "subtables", "multiple_subsamples"], - indirect=True, - ) - @pytest.mark.parametrize("orient", ["dict", "records"]) - def test_from_dict(self, example_pep_cfg_path, orient): - """ - Test initializing project from dict - """ - p1 = Project(cfg=example_pep_cfg_path) - p1_dict = p1.to_dict(extended=True, orient=orient) - del p1_dict["_config"]["sample_table"] - p2 = Project.from_dict(p1_dict) - assert p1 == p2 - - @pytest.mark.parametrize( - "config_with_pandas_obj, example_pep_csv_path", - [ - ["append", "append"], - ["derive", "derive"], - ["subtable1", "subtable1"], - ], - indirect=True, - ) - def test_from_pandas(self, config_with_pandas_obj, example_pep_csv_path): - """ - Test initializing project from dict - """ - p1 = Project.from_pandas(config_with_pandas_obj) - p2 = Project(example_pep_csv_path) - assert p1 == p2 - - @pytest.mark.parametrize( - "example_yaml_sample_file", - [ - "basic_sample_yaml", - ], - indirect=True, - ) - def test_from_yaml(self, example_yaml_sample_file): - """ - Test initializing project from dict - """ - p1 = Project.from_sample_yaml(example_yaml_sample_file) - assert p1.samples[0].sample_name == "sample1" - assert len(p1.samples) == 3 - - @pytest.mark.parametrize( - "config_with_pandas_obj, example_pep_csv_path", - [ - ["append", "append"], - ["derive", "derive"], - ["subtable1", "subtable1"], - ], - indirect=True, - ) - def test_from_pandas_unequal(self, config_with_pandas_obj, example_pep_csv_path): - """ - Test initializing project from pandas changing one of the samples - and checking inequality - """ - p1 = Project().from_pandas(config_with_pandas_obj) - - del p1.samples[0].sample_name - p2 = Project(example_pep_csv_path) - assert p1 != p2 - - @pytest.mark.parametrize( - "example_pep_cfg_path", - ["append"], - indirect=True, - ) - def test_description_setter(self, example_pep_cfg_path): - new_description = "new_description1" - p = Project(cfg=example_pep_cfg_path) - p.description = new_description - - assert p.description == new_description - assert p.to_dict(extended=True)["_config"]["description"] == new_description - - @pytest.mark.parametrize( - "example_pep_cfg_path", - ["append"], - indirect=True, - ) - def test_name_setter(self, example_pep_cfg_path): - new_name = "new_name1" - p = Project(cfg=example_pep_cfg_path) - p.name = new_name - - assert p.name == new_name - assert p.to_dict(extended=True)["_config"]["name"] == new_name - - -class TestSampleAttrMap: - @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) - def test_sample_getattr(self, example_pep_cfg_path): - """ - Verify that the getattr works - """ - p = Project(cfg=example_pep_cfg_path) - p1 = Project(cfg=example_pep_cfg_path) - - for s1, s2 in zip(p.samples, p1.samples): - assert s1.sample_name == s1["sample_name"] - assert s2.organism == s2["organism"] - - @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) - def test_sample_settatr(self, example_pep_cfg_path): - """ - Verify that the setattr works - """ - p = Project(cfg=example_pep_cfg_path) - new_name = "bingo" - p.samples[0].sample_name = new_name - - df = p.samples[0].to_dict() - assert df["sample_name"] == new_name - - @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) - def test_sample_len(self, example_pep_cfg_path): - """ - Verify that the len works - """ - p = Project(cfg=example_pep_cfg_path) - assert len(p.samples[0]) == 4 - - @pytest.mark.parametrize("example_pep_cfg_path", ["subsamples_none"], indirect=True) - def test_config_with_subsample_null(self, example_pep_cfg_path): - """ - Tests if config can have value with subsample=null - """ - p = Project(cfg=example_pep_cfg_path) - assert p.subsample_table is None - - @pytest.mark.parametrize( - "example_pep_cfg_path", ["nextflow_subsamples"], indirect=True - ) - def test_nextflow_subsamples(self, example_pep_cfg_path): - """ - Tests if config can have value with subsample=null - """ - p = Project(cfg=example_pep_cfg_path) - assert isinstance(p, Project) - - -class TestSampleModifiers: - @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) - def test_append(self, example_pep_cfg_path): - """Verify that the appended attribute is added to the samples""" - p = Project(cfg=example_pep_cfg_path) - assert all([s["read_type"] == "SINGLE" for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["imports"], indirect=True) - def test_imports(self, example_pep_cfg_path): - """Verify that the imported attribute is added to the samples""" - p = Project(cfg=example_pep_cfg_path) - assert all([s["imported_attr"] == "imported_val" for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["imply"], indirect=True) - def test_imply(self, example_pep_cfg_path): - """ - Verify that the implied attribute is added to the correct samples - """ - p = Project(cfg=example_pep_cfg_path) - assert all( - [s["genome"] == "hg38" for s in p.samples if s["organism"] == "human"] - ) - assert all( - [s["genome"] == "mm10" for s in p.samples if s["organism"] == "mouse"] - ) - - @pytest.mark.parametrize("example_pep_cfg_path", ["duplicate"], indirect=True) - def test_duplicate(self, example_pep_cfg_path): - """ - Verify that the duplicated attribute is identical to the original - """ - p = Project(cfg=example_pep_cfg_path) - assert all([s["organism"] == s["animal"] for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["derive"], indirect=True) - def test_derive(self, example_pep_cfg_path): - """ - Verify that the declared attr derivation happened - """ - p = Project(cfg=example_pep_cfg_path) - assert all(["file_path" in s for s in p.samples]) - assert all(["file_path" in s._derived_cols_done for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["remove"], indirect=True) - def test_remove(self, example_pep_cfg_path): - """ - Verify that the declared attr was eliminated from every sample - """ - p = Project(cfg=example_pep_cfg_path) - assert all(["protocol" not in s for s in p.samples]) - - @pytest.mark.parametrize("example_pep_cfg_path", ["subtable2"], indirect=True) - def test_subtable(self, example_pep_cfg_path): - """ - Verify that the sample merging takes place - """ - p = Project(cfg=example_pep_cfg_path) - assert all( - [ - isinstance(s["file"], list) - for s in p.samples - if s["sample_name"] in ["frog_1", "frog2"] - ] - ) diff --git a/tests/peppytests/test_Sample.py b/tests/peppytests/test_Sample.py deleted file mode 100644 index 62b9bf0c..00000000 --- a/tests/peppytests/test_Sample.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -import tempfile - -import pytest -from peppy import Project - -__author__ = "Michal Stolarczyk" -__email__ = "michal.stolarczyk@nih.gov" - -EXAMPLE_TYPES = [ - "basic", - "derive", - "imply", - "append", - "amendments1", - "amendments2", - "derive_imply", - "duplicate", - "imports", - "subtable1", - "subtable2", - "subtable3", - "subtable4", - "subtable5", - "remove", -] - - -class TestSample: - @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) - def test_serialization(self, example_pep_cfg_path): - """ - Verify that Project object is successfully created for every example PEP - """ - td = tempfile.mkdtemp() - fn = os.path.join(td, "serialized_sample.yaml") - p = Project(cfg=example_pep_cfg_path) - sample = p.samples[0] - sample["set"] = set("set") - sample["dict"] = dict({"dict": "dict"}) - sample["list"] = list(["list"]) - sample.to_yaml(fn) - with open(fn, "r") as f: - contents = f.read() - assert "set" in contents - assert "dict" in contents - assert "list" in contents - - @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) - def test_str_repr_correctness(self, example_pep_cfg_path): - """ - Verify that the missing amendment request raises correct exception - """ - p = Project(cfg=example_pep_cfg_path) - for sample in p.samples: - str_repr = sample.__str__(max_attr=100) - assert example_pep_cfg_path in str_repr - assert "Sample '{}'".format(sample["sample_name"]) in str_repr - - @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) - def test_sample_to_yaml_no_path(self, example_pep_cfg_path): - """ - Verify that to_yaml returns representation without requiring a path. - """ - p = Project(cfg=example_pep_cfg_path) - for sample in p.samples: - yaml_repr = sample.to_yaml() - assert "sample_name" in yaml_repr - - @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) - def test_sheet_dict_excludes_private_attrs(self, example_pep_cfg_path): - """ - Verify that sheet dict includes only original Sample attributes - """ - p = Project(cfg=example_pep_cfg_path) - for sample in p.samples: - assert len(sample.get_sheet_dict()) == len(p.sample_table.columns) - - @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) - def test_pickle_in_samples(self, example_pep_cfg_path): - import pickle - - p = Project(cfg=example_pep_cfg_path) - for sample in p.samples: - pickled_data = pickle.dumps(sample) - unpickled_sample = pickle.loads(pickled_data) - - assert sample.to_dict() == unpickled_sample.to_dict() - - @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) - def test_equals_samples(self, example_pep_cfg_path): - p1 = Project(cfg=example_pep_cfg_path) - p2 = Project(cfg=example_pep_cfg_path) - s1 = p1.samples[0] - s2 = p2.samples[0] - - assert s1 == s2 - - @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) - def test_not_equals_samples(self, example_pep_cfg_path): - p1 = Project(cfg=example_pep_cfg_path) - p2 = Project(cfg=example_pep_cfg_path) - s1 = p1.samples[0] - s2 = p2.samples[0] - s3 = p2.samples[1] - - s2.new = "something" - assert not s1 == s2 - assert not s1 == s3 diff --git a/tests/__init__.py b/tests/phctests/__init__.py similarity index 100% rename from tests/__init__.py rename to tests/phctests/__init__.py diff --git a/tests/conftest.py b/tests/phctests/conftest.py similarity index 77% rename from tests/conftest.py rename to tests/phctests/conftest.py index e0a54692..3eac1a40 100644 --- a/tests/conftest.py +++ b/tests/phctests/conftest.py @@ -1,14 +1,15 @@ import pytest +import os -from pephubclient.pephub_oauth.models import InitializeDeviceCodeResponse - -@pytest.fixture() -def device_code_return(): - device_code = "asdf2345" - return InitializeDeviceCodeResponse( - device_code=device_code, - auth_url=f"any_base_url/auth/device/login/{device_code}", +@pytest.fixture +def SAMPLE_PEP(): + return os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", + "phcdata", + "sample_pep", + "subsamp_config.yaml", ) diff --git a/tests/test_manual.py b/tests/phctests/test_manual.py similarity index 97% rename from tests/test_manual.py rename to tests/phctests/test_manual.py index cac8be0c..71d73bcb 100644 --- a/tests/test_manual.py +++ b/tests/phctests/test_manual.py @@ -1,4 +1,4 @@ -from pephubclient.pephubclient import PEPHubClient +from peppy.pephubclient.pephubclient import PEPHubClient import pytest diff --git a/tests/test_pephubclient.py b/tests/phctests/test_pephubclient.py similarity index 91% rename from tests/test_pephubclient.py rename to tests/phctests/test_pephubclient.py index 211a1436..6d406ee2 100644 --- a/tests/test_pephubclient.py +++ b/tests/phctests/test_pephubclient.py @@ -3,17 +3,20 @@ import pytest -from pephubclient.exceptions import ResponseError -from pephubclient.pephubclient import PEPHubClient -from pephubclient.helpers import is_registry_path +from peppy.pephubclient.exceptions import ResponseError +from peppy.pephubclient.pephubclient import PEPHubClient +from peppy.pephubclient.helpers import is_registry_path +from peppy.pephubclient.pephub_oauth.models import InitializeDeviceCodeResponse -SAMPLE_PEP = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "tests", - "data", - "sample_pep", - "subsamp_config.yaml", -) + + +@pytest.fixture() +def device_code_return(): + device_code = "asdf2345" + return InitializeDeviceCodeResponse( + device_code=device_code, + auth_url=f"any_base_url/auth/device/login/{device_code}", + ) class TestSmoke: @@ -26,16 +29,17 @@ def test_login(self, mocker, device_code_return, test_jwt): return_value=Mock(content=device_code_return, status_code=200), ) pephub_response_mock = mocker.patch( - "pephubclient.pephub_oauth.pephub_oauth.PEPHubAuth._handle_pephub_response", + "peppy.pephubclient.pephub_oauth.pephub_oauth.PEPHubAuth._handle_pephub_response", return_value=device_code_return, ) + pephub_exchange_code_mock = mocker.patch( - "pephubclient.pephub_oauth.pephub_oauth.PEPHubAuth._exchange_device_code_on_token", + "peppy.pephubclient.pephub_oauth.pephub_oauth.PEPHubAuth._exchange_device_code_on_token", return_value=test_jwt, ) pathlib_mock = mocker.patch( - "pephubclient.files_manager.FilesManager.save_jwt_data_to_file" + "peppy.pephubclient.files_manager.FilesManager.save_jwt_data_to_file" ) PEPHubClient().login() @@ -53,7 +57,7 @@ def test_logout(self, mocker): def test_pull(self, mocker, test_jwt, test_raw_pep_return): jwt_mock = mocker.patch( - "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + "peppy.pephubclient.files_manager.FilesManager.load_jwt_data_from_file", return_value=test_jwt, ) requests_mock = mocker.patch( @@ -61,16 +65,16 @@ def test_pull(self, mocker, test_jwt, test_raw_pep_return): return_value=Mock(content="some return", status_code=200), ) mocker.patch( - "pephubclient.helpers.RequestManager.decode_response", + "peppy.pephubclient.helpers.RequestManager.decode_response", return_value=test_raw_pep_return, ) save_yaml_mock = mocker.patch( - "pephubclient.files_manager.FilesManager.save_yaml" + "peppy.pephubclient.files_manager.FilesManager.save_yaml" ) save_sample_mock = mocker.patch( - "pephubclient.files_manager.FilesManager.save_pandas" + "peppy.pephubclient.files_manager.FilesManager.save_pandas" ) - mocker.patch("pephubclient.files_manager.FilesManager.create_project_folder") + mocker.patch("peppy.pephubclient.files_manager.FilesManager.create_project_folder") PEPHubClient().pull("some/project") @@ -96,7 +100,7 @@ def test_pull_with_pephub_error_response( self, mocker, test_jwt, status_code, expected_error_message ): mocker.patch( - "pephubclient.files_manager.FilesManager.load_jwt_data_from_file", + "peppy.pephubclient.files_manager.FilesManager.load_jwt_data_from_file", return_value=test_jwt, ) mocker.patch( @@ -111,7 +115,7 @@ def test_pull_with_pephub_error_response( assert e.value.message == expected_error_message - def test_push(self, mocker, test_jwt): + def test_push(self, mocker, test_jwt, SAMPLE_PEP): requests_mock = mocker.patch( "requests.request", return_value=Mock(status_code=202) ) @@ -139,7 +143,7 @@ def test_push(self, mocker, test_jwt): ], ) def test_push_with_pephub_error_response( - self, mocker, status_code, expected_error_message + self, mocker, status_code, expected_error_message, SAMPLE_PEP ): mocker.patch("requests.request", return_value=Mock(status_code=status_code)) with pytest.raises(ResponseError, match=expected_error_message): @@ -179,7 +183,7 @@ def test_search_prj(self, mocker): return_value=Mock(content=return_value, status_code=200), ) mocker.patch( - "pephubclient.helpers.RequestManager.decode_response", + "peppy.pephubclient.helpers.RequestManager.decode_response", return_value=return_value, ) @@ -242,7 +246,7 @@ def test_get(self, mocker): return_value=Mock(content=return_value, status_code=200), ) mocker.patch( - "pephubclient.helpers.RequestManager.decode_response", + "peppy.pephubclient.helpers.RequestManager.decode_response", return_value=return_value, ) return_value = PEPHubClient().sample.get( @@ -436,7 +440,7 @@ def test_get(self, mocker, test_raw_pep_return): return_value=Mock(content=return_value, status_code=200), ) mocker.patch( - "pephubclient.helpers.RequestManager.decode_response", + "peppy.pephubclient.helpers.RequestManager.decode_response", return_value=return_value, ) From fa215bfdfa638f943b09cd293adf97f7abd1a64a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 00:31:21 -0500 Subject: [PATCH 138/165] extra tests --- tests/peppytests/conftest.py | 70 +++ tests/peppytests/test_Project.py | 819 +++++++++++++++++++++++++++++++ tests/peppytests/test_Sample.py | 109 ++++ 3 files changed, 998 insertions(+) create mode 100644 tests/peppytests/conftest.py create mode 100644 tests/peppytests/test_Project.py create mode 100644 tests/peppytests/test_Sample.py diff --git a/tests/peppytests/conftest.py b/tests/peppytests/conftest.py new file mode 100644 index 00000000..1b87b0a7 --- /dev/null +++ b/tests/peppytests/conftest.py @@ -0,0 +1,70 @@ +"""Configuration for modules with independent tests of models.""" + +import os + +import pandas as pd +import pytest + +__author__ = "Michal Stolarczyk" +__email__ = "michal.stolarczyk@nih.gov" + +# example_peps branch, see: https://github.com/pepkit/example_peps +EPB = "master" + + +def merge_paths(pep_branch, directory_name): + return os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", + "peppydata", + "example_peps-{}".format(pep_branch), + "example_{}".format(directory_name), + ) + + +def get_path_to_example_file(branch, directory_name, file_name): + return os.path.join(merge_paths(branch, directory_name), file_name) + + +@pytest.fixture +def example_pep_cfg_path(request): + return get_path_to_example_file(EPB, request.param, "project_config.yaml") + + +@pytest.fixture +def example_pep_csv_path(request): + return get_path_to_example_file(EPB, request.param, "sample_table.csv") + + +@pytest.fixture +def example_yaml_sample_file(request): + return get_path_to_example_file(EPB, request.param, "sample.yaml") + + +@pytest.fixture +def example_pep_nextflow_csv_path(): + return get_path_to_example_file(EPB, "nextflow_taxprofiler_pep", "samplesheet.csv") + + +@pytest.fixture +def example_pep_cfg_noname_path(request): + return get_path_to_example_file(EPB, "noname", request.param) + + +@pytest.fixture +def example_peps_cfg_paths(request): + """ + This is the same as the ficture above, however, it lets + you return multiple paths (for comparing peps). Will return + list of paths. + """ + return [ + get_path_to_example_file(EPB, p, "project_config.yaml") for p in request.param + ] + + +@pytest.fixture +def config_with_pandas_obj(request): + return pd.read_csv( + get_path_to_example_file(EPB, request.param, "sample_table.csv"), dtype=str + ) \ No newline at end of file diff --git a/tests/peppytests/test_Project.py b/tests/peppytests/test_Project.py new file mode 100644 index 00000000..95b2e230 --- /dev/null +++ b/tests/peppytests/test_Project.py @@ -0,0 +1,819 @@ +import os +import pickle +import socket +import tempfile + +import numpy as np +import pytest +from pandas import DataFrame +from peppy import Project +from peppy.const import SAMPLE_NAME_ATTR, SAMPLE_TABLE_FILE_KEY +from peppy.exceptions import ( + IllegalStateException, + InvalidSampleTableFileException, + MissingAmendmentError, + RemoteYAMLError, +) +from yaml import dump, safe_load + +__author__ = "Michal Stolarczyk" +__email__ = "michal.stolarczyk@nih.gov" + +EXAMPLE_TYPES = [ + "basic", + "derive", + "imply", + "append", + "amendments1", + "amendments2", + "derive_imply", + "duplicate", + "imports", + "subtable1", + "subtable2", + "subtable3", + "subtable4", + "subtable5", + "remove", + "issue499", +] + + +def _get_pair_to_post_init_test(cfg_path): + """ + + :param cfg_path: path to the project config file + :type cfg_path: str + :return: list of two project objects to compare + :rtype: list[peppy.Project] + """ + p = Project(cfg=cfg_path) + pd = Project(cfg=cfg_path, defer_samples_creation=True) + pd.create_samples(modify=False if pd[SAMPLE_TABLE_FILE_KEY] is not None else True) + return [p, pd] + + +def _cmp_all_samples_attr(p1, p2, attr): + """ + Compare a selected attribute values for all samples in two Projects + + :param p1: project to comapre + :type p1: peppy.Project + :param p2: project to comapre + :type p2: peppy.Project + :param attr: attribute name to compare + :type attr: str + """ + assert [s1.get(attr, "") for s1 in p1.samples] == [ + s2.get(attr, "") for s2 in p2.samples + ] + + +class TestProjectConstructor: + def test_empty(self): + """Verify that an empty Project instance can be created""" + p = Project() + assert isinstance(p, Project) + assert len(p.samples) == 0 + + def test_nonexistent(self): + """Verify that OSError is thrown when config does not exist""" + with pytest.raises(OSError): + Project(cfg="nonexistentfile.yaml") + + @pytest.mark.parametrize("defer", [False, True]) + @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) + def test_instantiaion(self, example_pep_cfg_path, defer): + """ + Verify that Project object is successfully created for every example PEP + """ + p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) + assert isinstance(p, Project) + + @pytest.mark.parametrize("defer", [False, True]) + @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) + def test_expand_path(self, example_pep_cfg_path, defer): + """ + Verify output_path is expanded + """ + p = Project( + cfg=example_pep_cfg_path, + amendments="newLib", + defer_samples_creation=defer, + ) + assert not p.config["output_dir"].startswith("$") + + @pytest.mark.parametrize( + "config_path", + [ + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/project_config.yaml", + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_derive/project_config.yaml", + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imply/project_config.yaml", + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imports/project_config.yaml", + ], + ) + def test_remote(self, config_path): + """ + Verify that remote project configs are supported + """ + p = Project(cfg=config_path) + assert isinstance(p, Project) + + @pytest.mark.parametrize( + "config_path", + [ + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/project_config.yaml", + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_derive/project_config.yaml", + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imply/project_config.yaml", + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imports/project_config.yaml", + ], + ) + def test_remote_simulate_no_network(self, config_path): + """ + Verify correctness of the remote config reading behavior with no network + """ + + def guard(*args, **kwargs): + raise Exception("Block internet connection") + + ori_socket_val = socket.socket + socket.socket = guard + with pytest.raises(RemoteYAMLError): + Project(cfg=config_path) + socket.socket = ori_socket_val + + @pytest.mark.parametrize("example_pep_cfg_path", ["basic", "imply"], indirect=True) + def test_csv_init_autodetect(self, example_pep_cfg_path): + """ + Verify that a CSV file can be used to initialize a config file + """ + assert isinstance(Project(cfg=example_pep_cfg_path), Project) + + @pytest.mark.parametrize( + "csv_path", + [ + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/sample_table.csv", + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_imply/sample_table.csv", + ], + ) + def test_remote_csv_init_autodetect(self, csv_path): + """ + Verify that a remote CSV file can be used to initialize a config file + """ + assert isinstance(Project(cfg=csv_path), Project) + + @pytest.mark.parametrize("example_pep_cfg_path", ["automerge"], indirect=True) + def test_automerge(self, example_pep_cfg_path): + """ + Verify that duplicated sample names lead to sample auto-merging + """ + p = Project(cfg=example_pep_cfg_path) + # there are 4 rows in the table, but 1 sample has a duplicate + assert len(p.samples) == 3 + + @pytest.mark.parametrize("example_pep_csv_path", ["automerge"], indirect=True) + def test_automerge_csv(self, example_pep_csv_path): + """ + Verify that duplicated sample names lead to sample auto-merging if object is initialized from a CSV + """ + p = Project(cfg=example_pep_csv_path) + # there are 4 rows in the table, but 1 sample has a duplicate + assert len(p.samples) == 3 + + @pytest.mark.parametrize( + "config_path", + [ + "https://raw.githubusercontent.com/pepkit/example_peps/master/example_automerge/project_config.yaml", + ], + ) + def test_automerge_remote(self, config_path): + """ + Verify that duplicated sample names lead to sample auto-merging from a remote config + """ + p = Project(cfg=config_path) + # there are 4 rows in the table, but 1 sample has a duplicate + assert len(p.samples) == 3 + + @pytest.mark.parametrize( + "example_pep_cfg_path", ["subtable_automerge"], indirect=True + ) + def test_automerge_disallowed_with_subsamples(self, example_pep_cfg_path): + """ + Verify that both duplicated sample names and subsample table specification is disallowed + """ + with pytest.raises(IllegalStateException): + Project(cfg=example_pep_cfg_path) + + @pytest.mark.parametrize("defer", [False, True]) + @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) + def test_amendments(self, example_pep_cfg_path, defer): + """ + Verify that the amendment is activate at object instantiation + """ + p = Project( + cfg=example_pep_cfg_path, amendments="newLib", defer_samples_creation=defer + ) + assert all([s["protocol"] == "ABCD" for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["subtable1"], indirect=True) + def test_subsample_table_works_when_no_sample_mods(self, example_pep_cfg_path): + """ + Verify that subsample table functionality is not + dependant on sample modifiers + """ + p = Project(cfg=example_pep_cfg_path) + assert any([s["file"] != "multi" for s in p.samples]) + + @pytest.mark.parametrize( + "example_pep_cfg_path", ["custom_index", "multiple_subsamples"], indirect=True + ) + def test_custom_sample_table_index_config(self, example_pep_cfg_path): + """ + Verify that custom sample table index is sourced from the config + """ + Project(cfg=example_pep_cfg_path) + + @pytest.mark.parametrize("example_pep_cfg_path", ["incorrect_index"], indirect=True) + def test_cutsom_sample_table_index_config_exception(self, example_pep_cfg_path): + """ + Verify that custom sample table index is sourced from the config + """ + with pytest.raises(InvalidSampleTableFileException): + Project(cfg=example_pep_cfg_path) + + @pytest.mark.parametrize("example_pep_cfg_path", ["custom_index"], indirect=True) + def test_cutsom_sample_table_index_constructor(self, example_pep_cfg_path): + """ + Verify that custom sample table index is sourced from the config + """ + with pytest.raises(InvalidSampleTableFileException): + Project(cfg=example_pep_cfg_path, sample_table_index="bogus_column") + + @pytest.mark.parametrize("example_pep_cfg_path", ["subtables"], indirect=True) + def test_subsample_table_multiple(self, example_pep_cfg_path): + """ + Verify that subsample table functionality in multi subsample context + """ + p = Project(cfg=example_pep_cfg_path) + assert any(["desc" in s for s in p.samples]) + + @pytest.mark.parametrize("defer", [False, True]) + @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) + def test_no_description(self, example_pep_cfg_path, defer): + """ + Verify that Project object is successfully created when no description + is specified in the config + """ + p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) + assert isinstance(p, Project) + assert p.description is None + + @pytest.mark.parametrize("defer", [False, True]) + @pytest.mark.parametrize("desc", ["desc1", "desc 2 123$!@#;11", 11, None]) + @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) + def test_description(self, example_pep_cfg_path, desc, defer): + """ + Verify that Project object contains description specified in the config + """ + td = tempfile.mkdtemp() + temp_path_cfg = os.path.join(td, "config.yaml") + with open(example_pep_cfg_path, "r") as f: + data = safe_load(f) + data["description"] = desc + del data["sample_table"] + with open(temp_path_cfg, "w") as f: + dump(data, f) + p = Project(cfg=temp_path_cfg, defer_samples_creation=defer) + assert isinstance(p, Project) + assert p.description == str(desc) + + @pytest.mark.parametrize( + "example_pep_cfg_noname_path", ["project_config.yaml"], indirect=True + ) + def test_missing_sample_name_derive(self, example_pep_cfg_noname_path): + """ + Verify that even if sample_name column is missing in the sample table, + it can be derived and no error is issued + """ + p = Project(cfg=example_pep_cfg_noname_path) + assert SAMPLE_NAME_ATTR in p.sample_table.columns + + @pytest.mark.parametrize( + "example_pep_cfg_noname_path", ["project_config_noname.yaml"], indirect=True + ) + def test_missing_sample_name(self, example_pep_cfg_noname_path): + """ + Verify that if sample_name column is missing in the sample table an + error is issued + """ + with pytest.raises(InvalidSampleTableFileException): + Project(cfg=example_pep_cfg_noname_path) + + @pytest.mark.parametrize( + "example_pep_cfg_noname_path", ["project_config_noname.yaml"], indirect=True + ) + def test_missing_sample_name_defer(self, example_pep_cfg_noname_path): + """ + Verify that if sample_name column is missing in the sample table an + error is not issued if sample creation is deferred + """ + Project(cfg=example_pep_cfg_noname_path, defer_samples_creation=True) + + @pytest.mark.parametrize( + "example_pep_cfg_noname_path", ["project_config_noname.yaml"], indirect=True + ) + def test_missing_sample_name_custom_index(self, example_pep_cfg_noname_path): + """ + Verify that if sample_name column is missing in the sample table an + error is not issued if a custom sample_table index is set + """ + p = Project(cfg=example_pep_cfg_noname_path, sample_table_index="id") + assert p.sample_name_colname == "id" + + @pytest.mark.parametrize("example_pep_cfg_path", ["custom_index"], indirect=True) + def test_sample_name_custom_index(self, example_pep_cfg_path): + """ + Verify that sample_name attribute becomes st_index from cfg + """ + p = Project(cfg=example_pep_cfg_path) + assert p.sample_name_colname == "NOT_SAMPLE_NAME" + assert p.samples[0].sample_name == "frog_1" + + @pytest.mark.parametrize( + "example_pep_cfg_path", + ["basic"], + indirect=True, + ) + def test_equality(self, example_pep_cfg_path): + p1 = Project(cfg=example_pep_cfg_path) + p2 = Project(cfg=example_pep_cfg_path) + + assert p1 == p2 + + @pytest.mark.parametrize( + "example_peps_cfg_paths", [["basic", "BiocProject"]], indirect=True + ) + def test_inequality(self, example_peps_cfg_paths): + cfg1, cfg2 = example_peps_cfg_paths + p1 = Project(cfg=cfg1) + p2 = Project(cfg=cfg2) + assert p1 != p2 + + @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) + def test_from_dict_instatiation(self, example_pep_cfg_path): + """ + Verify that we can accurately instiate a project from its dictionary + representation. + """ + p1 = Project(cfg=example_pep_cfg_path) + p2 = Project.from_dict(p1.to_dict(extended=True)) + assert p1 == p2 + + def test_to_dict_does_not_create_nans(self, example_pep_nextflow_csv_path): + wrong_values = ["NaN", np.nan, "nan"] + + p1 = Project( + cfg=example_pep_nextflow_csv_path, sample_table_index="sample" + ).to_dict() + for sample in p1.get("samples"): + for attribute, value in sample.items(): + assert value not in wrong_values + + @pytest.mark.parametrize("example_pep_cfg_path", ["missing_version"], indirect=True) + def test_missing_version(self, example_pep_cfg_path): + """ + Verify that peppy can load a config file with no pep version + """ + p = Project(cfg=example_pep_cfg_path) + assert isinstance(p.pep_version, str) + + @pytest.mark.parametrize("example_pep_csv_path", ["basic"], indirect=True) + def test_sample_table_version(self, example_pep_csv_path): + """ + Verify that peppy can load a config file with no pep version + """ + p = Project(cfg=example_pep_csv_path) + assert isinstance(p.pep_version, str) + + @pytest.mark.parametrize( + "example_pep_csv_path", ["nextflow_samplesheet"], indirect=True + ) + def test_auto_merge_duplicated_names_works_for_different_read_types( + self, example_pep_csv_path + ): + p = Project(example_pep_csv_path, sample_table_index="sample") + assert len(p.samples) == 4 + + @pytest.mark.parametrize( + "expected_attribute", + [ + "sample", + "instrument_platform", + "run_accession", + "fastq_1", + "fastq_2", + "fasta", + ], + ) + @pytest.mark.parametrize("example_pep_cfg_path", ["nextflow_config"], indirect=True) + def test_peppy_initializes_samples_with_correct_attributes( + self, example_pep_cfg_path, expected_attribute + ): + p = Project(example_pep_cfg_path, sample_table_index="sample") + assert all([expected_attribute in sample for sample in p.samples]) + + # @pytest.mark.skip( + # "skipping this test, because this functionality is unavailable now" + # ) + @pytest.mark.parametrize("example_pep_cfg_path", ["basic", "imply"], indirect=True) + def test_correct_pickle(self, example_pep_cfg_path): + proj = Project(example_pep_cfg_path) + pickled_data = pickle.dumps(proj) + unpickled_project = pickle.loads(pickled_data) + + assert proj == unpickled_project + + +class TestProjectManipulationTests: + @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) + def test_amendments_activation_interactive(self, example_pep_cfg_path): + """ + Verify that the amendment can be activated interactively + """ + p = Project(cfg=example_pep_cfg_path) + p.activate_amendments("newLib") + assert all([s["protocol"] == "ABCD" for s in p.samples]) + assert p.amendments is not None + + @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) + def test_amendments_deactivation_interactive(self, example_pep_cfg_path): + """ + Verify that the amendment can be activated interactively + """ + p = Project(cfg=example_pep_cfg_path) + p.deactivate_amendments() + assert all([s["protocol"] != "ABCD" for s in p.samples]) + p.activate_amendments("newLib") + p.deactivate_amendments() + assert all([s["protocol"] != "ABCD" for s in p.samples]) + assert p.amendments is None + + @pytest.mark.parametrize("defer", [False, True]) + @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) + def test_missing_amendment_raises_correct_exception( + self, example_pep_cfg_path, defer + ): + with pytest.raises(MissingAmendmentError): + Project( + cfg=example_pep_cfg_path, + amendments="nieznany", + defer_samples_creation=defer, + ) + + @pytest.mark.parametrize("defer", [False, True]) + @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) + def test_amendments_argument_cant_be_null(self, example_pep_cfg_path, defer): + p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) + with pytest.raises(TypeError): + p.activate_amendments(amendments=None) + + @pytest.mark.parametrize("defer", [False, True]) + @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) + def test_str_repr_correctness(self, example_pep_cfg_path, defer): + """ + Verify string representation correctness + """ + p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) + str_repr = p.__str__() + assert example_pep_cfg_path in str_repr + assert "{} samples".format(str(len(p.samples))) in str_repr + assert p.name in str_repr + + @pytest.mark.parametrize("defer", [False, True]) + @pytest.mark.parametrize("example_pep_cfg_path", ["amendments1"], indirect=True) + def test_amendments_listing(self, example_pep_cfg_path, defer): + p = Project(cfg=example_pep_cfg_path, defer_samples_creation=defer) + assert isinstance(p.list_amendments, list) + + @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) + def test_sample_updates_regenerate_df(self, example_pep_cfg_path): + """ + Verify that Sample modifications cause sample_table regeneration + """ + p = Project(cfg=example_pep_cfg_path) + s_ori = p.sample_table + p.samples[0].update({"witam": "i_o_zdrowie_pytam"}) + assert not p.sample_table.equals(s_ori) + + @pytest.mark.parametrize("example_pep_cfg_path", ["subtable1"], indirect=True) + def test_subsample_table_property(self, example_pep_cfg_path): + """ + Verify that Sample modifications cause sample_table regeneration + """ + p = Project(cfg=example_pep_cfg_path) + assert isinstance(p.subsample_table, DataFrame) or isinstance( + p.subsample_table, list + ) + + @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) + def test_get_sample(self, example_pep_cfg_path): + """Verify that sample getting method works""" + p = Project(cfg=example_pep_cfg_path) + p.get_sample(sample_name=p.samples[0]["sample_name"]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) + def test_get_sample_nonexistent(self, example_pep_cfg_path): + """Verify that sample getting returns ValueError if not sample found""" + p = Project(cfg=example_pep_cfg_path) + with pytest.raises(ValueError): + p.get_sample(sample_name="kdkdkdk") + + +class TestPostInitSampleCreation: + @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) + def test_append(self, example_pep_cfg_path): + """ + Verify that the appending works the same way in a post init + sample creation scenario + """ + p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) + _cmp_all_samples_attr(p, pd, "read_type") + + @pytest.mark.parametrize("example_pep_cfg_path", ["imports"], indirect=True) + def test_imports(self, example_pep_cfg_path): + """ + Verify that the importing works the same way in a post init + sample creation scenario + """ + p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) + _cmp_all_samples_attr(p, pd, "imported_attr") + + @pytest.mark.parametrize("example_pep_cfg_path", ["imply"], indirect=True) + def test_imply(self, example_pep_cfg_path): + """ + Verify that the implication the same way in a post init + sample creation scenario + """ + p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) + _cmp_all_samples_attr(p, pd, "genome") + + @pytest.mark.parametrize("example_pep_cfg_path", ["duplicate"], indirect=True) + def test_duplicate(self, example_pep_cfg_path): + """ + Verify that the duplication the same way in a post init + sample creation scenario + """ + p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) + _cmp_all_samples_attr(p, pd, "organism") + + @pytest.mark.parametrize("example_pep_cfg_path", ["derive"], indirect=True) + def test_derive(self, example_pep_cfg_path): + """ + Verify that the derivation the same way in a post init + sample creation scenario + """ + p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) + _cmp_all_samples_attr(p, pd, "file_path") + + @pytest.mark.parametrize("example_pep_cfg_path", ["issue499"], indirect=True) + def test_issue499(self, example_pep_cfg_path): + """ + Verify that the derivation the same way in a post init + sample creation scenario + """ + p, pd = _get_pair_to_post_init_test(example_pep_cfg_path) + _cmp_all_samples_attr(p, pd, "file_path") + + @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) + def test_equality(self, example_pep_cfg_path): + """ + Test equality function of two projects + """ + p1 = Project(cfg=example_pep_cfg_path) + p2 = Project(cfg=example_pep_cfg_path) + assert p1 == p2 + + @pytest.mark.parametrize( + "example_pep_cfg_path, example_pep_csv_path", + [["append", "derive"]], + indirect=True, + ) + def test_unequality(self, example_pep_cfg_path, example_pep_csv_path): + """ + Test equality function of two projects + """ + p1 = Project(cfg=example_pep_cfg_path) + p2 = Project(cfg=example_pep_csv_path) + assert not p1 == p2 + + @pytest.mark.parametrize( + "example_pep_cfg_path", + ["append", "custom_index", "imply", "subtables", "multiple_subsamples"], + indirect=True, + ) + @pytest.mark.parametrize("orient", ["dict", "records"]) + def test_from_dict(self, example_pep_cfg_path, orient): + """ + Test initializing project from dict + """ + p1 = Project(cfg=example_pep_cfg_path) + p1_dict = p1.to_dict(extended=True, orient=orient) + del p1_dict["_config"]["sample_table"] + p2 = Project.from_dict(p1_dict) + assert p1 == p2 + + @pytest.mark.parametrize( + "config_with_pandas_obj, example_pep_csv_path", + [ + ["append", "append"], + ["derive", "derive"], + ["subtable1", "subtable1"], + ], + indirect=True, + ) + def test_from_pandas(self, config_with_pandas_obj, example_pep_csv_path): + """ + Test initializing project from dict + """ + p1 = Project.from_pandas(config_with_pandas_obj) + p2 = Project(example_pep_csv_path) + assert p1 == p2 + + @pytest.mark.parametrize( + "example_yaml_sample_file", + [ + "basic_sample_yaml", + ], + indirect=True, + ) + def test_from_yaml(self, example_yaml_sample_file): + """ + Test initializing project from dict + """ + p1 = Project.from_sample_yaml(example_yaml_sample_file) + assert p1.samples[0].sample_name == "sample1" + assert len(p1.samples) == 3 + + @pytest.mark.parametrize( + "config_with_pandas_obj, example_pep_csv_path", + [ + ["append", "append"], + ["derive", "derive"], + ["subtable1", "subtable1"], + ], + indirect=True, + ) + def test_from_pandas_unequal(self, config_with_pandas_obj, example_pep_csv_path): + """ + Test initializing project from pandas changing one of the samples + and checking inequality + """ + p1 = Project().from_pandas(config_with_pandas_obj) + + del p1.samples[0].sample_name + p2 = Project(example_pep_csv_path) + assert p1 != p2 + + @pytest.mark.parametrize( + "example_pep_cfg_path", + ["append"], + indirect=True, + ) + def test_description_setter(self, example_pep_cfg_path): + new_description = "new_description1" + p = Project(cfg=example_pep_cfg_path) + p.description = new_description + + assert p.description == new_description + assert p.to_dict(extended=True)["_config"]["description"] == new_description + + @pytest.mark.parametrize( + "example_pep_cfg_path", + ["append"], + indirect=True, + ) + def test_name_setter(self, example_pep_cfg_path): + new_name = "new_name1" + p = Project(cfg=example_pep_cfg_path) + p.name = new_name + + assert p.name == new_name + assert p.to_dict(extended=True)["_config"]["name"] == new_name + + +class TestSampleAttrMap: + @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) + def test_sample_getattr(self, example_pep_cfg_path): + """ + Verify that the getattr works + """ + p = Project(cfg=example_pep_cfg_path) + p1 = Project(cfg=example_pep_cfg_path) + + for s1, s2 in zip(p.samples, p1.samples): + assert s1.sample_name == s1["sample_name"] + assert s2.organism == s2["organism"] + + @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) + def test_sample_settatr(self, example_pep_cfg_path): + """ + Verify that the setattr works + """ + p = Project(cfg=example_pep_cfg_path) + new_name = "bingo" + p.samples[0].sample_name = new_name + + df = p.samples[0].to_dict() + assert df["sample_name"] == new_name + + @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) + def test_sample_len(self, example_pep_cfg_path): + """ + Verify that the len works + """ + p = Project(cfg=example_pep_cfg_path) + assert len(p.samples[0]) == 4 + + @pytest.mark.parametrize("example_pep_cfg_path", ["subsamples_none"], indirect=True) + def test_config_with_subsample_null(self, example_pep_cfg_path): + """ + Tests if config can have value with subsample=null + """ + p = Project(cfg=example_pep_cfg_path) + assert p.subsample_table is None + + @pytest.mark.parametrize( + "example_pep_cfg_path", ["nextflow_subsamples"], indirect=True + ) + def test_nextflow_subsamples(self, example_pep_cfg_path): + """ + Tests if config can have value with subsample=null + """ + p = Project(cfg=example_pep_cfg_path) + assert isinstance(p, Project) + + +class TestSampleModifiers: + @pytest.mark.parametrize("example_pep_cfg_path", ["append"], indirect=True) + def test_append(self, example_pep_cfg_path): + """Verify that the appended attribute is added to the samples""" + p = Project(cfg=example_pep_cfg_path) + assert all([s["read_type"] == "SINGLE" for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["imports"], indirect=True) + def test_imports(self, example_pep_cfg_path): + """Verify that the imported attribute is added to the samples""" + p = Project(cfg=example_pep_cfg_path) + assert all([s["imported_attr"] == "imported_val" for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["imply"], indirect=True) + def test_imply(self, example_pep_cfg_path): + """ + Verify that the implied attribute is added to the correct samples + """ + p = Project(cfg=example_pep_cfg_path) + assert all( + [s["genome"] == "hg38" for s in p.samples if s["organism"] == "human"] + ) + assert all( + [s["genome"] == "mm10" for s in p.samples if s["organism"] == "mouse"] + ) + + @pytest.mark.parametrize("example_pep_cfg_path", ["duplicate"], indirect=True) + def test_duplicate(self, example_pep_cfg_path): + """ + Verify that the duplicated attribute is identical to the original + """ + p = Project(cfg=example_pep_cfg_path) + assert all([s["organism"] == s["animal"] for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["derive"], indirect=True) + def test_derive(self, example_pep_cfg_path): + """ + Verify that the declared attr derivation happened + """ + p = Project(cfg=example_pep_cfg_path) + assert all(["file_path" in s for s in p.samples]) + assert all(["file_path" in s._derived_cols_done for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["remove"], indirect=True) + def test_remove(self, example_pep_cfg_path): + """ + Verify that the declared attr was eliminated from every sample + """ + p = Project(cfg=example_pep_cfg_path) + assert all(["protocol" not in s for s in p.samples]) + + @pytest.mark.parametrize("example_pep_cfg_path", ["subtable2"], indirect=True) + def test_subtable(self, example_pep_cfg_path): + """ + Verify that the sample merging takes place + """ + p = Project(cfg=example_pep_cfg_path) + assert all( + [ + isinstance(s["file"], list) + for s in p.samples + if s["sample_name"] in ["frog_1", "frog2"] + ] + ) \ No newline at end of file diff --git a/tests/peppytests/test_Sample.py b/tests/peppytests/test_Sample.py new file mode 100644 index 00000000..80467324 --- /dev/null +++ b/tests/peppytests/test_Sample.py @@ -0,0 +1,109 @@ +import os +import tempfile + +import pytest +from peppy import Project + +__author__ = "Michal Stolarczyk" +__email__ = "michal.stolarczyk@nih.gov" + +EXAMPLE_TYPES = [ + "basic", + "derive", + "imply", + "append", + "amendments1", + "amendments2", + "derive_imply", + "duplicate", + "imports", + "subtable1", + "subtable2", + "subtable3", + "subtable4", + "subtable5", + "remove", +] + + +class TestSample: + @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) + def test_serialization(self, example_pep_cfg_path): + """ + Verify that Project object is successfully created for every example PEP + """ + td = tempfile.mkdtemp() + fn = os.path.join(td, "serialized_sample.yaml") + p = Project(cfg=example_pep_cfg_path) + sample = p.samples[0] + sample["set"] = set("set") + sample["dict"] = dict({"dict": "dict"}) + sample["list"] = list(["list"]) + sample.to_yaml(fn) + with open(fn, "r") as f: + contents = f.read() + assert "set" in contents + assert "dict" in contents + assert "list" in contents + + @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) + def test_str_repr_correctness(self, example_pep_cfg_path): + """ + Verify that the missing amendment request raises correct exception + """ + p = Project(cfg=example_pep_cfg_path) + for sample in p.samples: + str_repr = sample.__str__(max_attr=100) + assert example_pep_cfg_path in str_repr + assert "Sample '{}'".format(sample["sample_name"]) in str_repr + + @pytest.mark.parametrize("example_pep_cfg_path", EXAMPLE_TYPES, indirect=True) + def test_sample_to_yaml_no_path(self, example_pep_cfg_path): + """ + Verify that to_yaml returns representation without requiring a path. + """ + p = Project(cfg=example_pep_cfg_path) + for sample in p.samples: + yaml_repr = sample.to_yaml() + assert "sample_name" in yaml_repr + + @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) + def test_sheet_dict_excludes_private_attrs(self, example_pep_cfg_path): + """ + Verify that sheet dict includes only original Sample attributes + """ + p = Project(cfg=example_pep_cfg_path) + for sample in p.samples: + assert len(sample.get_sheet_dict()) == len(p.sample_table.columns) + + @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) + def test_pickle_in_samples(self, example_pep_cfg_path): + import pickle + + p = Project(cfg=example_pep_cfg_path) + for sample in p.samples: + pickled_data = pickle.dumps(sample) + unpickled_sample = pickle.loads(pickled_data) + + assert sample.to_dict() == unpickled_sample.to_dict() + + @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) + def test_equals_samples(self, example_pep_cfg_path): + p1 = Project(cfg=example_pep_cfg_path) + p2 = Project(cfg=example_pep_cfg_path) + s1 = p1.samples[0] + s2 = p2.samples[0] + + assert s1 == s2 + + @pytest.mark.parametrize("example_pep_cfg_path", ["basic"], indirect=True) + def test_not_equals_samples(self, example_pep_cfg_path): + p1 = Project(cfg=example_pep_cfg_path) + p2 = Project(cfg=example_pep_cfg_path) + s1 = p1.samples[0] + s2 = p2.samples[0] + s3 = p2.samples[1] + + s2.new = "something" + assert not s1 == s2 + assert not s1 == s3 \ No newline at end of file From e02206bfca2e01e51018410af697bc8b2a426599 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 00:38:26 -0500 Subject: [PATCH 139/165] linting --- peppy/pephubclient/__init__.py | 6 ++-- peppy/pephubclient/cli.py | 1 + peppy/pephubclient/constants.py | 2 +- peppy/pephubclient/files_manager.py | 2 +- peppy/pephubclient/helpers.py | 33 +++++++++---------- peppy/pephubclient/models.py | 7 ++-- peppy/pephubclient/modules/sample.py | 2 +- peppy/pephubclient/modules/view.py | 12 +++---- .../pephubclient/pephub_oauth/pephub_oauth.py | 5 +-- peppy/pephubclient/pephubclient.py | 23 ++++++------- tests/peppytests/conftest.py | 2 +- tests/peppytests/test_Project.py | 2 +- tests/peppytests/test_Sample.py | 2 +- tests/phctests/conftest.py | 3 +- tests/phctests/test_manual.py | 2 +- tests/phctests/test_pephubclient.py | 8 ++--- 16 files changed, 53 insertions(+), 59 deletions(-) diff --git a/peppy/pephubclient/__init__.py b/peppy/pephubclient/__init__.py index 8e0037f7..dc683240 100644 --- a/peppy/pephubclient/__init__.py +++ b/peppy/pephubclient/__init__.py @@ -1,8 +1,10 @@ -from pephubclient.pephubclient import PEPHubClient -from pephubclient.helpers import is_registry_path, save_pep import logging + import coloredlogs +from pephubclient.helpers import is_registry_path, save_pep +from pephubclient.pephubclient import PEPHubClient + __app_name__ = "pephubclient" __version__ = "0.4.5" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" diff --git a/peppy/pephubclient/cli.py b/peppy/pephubclient/cli.py index 4c5c1525..42e39a1d 100644 --- a/peppy/pephubclient/cli.py +++ b/peppy/pephubclient/cli.py @@ -1,6 +1,7 @@ import typer from pephubclient import __app_name__, __version__ + from .helpers import call_client_func from .pephubclient import PEPHubClient diff --git a/peppy/pephubclient/constants.py b/peppy/pephubclient/constants.py index 26e8ed70..075fc83f 100644 --- a/peppy/pephubclient/constants.py +++ b/peppy/pephubclient/constants.py @@ -1,6 +1,6 @@ +import os from enum import Enum from typing import Optional -import os from pydantic import BaseModel, field_validator diff --git a/peppy/pephubclient/files_manager.py b/peppy/pephubclient/files_manager.py index 3ead97ee..9cb65d28 100644 --- a/peppy/pephubclient/files_manager.py +++ b/peppy/pephubclient/files_manager.py @@ -1,10 +1,10 @@ import os +import zipfile from contextlib import suppress from pathlib import Path import pandas import yaml -import zipfile from .exceptions import PEPExistsError diff --git a/peppy/pephubclient/helpers.py b/peppy/pephubclient/helpers.py index 9cd8a1c5..ce61e021 100644 --- a/peppy/pephubclient/helpers.py +++ b/peppy/pephubclient/helpers.py @@ -1,28 +1,27 @@ import json -from typing import Any, Callable, Optional, Union -from ..project import Project -import yaml import os -import pandas as pd -from ..const import ( - NAME_KEY, - DESC_KEY, - CONFIG_KEY, - SUBSAMPLE_RAW_LIST_KEY, - SAMPLE_RAW_DICT_KEY, - CFG_SAMPLE_TABLE_KEY, - CFG_SUBSAMPLE_TABLE_KEY, -) +from typing import Any, Callable, Optional, Union +from urllib.parse import urlencode +import pandas as pd import requests +import yaml +from pydantic import ValidationError from requests.exceptions import ConnectionError -from urllib.parse import urlencode - from ubiquerg import parse_registry_path -from pydantic import ValidationError -from .exceptions import PEPExistsError, ResponseError +from ..const import ( + CFG_SAMPLE_TABLE_KEY, + CFG_SUBSAMPLE_TABLE_KEY, + CONFIG_KEY, + DESC_KEY, + NAME_KEY, + SAMPLE_RAW_DICT_KEY, + SUBSAMPLE_RAW_LIST_KEY, +) +from ..project import Project from .constants import RegistryPath +from .exceptions import PEPExistsError, ResponseError from .files_manager import FilesManager from .models import ProjectDict diff --git a/peppy/pephubclient/models.py b/peppy/pephubclient/models.py index 355d8517..e69ef92e 100644 --- a/peppy/pephubclient/models.py +++ b/peppy/pephubclient/models.py @@ -1,8 +1,9 @@ import datetime -from typing import Optional, List, Union +from typing import List, Optional, Union -from pydantic import BaseModel, Field, field_validator, ConfigDict -from ..const import CONFIG_KEY, SUBSAMPLE_RAW_LIST_KEY, SAMPLE_RAW_DICT_KEY +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from ..const import CONFIG_KEY, SAMPLE_RAW_DICT_KEY, SUBSAMPLE_RAW_LIST_KEY class ProjectDict(BaseModel): diff --git a/peppy/pephubclient/modules/sample.py b/peppy/pephubclient/modules/sample.py index a1ec7e8e..3199063a 100644 --- a/peppy/pephubclient/modules/sample.py +++ b/peppy/pephubclient/modules/sample.py @@ -1,8 +1,8 @@ import logging -from ..helpers import RequestManager from ..constants import PEPHUB_SAMPLE_URL, ResponseStatusCodes from ..exceptions import ResponseError +from ..helpers import RequestManager _LOGGER = logging.getLogger("pephubclient") diff --git a/peppy/pephubclient/modules/view.py b/peppy/pephubclient/modules/view.py index 893c8e9c..d45de3b9 100644 --- a/peppy/pephubclient/modules/view.py +++ b/peppy/pephubclient/modules/view.py @@ -1,15 +1,11 @@ +import logging from typing import Union + # import peppy from ...project import Project -import logging - -from ..helpers import RequestManager -from ..constants import ( - PEPHUB_VIEW_URL, - PEPHUB_VIEW_SAMPLE_URL, - ResponseStatusCodes, -) +from ..constants import PEPHUB_VIEW_SAMPLE_URL, PEPHUB_VIEW_URL, ResponseStatusCodes from ..exceptions import ResponseError +from ..helpers import RequestManager from ..models import ProjectDict _LOGGER = logging.getLogger("pephubclient") diff --git a/peppy/pephubclient/pephub_oauth/pephub_oauth.py b/peppy/pephubclient/pephub_oauth/pephub_oauth.py index 46243918..faeeb260 100644 --- a/peppy/pephubclient/pephub_oauth/pephub_oauth.py +++ b/peppy/pephubclient/pephub_oauth/pephub_oauth.py @@ -6,10 +6,7 @@ from pydantic import BaseModel from ..helpers import MessageHandler, RequestManager -from ..pephub_oauth.const import ( - PEPHUB_DEVICE_INIT_URI, - PEPHUB_DEVICE_TOKEN_URI, -) +from ..pephub_oauth.const import PEPHUB_DEVICE_INIT_URI, PEPHUB_DEVICE_TOKEN_URI from ..pephub_oauth.exceptions import ( PEPHubResponseException, PEPHubTokenExchangeException, diff --git a/peppy/pephubclient/pephubclient.py b/peppy/pephubclient/pephubclient.py index 53149fca..0d356a1a 100644 --- a/peppy/pephubclient/pephubclient.py +++ b/peppy/pephubclient/pephubclient.py @@ -1,35 +1,32 @@ -from typing import NoReturn, Optional, Literal -from typing_extensions import deprecated +from typing import Literal, NoReturn, Optional -from ..project import Project -from ..const import NAME_KEY import urllib3 from pydantic import ValidationError +from typing_extensions import deprecated from ubiquerg import parse_registry_path +from ..const import NAME_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, RegistryPath, ResponseStatusCodes, - PEPHUB_PEP_SEARCH_URL, - PATH_TO_FILE_WITH_JWT, -) -from .exceptions import ( - IncorrectQueryStringError, - ResponseError, ) +from .exceptions import IncorrectQueryStringError, ResponseError from .files_manager import FilesManager from .helpers import MessageHandler, RequestManager, save_pep from .models import ( + ProjectAnnotationModel, ProjectDict, ProjectUploadData, SearchReturnModel, - ProjectAnnotationModel, ) -from .pephub_oauth.pephub_oauth import PEPHubAuth -from .modules.view import PEPHubView from .modules.sample import PEPHubSample +from .modules.view import PEPHubView +from .pephub_oauth.pephub_oauth import PEPHubAuth urllib3.disable_warnings() diff --git a/tests/peppytests/conftest.py b/tests/peppytests/conftest.py index 1b87b0a7..2d9260f8 100644 --- a/tests/peppytests/conftest.py +++ b/tests/peppytests/conftest.py @@ -67,4 +67,4 @@ def example_peps_cfg_paths(request): def config_with_pandas_obj(request): return pd.read_csv( get_path_to_example_file(EPB, request.param, "sample_table.csv"), dtype=str - ) \ No newline at end of file + ) diff --git a/tests/peppytests/test_Project.py b/tests/peppytests/test_Project.py index 95b2e230..91ccd45d 100644 --- a/tests/peppytests/test_Project.py +++ b/tests/peppytests/test_Project.py @@ -816,4 +816,4 @@ def test_subtable(self, example_pep_cfg_path): for s in p.samples if s["sample_name"] in ["frog_1", "frog2"] ] - ) \ No newline at end of file + ) diff --git a/tests/peppytests/test_Sample.py b/tests/peppytests/test_Sample.py index 80467324..62b9bf0c 100644 --- a/tests/peppytests/test_Sample.py +++ b/tests/peppytests/test_Sample.py @@ -106,4 +106,4 @@ def test_not_equals_samples(self, example_pep_cfg_path): s2.new = "something" assert not s1 == s2 - assert not s1 == s3 \ No newline at end of file + assert not s1 == s3 diff --git a/tests/phctests/conftest.py b/tests/phctests/conftest.py index 3eac1a40..39ba8caf 100644 --- a/tests/phctests/conftest.py +++ b/tests/phctests/conftest.py @@ -1,6 +1,7 @@ -import pytest import os +import pytest + @pytest.fixture def SAMPLE_PEP(): diff --git a/tests/phctests/test_manual.py b/tests/phctests/test_manual.py index 71d73bcb..a86117db 100644 --- a/tests/phctests/test_manual.py +++ b/tests/phctests/test_manual.py @@ -1,5 +1,5 @@ -from peppy.pephubclient.pephubclient import PEPHubClient import pytest +from peppy.pephubclient.pephubclient import PEPHubClient @pytest.mark.skip(reason="Manual test") diff --git a/tests/phctests/test_pephubclient.py b/tests/phctests/test_pephubclient.py index 6d406ee2..bafb06be 100644 --- a/tests/phctests/test_pephubclient.py +++ b/tests/phctests/test_pephubclient.py @@ -2,12 +2,10 @@ from unittest.mock import Mock import pytest - from peppy.pephubclient.exceptions import ResponseError -from peppy.pephubclient.pephubclient import PEPHubClient from peppy.pephubclient.helpers import is_registry_path from peppy.pephubclient.pephub_oauth.models import InitializeDeviceCodeResponse - +from peppy.pephubclient.pephubclient import PEPHubClient @pytest.fixture() @@ -74,7 +72,9 @@ def test_pull(self, mocker, test_jwt, test_raw_pep_return): save_sample_mock = mocker.patch( "peppy.pephubclient.files_manager.FilesManager.save_pandas" ) - mocker.patch("peppy.pephubclient.files_manager.FilesManager.create_project_folder") + mocker.patch( + "peppy.pephubclient.files_manager.FilesManager.create_project_folder" + ) PEPHubClient().pull("some/project") From 944154a06c6a6c629109ac0600741526950d35a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 16:59:51 -0500 Subject: [PATCH 140/165] adjust cli to add pephubclient --- peppy/cli.py | 225 +++++++++++++++++++++++----------------- peppy/eido/argparser.py | 76 +++++--------- 2 files changed, 156 insertions(+), 145 deletions(-) diff --git a/peppy/cli.py b/peppy/cli.py index e55dc3bc..b858ed84 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -2,10 +2,14 @@ import sys from typing import Dict, List +import logmuse from logmuse import init_logger +from ubiquerg import VersionInHelpParser +from ._version import __version__ from .const import PKG_NAME -from .eido.argparser import LEVEL_BY_VERBOSITY, build_argparser +from .eido.argparser import LEVEL_BY_VERBOSITY +from .eido.argparser import build_subparser as eido_subparser from .eido.const import CONVERT_CMD, INSPECT_CMD, LOGGING_LEVEL, VALIDATE_CMD from .eido.conversion import ( convert_project, @@ -57,11 +61,43 @@ def print_error_summary( _LOGGER.error(final_msg) +def build_argparser(): + """ + Builds argument parser. + + :return argparse.ArgumentParser: Argument parser + """ + + banner = "%(prog)s - Portable Encapsulated Projects toolkit" + # additional_description = "\nhttps://geniml.databio.org" + + parser = VersionInHelpParser( + prog="peppy", + version=f"{__version__}", + description=banner, + ) + + # Individual subcommands + msg_by_cmd = { + "eido": "PEP validation, conversion, and inspection", + "pephubclient": "Client for the PEPhub server", + } + + sp = parser.add_subparsers(dest="command") + subparsers: Dict[str, VersionInHelpParser] = {} + for k, v in msg_by_cmd.items(): + subparsers[k] = sp.add_parser(k, description=v, help=v) + + # build up subparsers for modules + subparsers["eido"] = eido_subparser(subparsers["eido"]) + + return parser + + def main(): """Primary workflow""" - parser, sps = build_argparser() - args, remaining_args = parser.parse_known_args() - + parser = logmuse.add_logging_options(build_argparser()) + args, _ = parser.parse_known_args() if args.command is None: parser.print_help(sys.stderr) sys.exit(1) @@ -81,98 +117,99 @@ def main(): # init_logger(name="peppy", **logger_kwargs) global _LOGGER _LOGGER = init_logger(name=PKG_NAME, **logger_kwargs) + if args.command == "eido": + + if args.subcommand == CONVERT_CMD: + filters = get_available_pep_filters() + if args.list: + _LOGGER.info("Available filters:") + if len(filters) < 1: + _LOGGER.info("No available filters") + for filter_name in filters: + _LOGGER.info(f" - {filter_name}") + sys.exit(0) + if not "format" in args: + _LOGGER.error("The following arguments are required: --format") + parser.print_help(sys.stderr) + sys.exit(1) + if args.describe: + if args.format not in filters: + raise EidoFilterError( + f"'{args.format}' filter not found. Available filters: {', '.join(filters)}" + ) + filter_functions_by_name = pep_conversion_plugins() + print(filter_functions_by_name[args.format].__doc__) + sys.exit(0) + if args.pep is None: + parser.print_help(sys.stderr) + _LOGGER.info("The following arguments are required: PEP") + sys.exit(1) + if args.paths: + paths = {y[0]: y[1] for y in [x.split("=") for x in args.paths]} + else: + paths = None + + p = Project( + args.pep, + sample_table_index=args.st_index, + subsample_table_index=args.sst_index, + amendments=args.amendments, + ) + plugin_kwargs = _parse_filter_args_str(args.args) + + # append paths + plugin_kwargs["paths"] = paths - if args.command == CONVERT_CMD: - filters = get_available_pep_filters() - if args.list: - _LOGGER.info("Available filters:") - if len(filters) < 1: - _LOGGER.info("No available filters") - for filter_name in filters: - _LOGGER.info(f" - {filter_name}") + convert_project(p, args.format, plugin_kwargs) + _LOGGER.info("Conversion successful") sys.exit(0) - if not "format" in args: - _LOGGER.error("The following arguments are required: --format") - sps[CONVERT_CMD].print_help(sys.stderr) - sys.exit(1) - if args.describe: - if args.format not in filters: - raise EidoFilterError( - f"'{args.format}' filter not found. Available filters: {', '.join(filters)}" + + _LOGGER.debug(f"Creating a Project object from: {args.pep}") + if args.subcommand == VALIDATE_CMD: + p = Project( + args.pep, + sample_table_index=args.st_index, + subsample_table_index=args.sst_index, + amendments=args.amendments, + ) + if args.sample_name: + try: + args.sample_name = int(args.sample_name) + except ValueError: + # If sample_name is not an integer, leave it as a string. + pass + _LOGGER.debug( + f"Comparing Sample ('{args.pep}') in Project ('{args.pep}') " + f"against a schema: {args.schema}" ) - filter_functions_by_name = pep_conversion_plugins() - print(filter_functions_by_name[args.format].__doc__) - sys.exit(0) - if args.pep is None: - sps[CONVERT_CMD].print_help(sys.stderr) - _LOGGER.info("The following arguments are required: PEP") - sys.exit(1) - if args.paths: - paths = {y[0]: y[1] for y in [x.split("=") for x in args.paths]} - else: - paths = None - - p = Project( - args.pep, - sample_table_index=args.st_index, - subsample_table_index=args.sst_index, - amendments=args.amendments, - ) - plugin_kwargs = _parse_filter_args_str(args.args) - - # append paths - plugin_kwargs["paths"] = paths - - convert_project(p, args.format, plugin_kwargs) - _LOGGER.info("Conversion successful") - sys.exit(0) - - _LOGGER.debug(f"Creating a Project object from: {args.pep}") - if args.command == VALIDATE_CMD: - p = Project( - args.pep, - sample_table_index=args.st_index, - subsample_table_index=args.sst_index, - amendments=args.amendments, - ) - if args.sample_name: + validator = validate_sample + arguments = [p, args.sample_name, args.schema] + elif args.just_config: + _LOGGER.debug( + f"Comparing Project ('{args.pep}') against a schema: {args.schema}" + ) + validator = validate_config + arguments = [p, args.schema] + else: + _LOGGER.debug( + f"Comparing Project ('{args.pep}') against a schema: {args.schema}" + ) + validator = validate_project + arguments = [p, args.schema] try: - args.sample_name = int(args.sample_name) - except ValueError: - # If sample_name is not an integer, leave it as a string. - pass - _LOGGER.debug( - f"Comparing Sample ('{args.pep}') in Project ('{args.pep}') " - f"against a schema: {args.schema}" - ) - validator = validate_sample - arguments = [p, args.sample_name, args.schema] - elif args.just_config: - _LOGGER.debug( - f"Comparing Project ('{args.pep}') against a schema: {args.schema}" - ) - validator = validate_config - arguments = [p, args.schema] - else: - _LOGGER.debug( - f"Comparing Project ('{args.pep}') against a schema: {args.schema}" + validator(*arguments) + except EidoValidationError as e: + print_error_summary(e.errors_by_type, _LOGGER) + sys.exit(1) + _LOGGER.info("Validation successful") + sys.exit(0) + + if args.subommand == INSPECT_CMD: + p = Project( + args.pep, + sample_table_index=args.st_index, + subsample_table_index=args.sst_index, + amendments=args.amendments, ) - validator = validate_project - arguments = [p, args.schema] - try: - validator(*arguments) - except EidoValidationError as e: - print_error_summary(e.errors_by_type, _LOGGER) - sys.exit(1) - _LOGGER.info("Validation successful") - sys.exit(0) - - if args.command == INSPECT_CMD: - p = Project( - args.pep, - sample_table_index=args.st_index, - subsample_table_index=args.sst_index, - amendments=args.amendments, - ) - inspect_project(p, args.sample_name, args.attr_limit) - sys.exit(0) + inspect_project(p, args.sample_name, args.attr_limit) + sys.exit(0) diff --git a/peppy/eido/argparser.py b/peppy/eido/argparser.py index 917bd9d6..e3341d1b 100644 --- a/peppy/eido/argparser.py +++ b/peppy/eido/argparser.py @@ -1,75 +1,50 @@ -from argparse import ArgumentParser from logging import CRITICAL, DEBUG, ERROR, INFO, WARN -from typing import Dict, Tuple -from ubiquerg import VersionInHelpParser - -from .._version import __version__ from ..const import PKG_NAME, SAMPLE_NAME_ATTR from .const import CONVERT_CMD, INSPECT_CMD, SUBPARSER_MSGS, VALIDATE_CMD LEVEL_BY_VERBOSITY = [ERROR, CRITICAL, WARN, INFO, DEBUG] -def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: - banner = "%(prog)s - Interact with PEPs" - additional_description = "\nhttp://eido.databio.org/" - parser = VersionInHelpParser( - prog=PKG_NAME, - description=banner, - epilog=additional_description, - version=__version__, - ) - subparsers = parser.add_subparsers(dest="command") - parser.add_argument( - "--verbosity", - dest="verbosity", - type=int, - choices=range(len(LEVEL_BY_VERBOSITY)), - help="Choose level of verbosity (default: %(default)s)", - ) - parser.add_argument("--logging-level", dest="logging_level", help="logging level") - parser.add_argument( - "--dbg", - dest="dbg", - action="store_true", - help="Turn on debug mode (default: %(default)s)", - ) - sps = {} - for cmd, desc in SUBPARSER_MSGS.items(): - subparser = subparsers.add_parser(cmd, description=desc, help=desc) - subparser.add_argument( +def build_subparser(parser): + sp = parser.add_subparsers(dest="subcommand") + subparsers = {} + + for k, v in SUBPARSER_MSGS.items(): + subparsers[k] = sp.add_parser(k, description=v, help=v) + subparsers[k].add_argument( "--st-index", required=False, type=str, # default=SAMPLE_NAME_ATTR, help=f"Sample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.", ) - subparser.add_argument( + subparsers[k].add_argument( "--sst-index", required=False, type=str, # default=SAMPLE_NAME_ATTR, help=f"Subsample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.", ) - subparser.add_argument( + subparsers[k].add_argument( "--amendments", required=False, type=str, nargs="+", help=f"Names of the amendments to activate.", ) - if cmd != CONVERT_CMD: - subparser.add_argument( + + if k != CONVERT_CMD: + subparsers[k].add_argument( "pep", metavar="PEP", help="Path to a PEP configuration file in yaml format.", default=None, ) else: - subparser.add_argument( + subparsers[k].add_argument( "pep", metavar="PEP", nargs="?", @@ -77,9 +52,7 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: default=None, ) - sps[cmd] = subparser - - sps[VALIDATE_CMD].add_argument( + subparsers[VALIDATE_CMD].add_argument( "-s", "--schema", required=True, @@ -87,7 +60,7 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: metavar="S", ) - sps[INSPECT_CMD].add_argument( + subparsers[INSPECT_CMD].add_argument( "-n", "--sample-name", required=False, @@ -96,7 +69,7 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: metavar="SN", ) - sps[INSPECT_CMD].add_argument( + subparsers[INSPECT_CMD].add_argument( "-l", "--attr-limit", required=False, @@ -105,7 +78,7 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: help="Number of sample attributes to display.", ) - group = sps[VALIDATE_CMD].add_mutually_exclusive_group() + group = subparsers[VALIDATE_CMD].add_mutually_exclusive_group() group.add_argument( "-n", @@ -125,7 +98,7 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: help="Whether samples should be excluded from the validation.", ) - sps[CONVERT_CMD].add_argument( + subparsers[CONVERT_CMD].add_argument( "-f", "--format", required=False, @@ -133,7 +106,7 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: help="Output format (name of filter; use -l to see available).", ) - sps[CONVERT_CMD].add_argument( + subparsers[CONVERT_CMD].add_argument( "-n", "--sample-name", required=False, @@ -141,7 +114,7 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: help="Name of the samples to inspect.", ) - sps[CONVERT_CMD].add_argument( + subparsers[CONVERT_CMD].add_argument( "-a", "--args", nargs="+", @@ -151,7 +124,7 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: help="Provide arguments to the filter function (e.g. arg1=val1 arg2=val2).", ) - sps[CONVERT_CMD].add_argument( + subparsers[CONVERT_CMD].add_argument( "-l", "--list", required=False, @@ -160,7 +133,7 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: help="List available filters.", ) - sps[CONVERT_CMD].add_argument( + subparsers[CONVERT_CMD].add_argument( "-d", "--describe", required=False, @@ -169,10 +142,11 @@ def build_argparser() -> Tuple[ArgumentParser, Dict[str, ArgumentParser]]: help="Show description for a given filter.", ) - sps[CONVERT_CMD].add_argument( + subparsers[CONVERT_CMD].add_argument( "-p", "--paths", nargs="+", help="Paths to dump conversion result as key=value pairs.", ) - return parser, sps + + return parser \ No newline at end of file From 2d2faf5abee6497d1b1b5e4d17da4fd58fb951a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 17:42:41 -0500 Subject: [PATCH 141/165] save draft --- peppy/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/peppy/cli.py b/peppy/cli.py index b858ed84..01cd5e06 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -72,7 +72,7 @@ def build_argparser(): # additional_description = "\nhttps://geniml.databio.org" parser = VersionInHelpParser( - prog="peppy", + prog=PKG_NAME, version=f"{__version__}", description=banner, ) @@ -80,7 +80,7 @@ def build_argparser(): # Individual subcommands msg_by_cmd = { "eido": "PEP validation, conversion, and inspection", - "pephubclient": "Client for the PEPhub server", + # "pephubclient": "Client for the PEPhub server", } sp = parser.add_subparsers(dest="command") From 325a243b2ad43614b27c7beb543ea35e6e0bc38b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 18:33:45 -0500 Subject: [PATCH 142/165] remain subparser print_help in eido --- peppy/cli.py | 57 +++++++++++++++++++++++++++-------------- peppy/eido/argparser.py | 4 +-- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/peppy/cli.py b/peppy/cli.py index 01cd5e06..4d91b50e 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -1,3 +1,4 @@ +import argparse import logging import sys from typing import Dict, List @@ -22,6 +23,29 @@ from .project import Project +def _get_subparser(parser, *names): + """ + Access nested subparsers by name. + Example: _get_subparser(main_parser, "cmdA", "subA1") + """ + current = parser + for name in names: + # find subparsers action + subactions = [ + a for a in current._actions if isinstance(a, argparse._SubParsersAction) + ] + if not subactions: + raise ValueError(f"{current} has no subparsers") + + action = subactions[0] + if name not in action.choices: + raise ValueError(f"Subparser '{name}' not found") + + current = action.choices[name] + + return current + + def _parse_filter_args_str(input): """ Parse user input specification. @@ -94,32 +118,25 @@ def build_argparser(): return parser -def main(): +def main(test_args=None): """Primary workflow""" parser = logmuse.add_logging_options(build_argparser()) args, _ = parser.parse_known_args() + + if test_args: + args.__dict__.update(test_args) + + global _LOGGER + _LOGGER = logmuse.logger_via_cli(args, make_root=True) + if args.command is None: parser.print_help(sys.stderr) sys.exit(1) - # Set the logging level. - if args.dbg: - # Debug mode takes precedence and will listen for all messages. - level = args.logging_level or logging.DEBUG - elif args.verbosity is not None: - # Verbosity-framed specification trumps logging_level. - level = LEVEL_BY_VERBOSITY[args.verbosity] - else: - # Normally, we're not in debug mode, and there's not verbosity. - level = LOGGING_LEVEL - - logger_kwargs = {"level": level, "devmode": args.dbg} - # init_logger(name="peppy", **logger_kwargs) - global _LOGGER - _LOGGER = init_logger(name=PKG_NAME, **logger_kwargs) if args.command == "eido": if args.subcommand == CONVERT_CMD: + convert_sp = _get_subparser(parser, "eido", CONVERT_CMD) filters = get_available_pep_filters() if args.list: _LOGGER.info("Available filters:") @@ -130,7 +147,7 @@ def main(): sys.exit(0) if not "format" in args: _LOGGER.error("The following arguments are required: --format") - parser.print_help(sys.stderr) + convert_sp.print_help(sys.stderr) sys.exit(1) if args.describe: if args.format not in filters: @@ -141,7 +158,9 @@ def main(): print(filter_functions_by_name[args.format].__doc__) sys.exit(0) if args.pep is None: - parser.print_help(sys.stderr) + # parser.print_help(sys.stderr) + # sp[CONVERT_CMD].print_help(sys.stderr) + convert_sp.print_help(sys.stderr) _LOGGER.info("The following arguments are required: PEP") sys.exit(1) if args.paths: @@ -204,7 +223,7 @@ def main(): _LOGGER.info("Validation successful") sys.exit(0) - if args.subommand == INSPECT_CMD: + if args.subcommand == INSPECT_CMD: p = Project( args.pep, sample_table_index=args.st_index, diff --git a/peppy/eido/argparser.py b/peppy/eido/argparser.py index e3341d1b..7cf79b1e 100644 --- a/peppy/eido/argparser.py +++ b/peppy/eido/argparser.py @@ -6,8 +6,6 @@ LEVEL_BY_VERBOSITY = [ERROR, CRITICAL, WARN, INFO, DEBUG] - - def build_subparser(parser): sp = parser.add_subparsers(dest="subcommand") subparsers = {} @@ -149,4 +147,4 @@ def build_subparser(parser): help="Paths to dump conversion result as key=value pairs.", ) - return parser \ No newline at end of file + return parser From deef82cd1f8a1ac3106723613beed1ff3e089d28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 19:15:06 -0500 Subject: [PATCH 143/165] merge pephubclient cli --- peppy/cli.py | 29 ++++++++++++++++++++++++----- peppy/pephubclient/__init__.py | 1 - peppy/pephubclient/__main__.py | 9 --------- peppy/pephubclient/cli.py | 1 - setup.py | 10 ++++++++-- 5 files changed, 32 insertions(+), 18 deletions(-) delete mode 100644 peppy/pephubclient/__main__.py diff --git a/peppy/cli.py b/peppy/cli.py index 4d91b50e..ccd45e1e 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -4,14 +4,12 @@ from typing import Dict, List import logmuse -from logmuse import init_logger from ubiquerg import VersionInHelpParser from ._version import __version__ from .const import PKG_NAME -from .eido.argparser import LEVEL_BY_VERBOSITY from .eido.argparser import build_subparser as eido_subparser -from .eido.const import CONVERT_CMD, INSPECT_CMD, LOGGING_LEVEL, VALIDATE_CMD +from .eido.const import CONVERT_CMD, INSPECT_CMD, VALIDATE_CMD from .eido.conversion import ( convert_project, get_available_pep_filters, @@ -104,6 +102,7 @@ def build_argparser(): # Individual subcommands msg_by_cmd = { "eido": "PEP validation, conversion, and inspection", + "phc": "Client for the PEPhub server", # "pephubclient": "Client for the PEPhub server", } @@ -120,8 +119,19 @@ def build_argparser(): def main(test_args=None): """Primary workflow""" + if len(sys.argv) > 1 and sys.argv[1] == "phc": + # Import your Typer app directly + from .pephubclient.cli import app + + # Everything after "phc" goes to Typer + sub_args = sys.argv[2:] + # Show "peppy phc" in usage/help + app( + args=sub_args, + prog_name=f"{PKG_NAME} phc", + ) parser = logmuse.add_logging_options(build_argparser()) - args, _ = parser.parse_known_args() + args, remaining = parser.parse_known_args() if test_args: args.__dict__.update(test_args) @@ -134,7 +144,6 @@ def main(test_args=None): sys.exit(1) if args.command == "eido": - if args.subcommand == CONVERT_CMD: convert_sp = _get_subparser(parser, "eido", CONVERT_CMD) filters = get_available_pep_filters() @@ -232,3 +241,13 @@ def main(test_args=None): ) inspect_project(p, args.sample_name, args.attr_limit) sys.exit(0) + # if args.command == "phc": + # + # # direct import from the old Typer CLI + # from .pephubclient.cli import app + # + # # Typer/Click allows overriding program name + args: + # app( + # args=remaining, # all args after "peppy phc" + # prog_name=f"{PKG_NAME} phc", # better help/usage text + # ) diff --git a/peppy/pephubclient/__init__.py b/peppy/pephubclient/__init__.py index dc683240..2f735077 100644 --- a/peppy/pephubclient/__init__.py +++ b/peppy/pephubclient/__init__.py @@ -1,7 +1,6 @@ import logging import coloredlogs - from pephubclient.helpers import is_registry_path, save_pep from pephubclient.pephubclient import PEPHubClient diff --git a/peppy/pephubclient/__main__.py b/peppy/pephubclient/__main__.py deleted file mode 100644 index 60ec991e..00000000 --- a/peppy/pephubclient/__main__.py +++ /dev/null @@ -1,9 +0,0 @@ -from pephubclient.cli import __app_name__, app - - -def main(): - app(prog_name=__app_name__) - - -if __name__ == "__main__": - main() diff --git a/peppy/pephubclient/cli.py b/peppy/pephubclient/cli.py index 42e39a1d..71b9a885 100644 --- a/peppy/pephubclient/cli.py +++ b/peppy/pephubclient/cli.py @@ -1,5 +1,4 @@ import typer - from pephubclient import __app_name__, __version__ from .helpers import call_client_func diff --git a/setup.py b/setup.py index f587ca24..79cb7b52 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,13 @@ def get_static(name, condition=None): setup( name=PACKAGE_NAME, - packages=[PACKAGE_NAME, "peppy.eido", "peppy.pephubclient"], + packages=[ + PACKAGE_NAME, + "peppy.eido", + "peppy.pephubclient", + "peppy.pephubclient.pephub_oauth", + "peppy.pephubclient.modules", + ], version=version, description="A python-based project metadata manager for portable encapsulated projects", long_description=long_description, @@ -74,4 +80,4 @@ def get_static(name, condition=None): ["pytest-runner"] if {"test", "pytest", "ptr"} & set(sys.argv) else [] ), **extra, -) \ No newline at end of file +) From 5da4eaa64f2eca2d5174ae426212fd20cd60b1d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 12:53:57 -0500 Subject: [PATCH 144/165] comments --- peppy/cli.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/peppy/cli.py b/peppy/cli.py index ccd45e1e..1b0707d3 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -120,12 +120,9 @@ def build_argparser(): def main(test_args=None): """Primary workflow""" if len(sys.argv) > 1 and sys.argv[1] == "phc": - # Import your Typer app directly from .pephubclient.cli import app - # Everything after "phc" goes to Typer sub_args = sys.argv[2:] - # Show "peppy phc" in usage/help app( args=sub_args, prog_name=f"{PKG_NAME} phc", From 35d1a3dbf3d5c31d180e460bcbc24d0842873d13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 16:22:07 -0500 Subject: [PATCH 145/165] save draft --- peppy/cli.py | 330 +++++++++++++++++++++++----------------------- peppy/eido/cli.py | 6 + 2 files changed, 172 insertions(+), 164 deletions(-) create mode 100644 peppy/eido/cli.py diff --git a/peppy/cli.py b/peppy/cli.py index 1b0707d3..63bdb19c 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -4,6 +4,7 @@ from typing import Dict, List import logmuse +import typer from ubiquerg import VersionInHelpParser from ._version import __version__ @@ -18,6 +19,7 @@ from .eido.exceptions import EidoFilterError, EidoValidationError from .eido.inspection import inspect_project from .eido.validation import validate_config, validate_project, validate_sample +from .pephubclient.cli import app as phc_app from .project import Project @@ -83,168 +85,168 @@ def print_error_summary( _LOGGER.error(final_msg) -def build_argparser(): - """ - Builds argument parser. - - :return argparse.ArgumentParser: Argument parser - """ - - banner = "%(prog)s - Portable Encapsulated Projects toolkit" - # additional_description = "\nhttps://geniml.databio.org" - - parser = VersionInHelpParser( - prog=PKG_NAME, - version=f"{__version__}", - description=banner, - ) - - # Individual subcommands - msg_by_cmd = { - "eido": "PEP validation, conversion, and inspection", - "phc": "Client for the PEPhub server", - # "pephubclient": "Client for the PEPhub server", - } - - sp = parser.add_subparsers(dest="command") - subparsers: Dict[str, VersionInHelpParser] = {} - for k, v in msg_by_cmd.items(): - subparsers[k] = sp.add_parser(k, description=v, help=v) - - # build up subparsers for modules - subparsers["eido"] = eido_subparser(subparsers["eido"]) - - return parser - - -def main(test_args=None): +app = typer.Typer(help=f"{PKG_NAME} - Portable Encapsulated Projects toolkit") + +app.add_typer(phc_app, name="phc", help="Client for the PEPhub server") + + +# def build_argparser(): +# """ +# Builds argument parser. +# +# :return argparse.ArgumentParser: Argument parser +# """ +# +# banner = "%(prog)s - Portable Encapsulated Projects toolkit" +# # additional_description = "\nhttps://geniml.databio.org" +# +# parser = VersionInHelpParser( +# prog=PKG_NAME, +# version=f"{__version__}", +# description=banner, +# ) +# +# # Individual subcommands +# msg_by_cmd = { +# "eido": "PEP validation, conversion, and inspection", +# "phc": "Client for the PEPhub server", +# # "pephubclient": "Client for the PEPhub server", +# } +# +# sp = parser.add_subparsers(dest="command") +# subparsers: Dict[str, VersionInHelpParser] = {} +# for k, v in msg_by_cmd.items(): +# subparsers[k] = sp.add_parser(k, description=v, help=v) +# +# # build up subparsers for modules +# subparsers["eido"] = eido_subparser(subparsers["eido"]) +# +# return parser +# +# +# def main(test_args=None): +# """Primary workflow""" +# if len(sys.argv) > 1 and sys.argv[1] == "phc": +# from .pephubclient.cli import app +# +# sub_args = sys.argv[2:] +# app( +# args=sub_args, +# prog_name=f"{PKG_NAME} phc", +# ) +# parser = logmuse.add_logging_options(build_argparser()) +# args, remaining = parser.parse_known_args() +# +# if test_args: +# args.__dict__.update(test_args) +# +# global _LOGGER +# _LOGGER = logmuse.logger_via_cli(args, make_root=True) +# +# if args.command is None: +# parser.print_help(sys.stderr) +# sys.exit(1) +# +# if args.command == "eido": +# if args.subcommand == CONVERT_CMD: +# convert_sp = _get_subparser(parser, "eido", CONVERT_CMD) +# filters = get_available_pep_filters() +# if args.list: +# _LOGGER.info("Available filters:") +# if len(filters) < 1: +# _LOGGER.info("No available filters") +# for filter_name in filters: +# _LOGGER.info(f" - {filter_name}") +# sys.exit(0) +# if not "format" in args: +# _LOGGER.error("The following arguments are required: --format") +# convert_sp.print_help(sys.stderr) +# sys.exit(1) +# if args.describe: +# if args.format not in filters: +# raise EidoFilterError( +# f"'{args.format}' filter not found. Available filters: {', '.join(filters)}" +# ) +# filter_functions_by_name = pep_conversion_plugins() +# print(filter_functions_by_name[args.format].__doc__) +# sys.exit(0) +# if args.pep is None: +# # parser.print_help(sys.stderr) +# # sp[CONVERT_CMD].print_help(sys.stderr) +# convert_sp.print_help(sys.stderr) +# _LOGGER.info("The following arguments are required: PEP") +# sys.exit(1) +# if args.paths: +# paths = {y[0]: y[1] for y in [x.split("=") for x in args.paths]} +# else: +# paths = None +# +# p = Project( +# args.pep, +# sample_table_index=args.st_index, +# subsample_table_index=args.sst_index, +# amendments=args.amendments, +# ) +# plugin_kwargs = _parse_filter_args_str(args.args) +# +# # append paths +# plugin_kwargs["paths"] = paths +# +# convert_project(p, args.format, plugin_kwargs) +# _LOGGER.info("Conversion successful") +# sys.exit(0) +# +# _LOGGER.debug(f"Creating a Project object from: {args.pep}") +# if args.subcommand == VALIDATE_CMD: +# p = Project( +# args.pep, +# sample_table_index=args.st_index, +# subsample_table_index=args.sst_index, +# amendments=args.amendments, +# ) +# if args.sample_name: +# try: +# args.sample_name = int(args.sample_name) +# except ValueError: +# # If sample_name is not an integer, leave it as a string. +# pass +# _LOGGER.debug( +# f"Comparing Sample ('{args.pep}') in Project ('{args.pep}') " +# f"against a schema: {args.schema}" +# ) +# validator = validate_sample +# arguments = [p, args.sample_name, args.schema] +# elif args.just_config: +# _LOGGER.debug( +# f"Comparing Project ('{args.pep}') against a schema: {args.schema}" +# ) +# validator = validate_config +# arguments = [p, args.schema] +# else: +# _LOGGER.debug( +# f"Comparing Project ('{args.pep}') against a schema: {args.schema}" +# ) +# validator = validate_project +# arguments = [p, args.schema] +# try: +# validator(*arguments) +# except EidoValidationError as e: +# print_error_summary(e.errors_by_type, _LOGGER) +# sys.exit(1) +# _LOGGER.info("Validation successful") +# sys.exit(0) +# +# if args.subcommand == INSPECT_CMD: +# p = Project( +# args.pep, +# sample_table_index=args.st_index, +# subsample_table_index=args.sst_index, +# amendments=args.amendments, +# ) +# inspect_project(p, args.sample_name, args.attr_limit) +# sys.exit(0) + + +def main(): """Primary workflow""" - if len(sys.argv) > 1 and sys.argv[1] == "phc": - from .pephubclient.cli import app - - sub_args = sys.argv[2:] - app( - args=sub_args, - prog_name=f"{PKG_NAME} phc", - ) - parser = logmuse.add_logging_options(build_argparser()) - args, remaining = parser.parse_known_args() - - if test_args: - args.__dict__.update(test_args) - - global _LOGGER - _LOGGER = logmuse.logger_via_cli(args, make_root=True) - - if args.command is None: - parser.print_help(sys.stderr) - sys.exit(1) - - if args.command == "eido": - if args.subcommand == CONVERT_CMD: - convert_sp = _get_subparser(parser, "eido", CONVERT_CMD) - filters = get_available_pep_filters() - if args.list: - _LOGGER.info("Available filters:") - if len(filters) < 1: - _LOGGER.info("No available filters") - for filter_name in filters: - _LOGGER.info(f" - {filter_name}") - sys.exit(0) - if not "format" in args: - _LOGGER.error("The following arguments are required: --format") - convert_sp.print_help(sys.stderr) - sys.exit(1) - if args.describe: - if args.format not in filters: - raise EidoFilterError( - f"'{args.format}' filter not found. Available filters: {', '.join(filters)}" - ) - filter_functions_by_name = pep_conversion_plugins() - print(filter_functions_by_name[args.format].__doc__) - sys.exit(0) - if args.pep is None: - # parser.print_help(sys.stderr) - # sp[CONVERT_CMD].print_help(sys.stderr) - convert_sp.print_help(sys.stderr) - _LOGGER.info("The following arguments are required: PEP") - sys.exit(1) - if args.paths: - paths = {y[0]: y[1] for y in [x.split("=") for x in args.paths]} - else: - paths = None - - p = Project( - args.pep, - sample_table_index=args.st_index, - subsample_table_index=args.sst_index, - amendments=args.amendments, - ) - plugin_kwargs = _parse_filter_args_str(args.args) - - # append paths - plugin_kwargs["paths"] = paths - - convert_project(p, args.format, plugin_kwargs) - _LOGGER.info("Conversion successful") - sys.exit(0) - - _LOGGER.debug(f"Creating a Project object from: {args.pep}") - if args.subcommand == VALIDATE_CMD: - p = Project( - args.pep, - sample_table_index=args.st_index, - subsample_table_index=args.sst_index, - amendments=args.amendments, - ) - if args.sample_name: - try: - args.sample_name = int(args.sample_name) - except ValueError: - # If sample_name is not an integer, leave it as a string. - pass - _LOGGER.debug( - f"Comparing Sample ('{args.pep}') in Project ('{args.pep}') " - f"against a schema: {args.schema}" - ) - validator = validate_sample - arguments = [p, args.sample_name, args.schema] - elif args.just_config: - _LOGGER.debug( - f"Comparing Project ('{args.pep}') against a schema: {args.schema}" - ) - validator = validate_config - arguments = [p, args.schema] - else: - _LOGGER.debug( - f"Comparing Project ('{args.pep}') against a schema: {args.schema}" - ) - validator = validate_project - arguments = [p, args.schema] - try: - validator(*arguments) - except EidoValidationError as e: - print_error_summary(e.errors_by_type, _LOGGER) - sys.exit(1) - _LOGGER.info("Validation successful") - sys.exit(0) - - if args.subcommand == INSPECT_CMD: - p = Project( - args.pep, - sample_table_index=args.st_index, - subsample_table_index=args.sst_index, - amendments=args.amendments, - ) - inspect_project(p, args.sample_name, args.attr_limit) - sys.exit(0) - # if args.command == "phc": - # - # # direct import from the old Typer CLI - # from .pephubclient.cli import app - # - # # Typer/Click allows overriding program name + args: - # app( - # args=remaining, # all args after "peppy phc" - # prog_name=f"{PKG_NAME} phc", # better help/usage text - # ) + app() diff --git a/peppy/eido/cli.py b/peppy/eido/cli.py new file mode 100644 index 00000000..c8f54837 --- /dev/null +++ b/peppy/eido/cli.py @@ -0,0 +1,6 @@ +import typer + +app = typer.Typer() + +@app.command() +def convert(): From a8a47119cc843dcdbf394eb2d36ef95fe8e6c962 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 20:07:29 -0500 Subject: [PATCH 146/165] save draft of typer cli for eido --- peppy/eido/cli.py | 356 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 355 insertions(+), 1 deletion(-) diff --git a/peppy/eido/cli.py b/peppy/eido/cli.py index c8f54837..3eab9103 100644 --- a/peppy/eido/cli.py +++ b/peppy/eido/cli.py @@ -1,6 +1,360 @@ +import sys +from logging import CRITICAL, DEBUG, ERROR, INFO, WARN, Logger + +# import logging +from typing import Dict, List, Optional + import typer +from logmuse import init_logger + +from ..const import SAMPLE_NAME_ATTR +from ..project import Project +from .const import LOGGING_LEVEL, PKG_NAME +from .conversion import ( + convert_project, + get_available_pep_filters, + pep_conversion_plugins, +) +from .exceptions import EidoFilterError, EidoValidationError +from .inspection import inspect_project +from .validation import validate_config, validate_project, validate_sample + +LEVEL_BY_VERBOSITY = [ERROR, CRITICAL, WARN, INFO, DEBUG] + +global _LOGGER +# _LOGGER = logmuse.init_logger("eido") app = typer.Typer() + +def _configure_logging( + verbosity: Optional[int], + logging_level: Optional[str], + dbg: bool, +) -> str: + """Mimic old verbosity / logging-level behavior.""" + if dbg: + level = logging_level or DEBUG + elif verbosity is not None: + # Verbosity-framed specification trumps logging_level. + level = LEVEL_BY_VERBOSITY[verbosity] + else: + level = LOGGING_LEVEL + return level + + +def _parse_filter_args_str(input: Optional[List[str]]) -> Dict[str, str]: + """ + Parse user input specification. + + :param Iterable[Iterable[str]] input: user command line input, + formatted as follows: [[arg=txt, arg1=txt]] + :return dict: mapping of keys, which are input names and values + """ + lst = [] + for i in input or []: + lst.extend(i) + return ( + {x.split("=")[0]: x.split("=")[1] for x in lst if "=" in x} + if lst is not None + else lst + ) + + +def print_error_summary( + errors_by_type: Dict[str, List[Dict[str, str]]], _LOGGER: Logger +): + """Print a summary of errors, organized by error type""" + n_error_types = len(errors_by_type) + _LOGGER.error(f"Found {n_error_types} types of error:") + for err_type, items in errors_by_type.items(): + n = len(items) + msg = f" - {err_type}: ({n} samples) " + if n < 50: + msg += ", ".join(x["sample_name"] for x in items) + _LOGGER.error(msg) + + if len(errors_by_type) > 1: + final_msg = f"Validation unsuccessful. {len(errors_by_type)} error types found." + else: + final_msg = f"Validation unsuccessful. {len(errors_by_type)} error type found." + + _LOGGER.error(final_msg) + + +@app.callback() +def main( + ctx: typer.Context, + verbosity: Optional[int] = typer.Option( + None, + "--verbosity", + min=0, + max=len(LEVEL_BY_VERBOSITY) - 1, + help=f"Choose level of verbosity (default: {None})", + ), + logging_level: Optional[str] = typer.Option( + None, + "--logging-level", + help="logging level", + ), + dbg: bool = typer.Option( + False, + "--dbg", + help=f"Turn on debug mode (default: {False})", + ), +): + ctx.obj = { + "verbosity": verbosity, + "logging_level": logging_level, + "dbg": dbg, + } + + logger_level = _configure_logging(verbosity, logging_level, dbg) + logger_kwargs = {"level": logger_level, "devmode": dbg} + + global _LOGGER + _LOGGER = init_logger(name=PKG_NAME, **logger_kwargs) + + +@app.command() +def convert( + ctx: typer.Context, + pep: Optional[str] = typer.Argument( + None, + metavar="PEP", + help="Path to a PEP configuration file in yaml format.", + ), + st_index: Optional[str] = typer.Option( + None, "--st-index", help="Sample table index to use" + ), + sst_index: Optional[str] = typer.Option( + None, "--sst-index", help=f"Subsample table index to use" + ), + amendments: Optional[List[str]] = typer.Option( + None, + "--amendments", + help="Names of the amendments to activate.", + ), + format_: str = typer.Option( + "yaml", + "-f", + "--format", + help="Output format (name of filter; use -l to see available).", + ), + sample_name: Optional[List[str]] = typer.Option( + None, + "-n", + "--sample-name", + help="Name of the samples to inspect.", + ), + args: Optional[List[str]] = typer.Option( + None, + "-a", + "--args", + help=( + "Provide arguments to the filter function " "(e.g. arg1=val1 arg2=val2)." + ), + ), + list_filters: bool = typer.Option( + False, + "-l", + "--list", + help="List available filters.", + ), + describe: bool = typer.Option( + False, + "-d", + "--describe", + help="Show description for a given filter.", + ), + paths_: Optional[List[str]] = typer.Option( + None, + "-p", + "--paths", + help="Paths to dump conversion result as key=value pairs.", + ), +): + filters = get_available_pep_filters() + if list_filters: + _LOGGER.info("Available filters:") + if len(filters) < 1: + _LOGGER.info("No available filters") + for filter_name in filters: + _LOGGER.info(f" - {filter_name}") + sys.exit(0) + if describe: + if format_ not in filters: + raise EidoFilterError( + f"'{format_}' filter not found. Available filters: {', '.join(filters)}" + ) + filter_functions_by_name = pep_conversion_plugins() + print(filter_functions_by_name[format_].__doc__) + sys.exit(0) + if pep is None: + typer.echo(ctx.get_help(), err=True) + _LOGGER.info("The following arguments are required: PEP") + sys.exit(1) + + if paths_: + paths = {y[0]: y[1] for y in [x.split("=") for x in paths_]} + else: + paths = None + + p = Project( + pep, + sample_table_index=st_index, + subsample_table_index=sst_index, + amendments=amendments, + ) + + plugin_kwargs = _parse_filter_args_str(args) + + # append paths + plugin_kwargs["paths"] = paths + + convert_project(p, format_, plugin_kwargs) + _LOGGER.info("Conversion successful") + sys.exit(0) + + +@app.command() +def validate( + pep: str = typer.Argument( + None, + metavar="PEP", + help="Path to a PEP configuration file in yaml format.", + ), + schema: str = typer.Option( + ..., + "-s", + "--schema", + metavar="S", + help="Path to a PEP schema file in yaml format.", + ), + st_index: Optional[str] = typer.Option( + None, + "--st-index", + help=( + f"Sample table index to use; samples are identified by " + f"'{SAMPLE_NAME_ATTR}' by default." + ), + ), + sst_index: Optional[str] = typer.Option( + None, + "--sst-index", + help=( + f"Subsample table index to use; samples are identified by " + f"'{SAMPLE_NAME_ATTR}' by default." + ), + ), + amendments: Optional[List[str]] = typer.Option( + None, + "--amendments", + help="Names of the amendments to activate.", + ), + sample_name: Optional[str] = typer.Option( + None, + "-n", + "--sample-name", + metavar="S", + help=( + "Name or index of the sample to validate. " + "Only this sample will be validated." + ), + ), + just_config: bool = typer.Option( + False, + "-c", + "--just-config", + help="Whether samples should be excluded from the validation.", + ), +): + if sample_name and just_config: + raise typer.BadParameter( + "Use only one of --sample-name or --just-config for 'validate'." + ) + p = Project( + pep, + sample_table_index=st_index, + subsample_table_index=sst_index, + amendments=amendments, + ) + if sample_name: + try: + sample_name = int(sample_name) + except ValueError: + pass + _LOGGER.debug( + f"Comparing Sample ('{pep}') in Project ('{pep}') " + f"against a schema: {schema}" + ) + validator = validate_sample + arguments = [p, sample_name, schema] + elif just_config: + _LOGGER.debug(f"Comparing Project ('{pep}') against a schema: {schema}") + + validator = validate_config + arguments = [p, schema] + else: + _LOGGER.debug(f"Comparing Project ('{pep}') against a schema: {schema}") + + validator = validate_project + arguments = [p, schema] + try: + validator(*arguments) + except EidoValidationError as e: + print_error_summary(e.errors_by_type, _LOGGER) + sys.exit(1) + _LOGGER.info("Validation successful") + sys.exit(0) + + @app.command() -def convert(): +def inspect( + pep: str = typer.Argument( + None, + metavar="PEP", + help="Path to a PEP configuration file in yaml format.", + ), + st_index: Optional[str] = typer.Option( + None, + "--st-index", + help=( + f"Sample table index to use; samples are identified by " + f"'{SAMPLE_NAME_ATTR}' by default." + ), + ), + sst_index: Optional[str] = typer.Option( + None, + "--sst-index", + help=( + f"Subsample table index to use; samples are identified by " + f"'{SAMPLE_NAME_ATTR}' by default." + ), + ), + amendments: Optional[List[str]] = typer.Option( + None, + "--amendments", + help="Names of the amendments to activate.", + ), + sample_name: Optional[List[str]] = typer.Option( + None, + "-n", + "--sample-name", + metavar="SN", + help="Name of the samples to inspect.", + ), + attr_limit: int = typer.Option( + 10, + "-l", + "--attr-limit", + help="Number of sample attributes to display.", + ), +): + p = Project( + pep, + sample_table_index=st_index, + subsample_table_index=sst_index, + amendments=amendments, + ) + inspect_project(p, sample_name, attr_limit) From be7ca590fea9cc2e59ea85008e30252c960516c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 21:37:47 -0500 Subject: [PATCH 147/165] typer cli for peppy --- peppy/cli.py | 249 ++------------------------------- peppy/eido/argparser.py | 150 -------------------- peppy/eido/cli.py | 19 +-- peppy/pephubclient/__init__.py | 26 ---- peppy/pephubclient/cli.py | 17 --- 5 files changed, 22 insertions(+), 439 deletions(-) delete mode 100644 peppy/eido/argparser.py diff --git a/peppy/cli.py b/peppy/cli.py index 63bdb19c..3ff8980c 100644 --- a/peppy/cli.py +++ b/peppy/cli.py @@ -1,252 +1,33 @@ -import argparse -import logging -import sys -from typing import Dict, List - -import logmuse import typer -from ubiquerg import VersionInHelpParser from ._version import __version__ from .const import PKG_NAME -from .eido.argparser import build_subparser as eido_subparser -from .eido.const import CONVERT_CMD, INSPECT_CMD, VALIDATE_CMD -from .eido.conversion import ( - convert_project, - get_available_pep_filters, - pep_conversion_plugins, -) -from .eido.exceptions import EidoFilterError, EidoValidationError -from .eido.inspection import inspect_project -from .eido.validation import validate_config, validate_project, validate_sample +from .eido.cli import app as eido_app from .pephubclient.cli import app as phc_app -from .project import Project - - -def _get_subparser(parser, *names): - """ - Access nested subparsers by name. - Example: _get_subparser(main_parser, "cmdA", "subA1") - """ - current = parser - for name in names: - # find subparsers action - subactions = [ - a for a in current._actions if isinstance(a, argparse._SubParsersAction) - ] - if not subactions: - raise ValueError(f"{current} has no subparsers") - action = subactions[0] - if name not in action.choices: - raise ValueError(f"Subparser '{name}' not found") - current = action.choices[name] +def version_callback(value: bool): + if value: + typer.echo(f"{PKG_NAME} version: {__version__}") + raise typer.Exit() - return current - -def _parse_filter_args_str(input): - """ - Parse user input specification. - - :param Iterable[Iterable[str]] input: user command line input, - formatted as follows: [[arg=txt, arg1=txt]] - :return dict: mapping of keys, which are input names and values - """ - lst = [] - for i in input or []: - lst.extend(i) - return ( - {x.split("=")[0]: x.split("=")[1] for x in lst if "=" in x} - if lst is not None - else lst - ) +app = typer.Typer(help=f"{PKG_NAME} - Portable Encapsulated Projects toolkit") -def print_error_summary( - errors_by_type: Dict[str, List[Dict[str, str]]], _LOGGER: logging.Logger +@app.callback() +def common( + ctx: typer.Context, + version: bool = typer.Option( + None, "--version", "-v", callback=version_callback, help="package version" + ), ): - """Print a summary of errors, organized by error type""" - n_error_types = len(errors_by_type) - _LOGGER.error(f"Found {n_error_types} types of error:") - for err_type, items in errors_by_type.items(): - n = len(items) - msg = f" - {err_type}: ({n} samples) " - if n < 50: - msg += ", ".join(x["sample_name"] for x in items) - _LOGGER.error(msg) + pass - if len(errors_by_type) > 1: - final_msg = f"Validation unsuccessful. {len(errors_by_type)} error types found." - else: - final_msg = f"Validation unsuccessful. {len(errors_by_type)} error type found." - - _LOGGER.error(final_msg) - - -app = typer.Typer(help=f"{PKG_NAME} - Portable Encapsulated Projects toolkit") app.add_typer(phc_app, name="phc", help="Client for the PEPhub server") - - -# def build_argparser(): -# """ -# Builds argument parser. -# -# :return argparse.ArgumentParser: Argument parser -# """ -# -# banner = "%(prog)s - Portable Encapsulated Projects toolkit" -# # additional_description = "\nhttps://geniml.databio.org" -# -# parser = VersionInHelpParser( -# prog=PKG_NAME, -# version=f"{__version__}", -# description=banner, -# ) -# -# # Individual subcommands -# msg_by_cmd = { -# "eido": "PEP validation, conversion, and inspection", -# "phc": "Client for the PEPhub server", -# # "pephubclient": "Client for the PEPhub server", -# } -# -# sp = parser.add_subparsers(dest="command") -# subparsers: Dict[str, VersionInHelpParser] = {} -# for k, v in msg_by_cmd.items(): -# subparsers[k] = sp.add_parser(k, description=v, help=v) -# -# # build up subparsers for modules -# subparsers["eido"] = eido_subparser(subparsers["eido"]) -# -# return parser -# -# -# def main(test_args=None): -# """Primary workflow""" -# if len(sys.argv) > 1 and sys.argv[1] == "phc": -# from .pephubclient.cli import app -# -# sub_args = sys.argv[2:] -# app( -# args=sub_args, -# prog_name=f"{PKG_NAME} phc", -# ) -# parser = logmuse.add_logging_options(build_argparser()) -# args, remaining = parser.parse_known_args() -# -# if test_args: -# args.__dict__.update(test_args) -# -# global _LOGGER -# _LOGGER = logmuse.logger_via_cli(args, make_root=True) -# -# if args.command is None: -# parser.print_help(sys.stderr) -# sys.exit(1) -# -# if args.command == "eido": -# if args.subcommand == CONVERT_CMD: -# convert_sp = _get_subparser(parser, "eido", CONVERT_CMD) -# filters = get_available_pep_filters() -# if args.list: -# _LOGGER.info("Available filters:") -# if len(filters) < 1: -# _LOGGER.info("No available filters") -# for filter_name in filters: -# _LOGGER.info(f" - {filter_name}") -# sys.exit(0) -# if not "format" in args: -# _LOGGER.error("The following arguments are required: --format") -# convert_sp.print_help(sys.stderr) -# sys.exit(1) -# if args.describe: -# if args.format not in filters: -# raise EidoFilterError( -# f"'{args.format}' filter not found. Available filters: {', '.join(filters)}" -# ) -# filter_functions_by_name = pep_conversion_plugins() -# print(filter_functions_by_name[args.format].__doc__) -# sys.exit(0) -# if args.pep is None: -# # parser.print_help(sys.stderr) -# # sp[CONVERT_CMD].print_help(sys.stderr) -# convert_sp.print_help(sys.stderr) -# _LOGGER.info("The following arguments are required: PEP") -# sys.exit(1) -# if args.paths: -# paths = {y[0]: y[1] for y in [x.split("=") for x in args.paths]} -# else: -# paths = None -# -# p = Project( -# args.pep, -# sample_table_index=args.st_index, -# subsample_table_index=args.sst_index, -# amendments=args.amendments, -# ) -# plugin_kwargs = _parse_filter_args_str(args.args) -# -# # append paths -# plugin_kwargs["paths"] = paths -# -# convert_project(p, args.format, plugin_kwargs) -# _LOGGER.info("Conversion successful") -# sys.exit(0) -# -# _LOGGER.debug(f"Creating a Project object from: {args.pep}") -# if args.subcommand == VALIDATE_CMD: -# p = Project( -# args.pep, -# sample_table_index=args.st_index, -# subsample_table_index=args.sst_index, -# amendments=args.amendments, -# ) -# if args.sample_name: -# try: -# args.sample_name = int(args.sample_name) -# except ValueError: -# # If sample_name is not an integer, leave it as a string. -# pass -# _LOGGER.debug( -# f"Comparing Sample ('{args.pep}') in Project ('{args.pep}') " -# f"against a schema: {args.schema}" -# ) -# validator = validate_sample -# arguments = [p, args.sample_name, args.schema] -# elif args.just_config: -# _LOGGER.debug( -# f"Comparing Project ('{args.pep}') against a schema: {args.schema}" -# ) -# validator = validate_config -# arguments = [p, args.schema] -# else: -# _LOGGER.debug( -# f"Comparing Project ('{args.pep}') against a schema: {args.schema}" -# ) -# validator = validate_project -# arguments = [p, args.schema] -# try: -# validator(*arguments) -# except EidoValidationError as e: -# print_error_summary(e.errors_by_type, _LOGGER) -# sys.exit(1) -# _LOGGER.info("Validation successful") -# sys.exit(0) -# -# if args.subcommand == INSPECT_CMD: -# p = Project( -# args.pep, -# sample_table_index=args.st_index, -# subsample_table_index=args.sst_index, -# amendments=args.amendments, -# ) -# inspect_project(p, args.sample_name, args.attr_limit) -# sys.exit(0) +app.add_typer(eido_app, name="eido", help="PEP validation, conversion, and inspection") def main(): - """Primary workflow""" - app() + app(prog_name=PKG_NAME) diff --git a/peppy/eido/argparser.py b/peppy/eido/argparser.py deleted file mode 100644 index 7cf79b1e..00000000 --- a/peppy/eido/argparser.py +++ /dev/null @@ -1,150 +0,0 @@ -from logging import CRITICAL, DEBUG, ERROR, INFO, WARN - -from ..const import PKG_NAME, SAMPLE_NAME_ATTR -from .const import CONVERT_CMD, INSPECT_CMD, SUBPARSER_MSGS, VALIDATE_CMD - -LEVEL_BY_VERBOSITY = [ERROR, CRITICAL, WARN, INFO, DEBUG] - - -def build_subparser(parser): - sp = parser.add_subparsers(dest="subcommand") - subparsers = {} - - for k, v in SUBPARSER_MSGS.items(): - subparsers[k] = sp.add_parser(k, description=v, help=v) - subparsers[k].add_argument( - "--st-index", - required=False, - type=str, - # default=SAMPLE_NAME_ATTR, - help=f"Sample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.", - ) - subparsers[k].add_argument( - "--sst-index", - required=False, - type=str, - # default=SAMPLE_NAME_ATTR, - help=f"Subsample table index to use, samples are identified by '{SAMPLE_NAME_ATTR}' by default.", - ) - subparsers[k].add_argument( - "--amendments", - required=False, - type=str, - nargs="+", - help=f"Names of the amendments to activate.", - ) - - if k != CONVERT_CMD: - subparsers[k].add_argument( - "pep", - metavar="PEP", - help="Path to a PEP configuration file in yaml format.", - default=None, - ) - else: - subparsers[k].add_argument( - "pep", - metavar="PEP", - nargs="?", - help="Path to a PEP configuration file in yaml format.", - default=None, - ) - - subparsers[VALIDATE_CMD].add_argument( - "-s", - "--schema", - required=True, - help="Path to a PEP schema file in yaml format.", - metavar="S", - ) - - subparsers[INSPECT_CMD].add_argument( - "-n", - "--sample-name", - required=False, - nargs="+", - help="Name of the samples to inspect.", - metavar="SN", - ) - - subparsers[INSPECT_CMD].add_argument( - "-l", - "--attr-limit", - required=False, - type=int, - default=10, - help="Number of sample attributes to display.", - ) - - group = subparsers[VALIDATE_CMD].add_mutually_exclusive_group() - - group.add_argument( - "-n", - "--sample-name", - required=False, - help="Name or index of the sample to validate. " - "Only this sample will be validated.", - metavar="S", - ) - - group.add_argument( - "-c", - "--just-config", - required=False, - action="store_true", - default=False, - help="Whether samples should be excluded from the validation.", - ) - - subparsers[CONVERT_CMD].add_argument( - "-f", - "--format", - required=False, - default="yaml", - help="Output format (name of filter; use -l to see available).", - ) - - subparsers[CONVERT_CMD].add_argument( - "-n", - "--sample-name", - required=False, - nargs="+", - help="Name of the samples to inspect.", - ) - - subparsers[CONVERT_CMD].add_argument( - "-a", - "--args", - nargs="+", - action="append", - required=False, - default=None, - help="Provide arguments to the filter function (e.g. arg1=val1 arg2=val2).", - ) - - subparsers[CONVERT_CMD].add_argument( - "-l", - "--list", - required=False, - default=False, - action="store_true", - help="List available filters.", - ) - - subparsers[CONVERT_CMD].add_argument( - "-d", - "--describe", - required=False, - default=False, - action="store_true", - help="Show description for a given filter.", - ) - - subparsers[CONVERT_CMD].add_argument( - "-p", - "--paths", - nargs="+", - help="Paths to dump conversion result as key=value pairs.", - ) - - return parser diff --git a/peppy/eido/cli.py b/peppy/eido/cli.py index 3eab9103..f31399a5 100644 --- a/peppy/eido/cli.py +++ b/peppy/eido/cli.py @@ -1,15 +1,13 @@ import sys from logging import CRITICAL, DEBUG, ERROR, INFO, WARN, Logger - -# import logging from typing import Dict, List, Optional import typer from logmuse import init_logger -from ..const import SAMPLE_NAME_ATTR +from ..const import PKG_NAME, SAMPLE_NAME_ATTR from ..project import Project -from .const import LOGGING_LEVEL, PKG_NAME +from .const import CONVERT_CMD, INSPECT_CMD, LOGGING_LEVEL, SUBPARSER_MSGS, VALIDATE_CMD from .conversion import ( convert_project, get_available_pep_filters, @@ -21,9 +19,6 @@ LEVEL_BY_VERBOSITY = [ERROR, CRITICAL, WARN, INFO, DEBUG] -global _LOGGER -# _LOGGER = logmuse.init_logger("eido") - app = typer.Typer() @@ -83,7 +78,7 @@ def print_error_summary( @app.callback() -def main( +def common( ctx: typer.Context, verbosity: Optional[int] = typer.Option( None, @@ -116,7 +111,7 @@ def main( _LOGGER = init_logger(name=PKG_NAME, **logger_kwargs) -@app.command() +@app.command(name=CONVERT_CMD, help=SUBPARSER_MSGS[CONVERT_CMD]) def convert( ctx: typer.Context, pep: Optional[str] = typer.Argument( @@ -217,7 +212,7 @@ def convert( sys.exit(0) -@app.command() +@app.command(name=VALIDATE_CMD, help=SUBPARSER_MSGS[VALIDATE_CMD]) def validate( pep: str = typer.Argument( None, @@ -225,7 +220,7 @@ def validate( help="Path to a PEP configuration file in yaml format.", ), schema: str = typer.Option( - ..., + None, "-s", "--schema", metavar="S", @@ -309,7 +304,7 @@ def validate( sys.exit(0) -@app.command() +@app.command(name=INSPECT_CMD, help=SUBPARSER_MSGS[INSPECT_CMD]) def inspect( pep: str = typer.Argument( None, diff --git a/peppy/pephubclient/__init__.py b/peppy/pephubclient/__init__.py index 2f735077..48c42e3f 100644 --- a/peppy/pephubclient/__init__.py +++ b/peppy/pephubclient/__init__.py @@ -1,27 +1 @@ -import logging - -import coloredlogs -from pephubclient.helpers import is_registry_path, save_pep -from pephubclient.pephubclient import PEPHubClient - -__app_name__ = "pephubclient" -__version__ = "0.4.5" __author__ = "Oleksandr Khoroshevskyi, Rafal Stepien" - - -# __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/peppy/pephubclient/cli.py b/peppy/pephubclient/cli.py index 71b9a885..20c90813 100644 --- a/peppy/pephubclient/cli.py +++ b/peppy/pephubclient/cli.py @@ -1,5 +1,4 @@ import typer -from pephubclient import __app_name__, __version__ from .helpers import call_client_func from .pephubclient import PEPHubClient @@ -72,19 +71,3 @@ def push( is_private=is_private, force=force, ) - - -def version_callback(value: bool): - if value: - typer.echo(f"{__app_name__} version: {__version__}") - raise typer.Exit() - - -@app.callback() -def common( - ctx: typer.Context, - version: bool = typer.Option( - None, "--version", "-v", callback=version_callback, help="App version" - ), -): - pass From 3561f1606332316d0bacff637eb8b0cc442971b8 Mon Sep 17 00:00:00 2001 From: "Ziyang \"Claude\" Hu" <33562602+ClaudeHu@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:47:34 -0500 Subject: [PATCH 148/165] Update peppy/eido/cli.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- peppy/eido/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/peppy/eido/cli.py b/peppy/eido/cli.py index f31399a5..0fc9dce1 100644 --- a/peppy/eido/cli.py +++ b/peppy/eido/cli.py @@ -123,7 +123,7 @@ def convert( None, "--st-index", help="Sample table index to use" ), sst_index: Optional[str] = typer.Option( - None, "--sst-index", help=f"Subsample table index to use" + None, "--sst-index", help="Subsample table index to use" ), amendments: Optional[List[str]] = typer.Option( None, From cace65ae84949d520781689f2648320505894593 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 12:26:04 -0500 Subject: [PATCH 149/165] update based on copilot feedback --- .../pephubclient/pephub_oauth/pephub_oauth.py | 6 +-- peppy/pephubclient/pephubclient.py | 13 +++--- tests/phctests/test_manual.py | 1 + tests/phctests/test_pephubclient.py | 46 +++++++++---------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/peppy/pephubclient/pephub_oauth/pephub_oauth.py b/peppy/pephubclient/pephub_oauth/pephub_oauth.py index faeeb260..d9246979 100644 --- a/peppy/pephubclient/pephub_oauth/pephub_oauth.py +++ b/peppy/pephubclient/pephub_oauth/pephub_oauth.py @@ -70,7 +70,7 @@ def _request_pephub_for_device_code(self) -> InitializeDeviceCodeResponse: def _exchange_device_code_on_token(self, device_code: str) -> str: """ - Send request with device dode to pephub in order to exchange it on JWT + Send request with device code to pephub in order to exchange it on JWT :param device_code: device code that was generated by pephub """ response = PEPHubAuth.send_request( @@ -89,7 +89,7 @@ def _handle_pephub_response( response: requests.Response, model: Type[BaseModel] ) -> Union[BaseModel, InitializeDeviceCodeResponse, PEPHubDeviceTokenResponse]: """ - Decode the response from GitHub and pack the returned data into appropriate model. + Decode the response from PEPhub and pack the returned data into appropriate model. :param response: Response from pephub :param model: Model that the data will be packed to. @@ -102,6 +102,6 @@ def _handle_pephub_response( try: content = json.loads(PEPHubAuth.decode_response(response)) except json.JSONDecodeError: - raise Exception("Something went wrong with GitHub response") + raise Exception("Something went wrong with PEPhub response") return model(**content) diff --git a/peppy/pephubclient/pephubclient.py b/peppy/pephubclient/pephubclient.py index 0d356a1a..acaf6de5 100644 --- a/peppy/pephubclient/pephubclient.py +++ b/peppy/pephubclient/pephubclient.py @@ -46,7 +46,7 @@ def view(self) -> PEPHubView: def sample(self) -> PEPHubSample: return self.__sample - def login(self) -> NoReturn: + def login(self) -> None: """ Log in to PEPhub """ @@ -55,7 +55,7 @@ def login(self) -> NoReturn: 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: + def logout(self) -> None: """ Log out from PEPhub """ @@ -153,9 +153,8 @@ def upload( :param namespace: namespace :param name: project name :param tag: project tag - :param force: Force push to the database. Use it to update, or upload project. :param is_private: Make project private - :param force: overwrite project if it exists + :param force: overwrite project if it exists, use it to update, or upload project. :return: None """ if name: @@ -318,7 +317,7 @@ def _set_registry_data(self, query_string: str) -> None: def _build_pull_request_url(self, query_param: dict = None) -> str: """ - Build request for getting projects form pephub + Build request for getting projects from pephub :param query_param: dict of parameters used in query string :return: url string @@ -336,7 +335,7 @@ def _build_pull_request_url(self, 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 + Build request for searching projects from pephub :param query_param: dict of parameters used in query string :return: url string @@ -350,7 +349,7 @@ def _build_project_search_url(namespace: str, query_param: dict = None) -> str: @staticmethod def _build_push_request_url(namespace: str) -> str: """ - Build project uplaod request used in pephub + Build project upload request used in pephub :param namespace: namespace where project will be uploaded :return: url string diff --git a/tests/phctests/test_manual.py b/tests/phctests/test_manual.py index a86117db..4ff5162b 100644 --- a/tests/phctests/test_manual.py +++ b/tests/phctests/test_manual.py @@ -1,4 +1,5 @@ import pytest + from peppy.pephubclient.pephubclient import PEPHubClient diff --git a/tests/phctests/test_pephubclient.py b/tests/phctests/test_pephubclient.py index bafb06be..21c7c4e8 100644 --- a/tests/phctests/test_pephubclient.py +++ b/tests/phctests/test_pephubclient.py @@ -1,7 +1,7 @@ -import os from unittest.mock import Mock import pytest + from peppy.pephubclient.exceptions import ResponseError from peppy.pephubclient.helpers import is_registry_path from peppy.pephubclient.pephub_oauth.models import InitializeDeviceCodeResponse @@ -251,7 +251,7 @@ def test_get(self, mocker): ) return_value = PEPHubClient().sample.get( "test_namespace", - "taest_name", + "test_name", "default", "gg1", ) @@ -281,7 +281,7 @@ def test_sample_get_with_pephub_error_response( with pytest.raises(ResponseError, match=expected_error_message): PEPHubClient().sample.get( "test_namespace", - "taest_name", + "test_name", "default", "gg1", ) @@ -302,7 +302,7 @@ def test_create(self, mocker, prj_dict): PEPHubClient().sample.create( "test_namespace", - "taest_name", + "test_name", "default", "gg1", sample_dict=return_value, @@ -333,7 +333,7 @@ def test_sample_create_with_pephub_error_response( with pytest.raises(ResponseError, match=expected_error_message): PEPHubClient().sample.create( "test_namespace", - "taest_name", + "test_name", "default", "gg1", sample_dict={ @@ -351,7 +351,7 @@ def test_delete(self, mocker): PEPHubClient().sample.remove( "test_namespace", - "taest_name", + "test_name", "default", "gg1", ) @@ -377,7 +377,7 @@ def test_sample_delete_with_pephub_error_response( with pytest.raises(ResponseError, match=expected_error_message): PEPHubClient().sample.remove( "test_namespace", - "taest_name", + "test_name", "default", "gg1", ) @@ -390,7 +390,7 @@ def test_update(self, mocker): PEPHubClient().sample.update( "test_namespace", - "taest_name", + "test_name", "default", "gg1", sample_dict={ @@ -421,7 +421,7 @@ def test_sample_update_with_pephub_error_response( with pytest.raises(ResponseError, match=expected_error_message): PEPHubClient().sample.update( "test_namespace", - "taest_name", + "test_name", "default", "gg1", sample_dict={ @@ -446,7 +446,7 @@ def test_get(self, mocker, test_raw_pep_return): return_value = PEPHubClient().view.get( "test_namespace", - "taest_name", + "test_name", "default", "gg1", ) @@ -472,7 +472,7 @@ def test_view_get_with_pephub_error_response( with pytest.raises(ResponseError, match=expected_error_message): PEPHubClient().view.get( "test_namespace", - "taest_name", + "test_name", "default", "gg1", ) @@ -485,7 +485,7 @@ def test_create(self, mocker): PEPHubClient().view.create( "test_namespace", - "taest_name", + "test_name", "default", "gg1", sample_list=["sample1", "sample2"], @@ -512,7 +512,7 @@ def test_view_create_with_pephub_error_response( with pytest.raises(ResponseError, match=expected_error_message): PEPHubClient().view.create( "test_namespace", - "taest_name", + "test_name", "default", "gg1", sample_list=["sample1", "sample2"], @@ -526,7 +526,7 @@ def test_delete(self, mocker): PEPHubClient().view.delete( "test_namespace", - "taest_name", + "test_name", "default", "gg1", ) @@ -552,7 +552,7 @@ def test_view_delete_with_pephub_error_response( with pytest.raises(ResponseError, match=expected_error_message): PEPHubClient().view.delete( "test_namespace", - "taest_name", + "test_name", "default", "gg1", ) @@ -565,7 +565,7 @@ def test_add_sample(self, mocker): PEPHubClient().view.add_sample( "test_namespace", - "taest_name", + "test_name", "default", "gg1", "sample1", @@ -580,7 +580,7 @@ def test_delete_sample(self, mocker): PEPHubClient().view.remove_sample( "test_namespace", - "taest_name", + "test_name", "default", "gg1", "sample1", @@ -597,19 +597,19 @@ def test_delete_sample(self, mocker): # 3. add with sample_name # 4. add without sample_name # 5. add with overwrite -# 6. add to unexisting project 404 +# 6. add to nonexistent project 404 # delete sample: # 1. delete existing 202 -# 2. delete unexisting 404 +# 2. delete nonexistent 404 # get sample: # 1. get existing 200 -# 2. get unexisting 404 +# 2. get nonexistent 404 # 3. get with raw 200 -# 4. get from unexisting project 404 +# 4. get from nonexistent project 404 # update sample: # 1. update existing 202 -# 2. update unexisting sample 404 -# 3. update unexisting project 404 +# 2. update nonexistent sample 404 +# 3. update nonexistent project 404 From 947ede6fe3d027dbbef9d21411276a6336adf927 Mon Sep 17 00:00:00 2001 From: "Ziyang \"Claude\" Hu" <33562602+ClaudeHu@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:31:23 -0500 Subject: [PATCH 150/165] Update peppy/pephubclient/helpers.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- peppy/pephubclient/helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/peppy/pephubclient/helpers.py b/peppy/pephubclient/helpers.py index ce61e021..e7119ac4 100644 --- a/peppy/pephubclient/helpers.py +++ b/peppy/pephubclient/helpers.py @@ -61,9 +61,9 @@ 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. + Decode the response from PEPhub and pack the returned data into appropriate model. - :param response: Response from GitHub. + :param response: Response from PEPhub. :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. From e9fed4115e702a51064b461b6b77f088dee104ef Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 15 Dec 2025 14:32:59 -0500 Subject: [PATCH 151/165] removed cfg file --- setup.cfg | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index ec734d81..00000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[aliases] -test = pytest - -[pytest] -# Only request extra info from failures and errors. -addopts = -rfE From 299816594e9780c63735ef4387ba9141b1a9a9a5 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 15 Dec 2025 15:20:16 -0500 Subject: [PATCH 152/165] updated requirementes and manifest file, and github actions --- .github/workflows/black.yml | 4 +++- .github/workflows/black_linter.yml | 13 ----------- .github/workflows/cli-coverage.yml | 8 +++---- .github/workflows/pytest.yml | 8 +++---- .github/workflows/run-codecov.yml | 21 ------------------ .github/workflows/run-pytest.yml | 35 ------------------------------ MANIFEST.in | 6 ++++- peppy/eido/__init__.py | 1 + requirements/requirements-all.txt | 12 +++++----- 9 files changed, 22 insertions(+), 86 deletions(-) delete mode 100644 .github/workflows/black_linter.yml delete mode 100644 .github/workflows/run-codecov.yml delete mode 100644 .github/workflows/run-pytest.yml create mode 100644 peppy/eido/__init__.py diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 8b48ddf1..90c97050 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -1,6 +1,8 @@ name: Lint -on: [pull_request] +on: + pull_request: + branches: [ main ] jobs: lint: diff --git a/.github/workflows/black_linter.yml b/.github/workflows/black_linter.yml deleted file mode 100644 index 6d34fe30..00000000 --- a/.github/workflows/black_linter.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Lint - -on: - pull_request: - branches: [main] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: psf/black@stable \ No newline at end of file diff --git a/.github/workflows/cli-coverage.yml b/.github/workflows/cli-coverage.yml index 5365a277..8b4e90fb 100644 --- a/.github/workflows/cli-coverage.yml +++ b/.github/workflows/cli-coverage.yml @@ -8,16 +8,16 @@ jobs: cli-coverage-report: strategy: matrix: - python-version: [ "3.11" ] + python-version: [ "3.12" ] os: [ ubuntu-latest ] r: [ release ] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - - uses: actions/setup-python@v1 + - uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install test dependencies run: if [ -f requirements/requirements-test.txt ]; then pip install -r requirements/requirements-test.txt; fi diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 334600e1..168da068 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -11,14 +11,14 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.8", "3.12"] - os: [ubuntu-20.04] + python-version: ["3.9", "3.13"] + os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - 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/run-codecov.yml b/.github/workflows/run-codecov.yml deleted file mode 100644 index a41a1fde..00000000 --- a/.github/workflows/run-codecov.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Run codecov - -on: - pull_request: - branches: [master] - -jobs: - pytest: - runs-on: ${{ matrix.os }} - strategy: - matrix: - python-version: [3.9] - os: [ubuntu-latest] - - steps: - - uses: actions/checkout@v2 - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 - with: - file: ./coverage.xml - name: py-${{ matrix.python-version }}-${{ matrix.os }} diff --git a/.github/workflows/run-pytest.yml b/.github/workflows/run-pytest.yml deleted file mode 100644 index 7864e39a..00000000 --- a/.github/workflows/run-pytest.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Run pytests - -on: - push: - branches: [dev] - pull_request: - branches: [master, dev] - -jobs: - pytest: - runs-on: ${{ matrix.os }} - strategy: - matrix: - python-version: ["3.9", "3.13"] - os: [ubuntu-latest] - - steps: - - uses: actions/checkout@v2 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dev dependencies - run: if [ -f requirements/requirements-dev.txt ]; then pip install -r requirements/requirements-dev.txt; fi - - - name: Install test dependencies - run: if [ -f requirements/requirements-test.txt ]; then pip install -r requirements/requirements-test.txt; fi - - - name: Install package - run: python -m pip install . - - - name: Run pytest tests - run: pytest tests -x -vv --cov=./ --cov-report=xml --remote-data diff --git a/MANIFEST.in b/MANIFEST.in index 857bdcf3..43e283b4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,6 @@ include requirements/* -include README.md \ No newline at end of file +include README.md +include peppy/pephubclient/* +include peppy/eido/* +include peppy/pephubclient/pephub_oauth/* +include peppy/pephubclient/modules/* diff --git a/peppy/eido/__init__.py b/peppy/eido/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/peppy/eido/__init__.py @@ -0,0 +1 @@ + diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index 55471dd4..3fb33cb0 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -1,14 +1,12 @@ -pandas>=0.24.2 -pyyaml +pandas>=2.2.0 +pyyaml>=6.0.0 rich>=10.3.0 ubiquerg>=0.6.2 -numpy -pephubclient>=0.4.2 -# eido +numpy>=2.2.0 +logmuse>=0.2.8 importlib-metadata; python_version < '3.10' jsonschema>=3.0.1 -# pephubclient -typer>=0.7.0 +typer>=0.20.0 requests>=2.28.2 pydantic>2.5.0 coloredlogs>=15.0.1 \ No newline at end of file From 63c7022b7d9ca94206e2e7055f9855099eb5d417 Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 15 Dec 2025 15:22:01 -0500 Subject: [PATCH 153/165] updated requirements --- requirements/requirements-all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index 3fb33cb0..bf0dbb5c 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -2,7 +2,7 @@ pandas>=2.2.0 pyyaml>=6.0.0 rich>=10.3.0 ubiquerg>=0.6.2 -numpy>=2.2.0 +numpy logmuse>=0.2.8 importlib-metadata; python_version < '3.10' jsonschema>=3.0.1 From 63730efca7df331e815e2d4d0800d003e94a0ced Mon Sep 17 00:00:00 2001 From: Khoroshevskyi Date: Mon, 15 Dec 2025 15:23:37 -0500 Subject: [PATCH 154/165] updated github actions tests --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 168da068..d19e0e50 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: python-version: ["3.9", "3.13"] - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest] steps: - uses: actions/checkout@v5 From 67d0015d40e3c6cfcd6c16a17ee39ef35f970b90 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 13:39:53 -0500 Subject: [PATCH 155/165] trial to fix pytest(windows) --- tests/peppytests/test_Project.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/peppytests/test_Project.py b/tests/peppytests/test_Project.py index 91ccd45d..4ddca4fa 100644 --- a/tests/peppytests/test_Project.py +++ b/tests/peppytests/test_Project.py @@ -2,6 +2,7 @@ import pickle import socket import tempfile +from pathlib import Path import numpy as np import pytest @@ -101,7 +102,8 @@ def test_expand_path(self, example_pep_cfg_path, defer): amendments="newLib", defer_samples_creation=defer, ) - assert not p.config["output_dir"].startswith("$") + # assert not p.config["output_dir"].startswith("$") + assert p.config["output_dir"] == str(Path.home() / "hello_looper_results") @pytest.mark.parametrize( "config_path", From 3d7b506f0b5ee7251fba356abdd4449da618ad84 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 15:23:14 -0500 Subject: [PATCH 156/165] trial to solve path in yaml for win --- .../example_amendments1/win_project_config.yaml | 16 ++++++++++++++++ tests/peppytests/test_Project.py | 12 ++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 tests/data/peppydata/example_peps-master/example_amendments1/win_project_config.yaml diff --git a/tests/data/peppydata/example_peps-master/example_amendments1/win_project_config.yaml b/tests/data/peppydata/example_peps-master/example_amendments1/win_project_config.yaml new file mode 100644 index 00000000..03347a84 --- /dev/null +++ b/tests/data/peppydata/example_peps-master/example_amendments1/win_project_config.yaml @@ -0,0 +1,16 @@ +pep_version: "2.0.0" +sample_table: sample_table.csv +output_dir: "%USERPROFILE%/hello_looper_results" + +sample_modifiers: + derive: + attributes: [file_path] + sources: + source1: /data/lab/project/{organism}_{time}h.fastq + source2: /path/from/collaborator/weirdNamingScheme_{external_id}.fastq +project_modifiers: + amend: + newLib: + sample_table: sample_table_newLib.csv + newLib2: + sample_table: sample_table_newLib2.csv diff --git a/tests/peppytests/test_Project.py b/tests/peppytests/test_Project.py index 4ddca4fa..03775b92 100644 --- a/tests/peppytests/test_Project.py +++ b/tests/peppytests/test_Project.py @@ -97,13 +97,21 @@ def test_expand_path(self, example_pep_cfg_path, defer): """ Verify output_path is expanded """ + if os.name == "nt": + example_pep_cfg_path = os.path.join( + *os.path.split(example_pep_cfg_path)[:1], + f"win_{os.path.split(example_pep_cfg_path)[1]}", + ) p = Project( cfg=example_pep_cfg_path, amendments="newLib", defer_samples_creation=defer, ) - # assert not p.config["output_dir"].startswith("$") - assert p.config["output_dir"] == str(Path.home() / "hello_looper_results") + assert not ( + p.config["output_dir"].startswith("$") + or p.config["output_dir"].startswith("%") + ) + # assert p.config["output_dir"] == str(Path.home() / "hello_looper_results") @pytest.mark.parametrize( "config_path", From 234bdb049854a2703074ea9188988527cd5a8ab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 19:22:02 -0500 Subject: [PATCH 157/165] for url path expansion in Windows --- peppy/utils.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/peppy/utils.py b/peppy/utils.py index 40600ef0..49099b84 100644 --- a/peppy/utils.py +++ b/peppy/utils.py @@ -62,11 +62,25 @@ def make_abs_via_cfg( _LOGGER.debug("Expanded: {}".format(expanded)) return expanded # Set path to an absolute path, relative to project config. - config_dirpath = os.path.dirname(cfg_path) + # config_dirpath = os.path.dirname(cfg_path) + # _LOGGER.debug("config_dirpath: {}".format(config_dirpath)) + # abs_path = os.path.join(config_dirpath, maybe_relpath) + # _LOGGER.debug("Expanded and/or made absolute: {}".format(abs_path)) + # if check_exists and not os.path.exists(abs_path): + # raise OSError(f"Path made absolute does not exist: {abs_path}") + # return abs_path + if is_url(cfg_path): + config_dirpath = psp.dirname(cfg_path) + else: + config_dirpath = os.path.dirname(cfg_path) _LOGGER.debug("config_dirpath: {}".format(config_dirpath)) - abs_path = os.path.join(config_dirpath, maybe_relpath) + + if is_url(cfg_path): + abs_path = psp.join(config_dirpath, maybe_relpath) + else: + abs_path = os.path.join(config_dirpath, maybe_relpath) _LOGGER.debug("Expanded and/or made absolute: {}".format(abs_path)) - if check_exists and not os.path.exists(abs_path): + if check_exists and not is_url(abs_path) and not os.path.exists(abs_path): raise OSError(f"Path made absolute does not exist: {abs_path}") return abs_path From a9699325f40040b4f6858c5008a028bd26ec4175 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 19:35:11 -0500 Subject: [PATCH 158/165] remove commented code --- peppy/utils.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/peppy/utils.py b/peppy/utils.py index 49099b84..55d23c0e 100644 --- a/peppy/utils.py +++ b/peppy/utils.py @@ -62,13 +62,6 @@ def make_abs_via_cfg( _LOGGER.debug("Expanded: {}".format(expanded)) return expanded # Set path to an absolute path, relative to project config. - # config_dirpath = os.path.dirname(cfg_path) - # _LOGGER.debug("config_dirpath: {}".format(config_dirpath)) - # abs_path = os.path.join(config_dirpath, maybe_relpath) - # _LOGGER.debug("Expanded and/or made absolute: {}".format(abs_path)) - # if check_exists and not os.path.exists(abs_path): - # raise OSError(f"Path made absolute does not exist: {abs_path}") - # return abs_path if is_url(cfg_path): config_dirpath = psp.dirname(cfg_path) else: From 1210ee2678f088b624e5c8f95929e4627d52eaca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 13:19:54 -0500 Subject: [PATCH 159/165] branch extension on pytest workflow --- .github/workflows/pytest-windows.yml | 2 +- .github/workflows/pytest.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest-windows.yml b/.github/workflows/pytest-windows.yml index 34a557ed..7d9cb327 100644 --- a/.github/workflows/pytest-windows.yml +++ b/.github/workflows/pytest-windows.yml @@ -4,7 +4,7 @@ on: push: branches: [dev] pull_request: - branches: [main, dev] + branches: [main, dev, peppy0_50] jobs: pytest: diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index d19e0e50..2010fe77 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -4,7 +4,7 @@ on: push: branches: [dev] pull_request: - branches: [main, dev] + branches: [main, dev, peppy0_50] jobs: pytest: From ca90860f1098e2034a93c48dc64c32014323c1c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 13:34:15 -0500 Subject: [PATCH 160/165] version update --- peppy/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/peppy/_version.py b/peppy/_version.py index fe4ef03f..f4759ace 100644 --- a/peppy/_version.py +++ b/peppy/_version.py @@ -1 +1 @@ -__version__ = "0.40.8" +__version__ = "0.50.0a1" From 8ad583b4021d0813ab0d5bc37fc977f6bfb3b6e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 13:46:16 -0500 Subject: [PATCH 161/165] remove docs due to overlapping with pespec --- docs/.gitignore | 1 - docs/README.md | 47 -- docs/changelog.md | 34 - docs/contributing.md | 13 - docs/hello-world.md | 48 -- docs/img/format_convert.svg | 1275 --------------------------------- docs/initialize.md | 91 --- docs/models.md | 199 ----- docs/support.md | 4 - docs/templates/usage.template | 6 - docs/usage.md | 65 -- docs/validating.md | 9 - 12 files changed, 1792 deletions(-) delete mode 100644 docs/.gitignore delete mode 100644 docs/README.md delete mode 100644 docs/changelog.md delete mode 100644 docs/contributing.md delete mode 100644 docs/hello-world.md delete mode 100644 docs/img/format_convert.svg delete mode 100644 docs/initialize.md delete mode 100644 docs/models.md delete mode 100644 docs/support.md delete mode 100644 docs/templates/usage.template delete mode 100644 docs/usage.md delete mode 100644 docs/validating.md diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 64beb194..00000000 --- a/docs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -autodoc_build/ diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 5c7dab21..00000000 --- a/docs/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# peppy - -## Introduction - -`peppy` is a Python package that provides an API for handling standardized project and sample metadata. -If you define your project in [Portable Encapsulated Project](http://pep.databio.org/en/2.0.0/) (PEP) format, -you can use the `peppy` package to instantiate an in-memory representation of your project and sample metadata. -You can then use `peppy` for interactive analysis, or to develop Python tools so you don't have to handle sample processing. `peppy` is useful to tool developers and data analysts who want a standard way of representing sample-intensive research project metadata. - -## What is a PEP? - -A [PEP](http://pep.databio.org/en/2.0.0/) is a collection of metadata files conforming to a standardized structure. -These files are written using the simple **YAML** and **TSV/CSV** formats, -and they can be read by a variety of tools in the pep toolkit, including `peppy`. If you don't already understand why the PEP concept is useful to you, -start by reading the [PEP specification](http://pep.databio.org/en/2.0.0/), -where you can also find example projects. - -## Why use `peppy`? - -`peppy` provides an API with which to interact from Python with PEP metadata. -This is often useful on its own, but the big wins include: - -- *Portability* between computing environments -- *Reusability* among different tools and project stages -- *Durability* with respect to data movement - -## Who should use `peppy`? - -There are **two main kinds of user** that may have interest: - -- A tool *developer* -- A data *analyst* - -If you neither of those describes you, you may be interested in [`pepr`](http://code.databio.org/pepr) (R package), -which provides an R interface to PEP objects, or [looper](http://github.com/pepkit/looper) (command-line application), -which lets you run any command-line tool or pipeline on samples in a project. - -**Developer** - -As a tool developer, you should `import peppy` in your Python tool and read PEP projects as its input. - -This will simplify use of your tool, because users may already have PEP-formatted projects for other tools. - -**Analyst** - -`peppy` provides an easy way to read project metadata into Python. -You will have access to an API to access samples and their attributes, facilitating downstream analysis. diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index 2c675114..00000000 --- a/docs/changelog.md +++ /dev/null @@ -1,34 +0,0 @@ -# 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. - - -### Added - -### Fixed - - - - - - -### Fixed - -### Added - - -### Added - - -### Added - - -### Added - -### Added - -### Fixed - -### Fixed - -### Added diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index 41916b5b..00000000 --- a/docs/contributing.md +++ /dev/null @@ -1,13 +0,0 @@ -# Contributing - -Pull requests are welcome. - -After adding tests in `tests` for a new feature or a bug fix, please run the test suite. -To do so, the only additional dependencies (beyond those needed for the package itself) can be -installed with: - -```{bash} -pip install -r requirements/requirements-dev.txt -``` - -Once those are installed, the tests can be run with `pytest`. Alternatively, `python setup.py test` can be used. diff --git a/docs/hello-world.md b/docs/hello-world.md deleted file mode 100644 index 9ba73230..00000000 --- a/docs/hello-world.md +++ /dev/null @@ -1,48 +0,0 @@ -# Installation and Hello, World! - -## Installation - -With `pip` you can install the [latest release from PyPI](https://pypi.python.org/pypi/peppy): - -```bash -pip install --user peppy -``` - -Update `peppy` with `pip`: - -```bash -pip install --user --upgrade peppy -``` - -Releases and development versions may also be installed from the [GitHub releases](https://github.com/pepkit/peppy/releases): - -```bash -pip install --user https://github.com/pepkit/peppy/zipball/master -``` - - -## Hello world! - -Now, to test `peppy`, let's grab an clone an example project that follows PEP format. -We've produced a bunch of example PEPs in the [`example_peps` repository](https://github.com/pepkit/example_peps). -Let's clone it: - -```bash -git clone https://github.com/pepkit/example_peps.git -``` - -Then, from within the `example_peps` folder, enter the following commands in a Python session: - -```python -import peppy - -project = peppy.Project("example_basic/project_config.yaml") # instantiate in-memory Project representation -samples = project.samples # grab the list of Sample objects defined in this Project - -# Find the input file for the first sample in the project -samples[0]["file"] -``` - -That's it! You've got `peppy` running on an example project. -Now you can play around with project metadata from within python. -There are lots of other ways to initialize a project, which we will in the next section. diff --git a/docs/img/format_convert.svg b/docs/img/format_convert.svg deleted file mode 100644 index bc08ce4e..00000000 --- a/docs/img/format_convert.svg +++ /dev/null @@ -1,1275 +0,0 @@ - - - -PEPpeppy.ProjectDataFramesamplesYAMLInput formatsOutput formatsCSVURLCSVDataFramesamplesYAMLPEP diff --git a/docs/initialize.md b/docs/initialize.md deleted file mode 100644 index f8d74632..00000000 --- a/docs/initialize.md +++ /dev/null @@ -1,91 +0,0 @@ -# How to initiate peppy using different methods - -The primary use case of `peppy` is to create a `peppy.Project` object, which will give you an API for interacting with your project and sample metadata. There are multiple ways to instantiate a `peppy.Project`. -The most common is to use a configuration file; however, you can also use a `CSV` file (sample sheet), or a sample `YAML` file (sample sheet), or use Python objects directly, such as a `pandas` DataFrame, or a Python `dict`. - - -
- -
peppy can read from and produce various metadata formats
-
- - -## 1. From PEP configuration file - -```python -import peppy -project = peppy.Project.from_pep_config("path/to/project/config.yaml") -``` - -## 2. FROM `CSV` file (sample sheet) - -```python -import peppy -project = peppy.Project.from_pep_config("path/to/project/sample_sheet.csv") -``` - -You can also instantiate directly from a URL to a CSV file: - -```python -import peppy -project = peppy.Project("https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/sample_table.csv") -``` - - -## 3. From `YAML` sample sheet - -```python -import peppy - -project = peppy.Project.from_sample_yaml("path/to/project/sample_sheet.yaml") -``` - - -## 4. From a `pandas` DataFrame - -```python -import pandas as pd -import peppy -df = pd.read_csv("path/to/project/sample_sheet.csv") -project = peppy.Project.from_pandas(df) -``` - -## 5. From a `peppy`-generated `dict` - -Store a `peppy.Project` object as a dict using `prj.to_dict()`. Then, load it with `Project.from_dict()`: - -```python -import peppy - -project = peppy.Project("https://raw.githubusercontent.com/pepkit/example_peps/master/example_basic/sample_table.csv") -project_dict = project.to_dict(extended=True) -project_copy = peppy.Project.from_dict(project_dict) - -# now you can check if this project is the same as the original project -print(project_copy == project) -``` - -Or, you could generate an equivalent dictionary in some other way: - - -```python -import peppy -project = peppy.Project.from_dict( - {'_config': {'description': None, - 'name': 'example_basic', - 'pep_version': '2.0.0', - 'sample_table': 'sample_table.csv',}, - '_sample_dict': [{'organism': 'pig', 'sample_name': 'pig_0h', 'time': '0'}, - {'organism': 'pig', 'sample_name': 'pig_1h', 'time': '1'}, - {'organism': 'frog', 'sample_name': 'frog_0h', 'time': '0'}, - {'organism': 'frog', 'sample_name': 'frog_1h', 'time': '1'}], - '_subsample_list': [[{'read1': 'frog1a_data.txt', - 'read2': 'frog1a_data2.txt', - 'sample_name': 'frog_0h'}, - {'read1': 'frog1b_data.txt', - 'read2': 'frog1b_data2.txt', - 'sample_name': 'pig_0h'}, - {'read1': 'frog1c_data.txt', - 'read2': 'frog1b_data2.txt', - 'sample_name': 'pig_0h'}]]}) -``` diff --git a/docs/models.md b/docs/models.md deleted file mode 100644 index 5f4628d5..00000000 --- a/docs/models.md +++ /dev/null @@ -1,199 +0,0 @@ -# Project models - -`peppy` models projects and samples as Python objects. - -```python -import peppy - -my_project = peppy.Project("path/to/project_config.yaml") -my_samples = my_project.samples -``` - -Once you have your project and samples in your Python session, the possibilities are endless. For example, one way we use these objects is for post-pipeline processing. After we use looper to run each sample through its pipeline, we can load the project and it sample objects into an analysis session, where we do comparisons across samples. - -**Exploration:** - -To interact with the various `models` and become acquainted with their -features and behavior, there is a lightweight module that provides small -working versions of a couple of the core objects. Specifically, from -within the `tests` directory, the Python code in the `tests.interactive` -module can be copied and pasted into an interpreter. This provides a -`Project` instance called `proj` and a `PipelineInterface` instance -called `pi`. Additionally, this provides logging information in great detail, -affording visibility into some what's happening as the `models` are created -and used. - - -## Extending sample objects - -By default we use *generic* models (see [API docs](autodoc_build/peppy.md) for more) that can be used in many contexts -via Python import, or by object serialization and deserialization via YAML. - -Since these models provide useful methods to store, update, and read attributes in the objects created from them -(most notably a *sample* - `Sample` object), a frequent use case is during the run of a pipeline. -A pipeline can create a more custom `Sample` model, adding or altering properties and methods. - -### Use case - -You have several samples, of different experiment types, -each yielding different varieties of data and files. For each sample of a given -experiment type that uses a particular pipeline, the set of file path types -that are relevant for the initial pipeline processing or for downstream -analysis is known. For instance, a peak file with a certain genomic location -will likely be relevant for a ChIP-seq sample, while a transcript -abundance/quantification file will probably be used when working with a RNA-seq -sample. This common situation, in which one or more file types are specific -to a pipeline and analysis both benefits from and is amenable to a bespoke -`Sample` *type*. - -Rather than working with a base `Sample` instance and -repeatedly specifying paths to relevant files, those locations can be provided -just once, stored in an instance of the custom `Sample` *type*, and later -used or modified as needed by referencing a named attribute on the object. -This approach can dramatically reduce the number of times that a full filepath -must be precisely typed, improving pipeline readability and accuracy. - -### Mechanics - -It's the specification of *both an experiment or data type* ("library" or -"protocol") *and a pipeline with which to process that input type* that -`looper` uses to determine which type of `Sample` object(s) to create for -pipeline processing and analysis (i.e., which `Sample` extension to use). -There's a pair of symmetric reasons for this--the relationship between input -type and pipeline can be one-to-many, in both directions. That is, it's -possible for a single pipeline to process more than one input type, and a -single input type may be processed by more than one pipeline. - -There are a few different `Sample` extension scenarios. Most basic is the -one in which an extension, or *subtype*, is neither defined nor needed--the -pipeline author does not provide one, and users do not request one. Almost -equally effortless on the user side is the case in which a pipeline author -intends for a single subtype to be used with her pipeline. In this situation, -the pipeline author simply implements the subtype within the pipeline module, -and nothing further is required--of the pipeline author or of a user! The -`Sample` subtype will be found within the pipeline module, and the inference -will be made that it's intended to be used as the fundamental representation -of a sample within that pipeline. - -If a pipeline author extends the base`Sample` type in the pipeline module, it's -likely that the pipeline's proper functionality depends on the use of that subtype. -In some cases, though, it may be desirable to use the base `Sample` type even if -the pipeline author has provided a more customized version with the pipeline. -To favor the base `Sample` over the tailored one created by a pipeline author, -the user may simply set `sample_subtypes` to `null` in an altered version of the pipeline -interface, either for all types of inpute to that pipeline, or just a subset. - - -```python -# atacseq.py - -import os -from peppy import Sample - -class ATACseqSample(Sample): - """ - Class to model ATAC-seq samples based on the generic Sample class. - - :param pandas.Series series: data defining the Sample - """ - - def __init__(self, series): - if not isinstance(series, pd.Series): - raise TypeError("Provided object is not a pandas Series.") - super(ATACseqSample, self).__init__(series) - self.make_sample_dirs() - - def set_file_paths(self, project=None): - """Sets the paths of all files for this sample.""" - # Inherit paths from Sample by running Sample's set_file_paths() - super(ATACseqSample, self).set_file_paths(project) - - self.fastqc = os.path.join(self.paths.sample_root, self.name + ".fastqc.zip") - self.trimlog = os.path.join(self.paths.sample_root, self.name + ".trimlog.txt") - self.fastq = os.path.join(self.paths.sample_root, self.name + ".fastq") - self.trimmed = os.path.join(self.paths.sample_root, self.name + ".trimmed.fastq") - self.mapped = os.path.join(self.paths.sample_root, self.name + ".bowtie2.bam") - self.peaks = os.path.join(self.paths.sample_root, self.name + "_peaks.bed") -``` - - -To leverage the power of a `Sample` subtype, the relevant model is the -`PipelineInterface`. For each pipeline defined in the `pipelines` section -of `pipeline_interface.yaml`, there's accommodation for a `sample_subtypes` -subsection to communicate this information. The value for each such key may be -either a single string or a collection of key-value pairs. If it's a single -string, the value is the name of the class that's to be used as the template -for each `Sample` object created for processing by that pipeline. If instead -it's a collection of key-value pairs, the keys should be names of input data -types (as in the `protocol_mapping`), and each value is the name of the class -that should be used for each sample object of the corresponding key*for that -pipeline*. This underscores that it's the ***combination** of a pipeline and input -type* that determines the subtype. - - -```yaml -# Content of pipeline_interface.yaml - -protocol_mapping: - ATAC: atacseq.py - -pipelines: - atacseq.py: - ... - ... - sample_subtypes: ATACseqSample - ... - ... - ... - ... -``` - - -If a pipeline author provides more than one subtype, the `sample_subtypes` -section is needed to select from among them once it's time to create -`Sample` objects. If multiple options are available, and the -`sample_subtypes` section fails to clarify the decision, the base/generic -type will be used. The responsibility for supplying the `sample_subtypes` -section, as is true for the rest of the pipeline interface, therefore rests -primarily with the pipeline developer. It is possible for an end user to -modify these settings, though. - -Since the mechanism for subtype detection is `inspect`-ion of each of the -pipeline module's classes and retention of those which satisfy a subclass -status check against `Sample`, it's possible for pipeline authors to -implement a class hierarchy with multi-hop inheritance relationships. For -example, consider the addition of the following class to the previous example -of a pipeline module `atacseq.py`: - - -```python -class DNaseSample(ATACseqSample): - ... -``` - -In this case there are now two `Sample` subtypes available, and more -generally, there will necessarily be multiple subtypes available in any -pipeline module that uses a subtype scheme with multiple, serial inheritance -steps. In such cases, the pipeline interface should include an unambiguous -`sample_subtypes` section. - - -```yaml -# Content of pipeline_interface.yaml - -protocol_mapping: - ATAC: atacseq.py - DNase: atacseq.py - -pipelines: - atacseq.py: - ... - ... - sample_subtypes: - ATAC: ATACseqSample - DNase: DNaseSample - ... - ... - ... - ... -``` diff --git a/docs/support.md b/docs/support.md deleted file mode 100644 index f185391d..00000000 --- a/docs/support.md +++ /dev/null @@ -1,4 +0,0 @@ -# Support - -Please use the issue tracker at GitHub to file bug reports or feature requests -on the [project's issues page](https://github.com/pepkit/peppy/issues). diff --git a/docs/templates/usage.template b/docs/templates/usage.template deleted file mode 100644 index c7211be4..00000000 --- a/docs/templates/usage.template +++ /dev/null @@ -1,6 +0,0 @@ -# Usage reference - -pephubclient is a command line tool that can be used to interact with the PEPhub API. -It can be used to create, update, delete PEPs in the PEPhub database. - -Below are usage examples for the different commands that can be used with pephubclient. \ No newline at end of file diff --git a/docs/usage.md b/docs/usage.md deleted file mode 100644 index 6fd05bdc..00000000 --- a/docs/usage.md +++ /dev/null @@ -1,65 +0,0 @@ -# Usage reference - -pephubclient is a command line tool that can be used to interact with the PEPhub API. -It can be used to create, update, delete PEPs in the PEPhub database. - -Below are usage examples for the different commands that can be used with pephubclient.## `phc --help` -```console - - Usage: pephubclient [OPTIONS] COMMAND [ARGS]... - -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --version -v App version │ -│ --install-completion [bash|zsh|fish|powershell|pwsh] Install completion for the specified shell. [default: None] │ -│ --show-completion [bash|zsh|fish|powershell|pwsh] Show completion for the specified shell, to copy it or customize the installation. [default: None] │ -│ --help Show this message and exit. │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ login Login to PEPhub │ -│ logout Logout │ -│ pull Download and save project locally. │ -│ push Upload/update project in PEPhub │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - -``` - -## `phc pull --help` -```console - - Usage: pephubclient pull [OPTIONS] PROJECT_REGISTRY_PATH - - Download and save project locally. - -╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * project_registry_path TEXT [default: None] [required] │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --force --no-force Overwrite project if it exists. [default: no-force] │ -│ --zip --no-zip Save project as zip file. [default: no-zip] │ -│ --output TEXT Output directory. [default: None] │ -│ --help Show this message and exit. │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - -``` - -## `phc push --help` -```console - - Usage: pephubclient push [OPTIONS] CFG - - Upload/update project in PEPhub - -╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * cfg TEXT Project config file (YAML) or sample table (CSV/TSV)with one row per sample to constitute project [default: None] [required] │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * --namespace TEXT Project namespace [default: None] [required] │ -│ * --name TEXT Project name [default: None] [required] │ -│ --tag TEXT Project tag [default: None] │ -│ --force --no-force Force push to the database. Use it to update, or upload project. [default: no-force] │ -│ --is-private --no-is-private Upload project as private. [default: no-is-private] │ -│ --help Show this message and exit. │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - -``` - diff --git a/docs/validating.md b/docs/validating.md deleted file mode 100644 index 82ba0c5c..00000000 --- a/docs/validating.md +++ /dev/null @@ -1,9 +0,0 @@ -# How to validate a PEP - -Starting with version `0.30.0`, peppy now includes a powerful validation framework. We provide a schema for the basic PEP specification, so you can validate that a PEP fills that spec. Then, you can also write an extended schema to validate a pep for a specific analysis. All of the PEP validation functionality is handled by a separate package called `eido`. You can read more in the eido documentation, including: - -- How to validate a PEP against the generic PEP format -- How to validate a PEP against a custom schema -- How to write your own custom schema - -See the [eido documentation](http://eido.databio.org/) for further detail. From d0f7bf42680ec6a724cc29e39f6828bdd21f8c9c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 13:51:47 -0500 Subject: [PATCH 162/165] fix README that was accidentally emptied --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 80b7fb54..98b99dc2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,15 @@ +# peppy python package + +![Run pytests](https://github.com/pepkit/peppy/workflows/Run%20pytests/badge.svg) +[![codecov](https://codecov.io/gh/pepkit/peppy/branch/master/graph/badge.svg)](https://codecov.io/gh/pepkit/peppy) +[![PEP compatible](https://pepkit.github.io/img/PEP-compatible-green.svg)](https://pep.databio.org) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +`peppy` is the official python package for reading **Portable Encapsulated Projects** or **PEP**s in Python. +Links to complete documentation: +* Complete documentation and API for the `peppy` python package is at [peppy.databio.org](https://peppy.databio.org). +* Reference documentation for standard **PEP** format is at [pep.databio.org](https://pep.databio.org/). +* Example PEPs for testing `peppy` are in the [example_peps repository](https://github.com/pepkit/example_peps). +* The package [on PyPI](https://pypi.org/project/peppy/). \ No newline at end of file From 66d4abcd45ece8bc8b3c25c6fd1e1e60757cc671 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 14:09:39 -0500 Subject: [PATCH 163/165] mkdocs no longer needed since docs relocated to pepspec --- mkdocs.yml | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 mkdocs.yml diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index c2989c63..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,31 +0,0 @@ -site_name: Peppy -site_url: http://peppy.databio.org/ -repo_url: http://github.com/pepkit/peppy -pypi_name: peppy - -nav: - - Getting started: - - Introduction: README.md - - Installing and Hello World: hello-world.md - - How-to Guides: - - How to initialize a Project: initialize.md - - How to use peppy: tutorial.md - - How to use subsample table: feature4_subsample_table.md - - How to use amendments: feature5_amend.md - - How to use append sample modifier: feature1_append.md - - How to use imply sample modifier: feature2_imply.md - - How to validate a PEP: validating.md - - Reference: - - API: autodoc_build/peppy.md - - Support: support.md - - Contributing: contributing.md - - Changelog: changelog.md - -theme: databio - -plugins: - - databio: - autodoc_build: "docs/autodoc_build" - autodoc_package: "peppy" - no_top_level: true - - search From 77440b7cfee3cca8b9c63328a3d2039006787792 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 14:17:41 -0500 Subject: [PATCH 164/165] readthedocs also unnecessary --- .readthedocs.yaml | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml deleted file mode 100644 index 3c789e4c..00000000 --- a/.readthedocs.yaml +++ /dev/null @@ -1,15 +0,0 @@ -version: 2 - -build: - os: ubuntu-22.04 - tools: - python: "3.10" - -mkdocs: - configuration: mkdocs.yml - fail_on_warning: false - -# Optionally declare the Python requirements required to build your docs -python: - install: - - requirements: requirements/requirements-doc.txt From c0b4e7bac36a9cd69d403bae2ca5247c6e9fb610 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 14:34:36 -0500 Subject: [PATCH 165/165] adjust test data path --- update_test_data.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/update_test_data.sh b/update_test_data.sh index afca3415..615342b6 100755 --- a/update_test_data.sh +++ b/update_test_data.sh @@ -8,8 +8,8 @@ fi branch=$1 wget https://github.com/pepkit/example_peps/archive/${branch}.zip -mv ${branch}.zip tests/data/ -cd tests/data/ +mv ${branch}.zip tests/data/peppydata/ +cd tests/data/peppydata/ rm -rf example_peps-${branch} unzip ${branch}.zip rm ${branch}.zip