diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cf0ddf1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +--- +name: continuous-integration + +on: [push, pull_request] + +jobs: + + pre-commit: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.10 + uses: actions/setup-python@v2 + with: + python-version: '3.10' + - uses: pre-commit/action@v2.0.0 + + test-package: + + needs: [pre-commit] + + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + fail-fast: false + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] + + steps: + + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + submodules: true + + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package + run: | + which python + python -m pip install -e .[tests] + python -m pip freeze + + - name: Run tests + run: python -m pytest -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a7a6f01 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +--- +name: Release + +on: + push: + tags: + - v* + branches: + - release/* + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + + - uses: actions/checkout@v2 + + - name: Set up Python 3.10 + uses: actions/setup-python@v2 + with: + python-version: '3.10' + + - name: Install pypa/build + run: python -m pip install build + + - name: Build a binary wheel and a source tarball + run: >- + python -m + build + --sdist + --wheel + --outdir dist/ + + - name: Upload distribution artifact + uses: actions/upload-artifact@v2 + with: + name: release + path: dist/ + + publish: + + needs: [build] + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@v2 + name: Download distribution artifact + with: + name: release + path: dist/ + + - name: Publish distribution on Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + if: startsWith(github.ref, 'refs/heads/release/') + with: + user: __token__ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + skip_existing: true + + - uses: softprops/action-gh-release@v0.1.14 + name: Create release + if: startsWith(github.ref, 'refs/tags/v') + with: + files: | + dist/* + generate_release_notes: true + + - name: Publish distribution on PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + if: startsWith(github.ref, 'refs/tags/v') + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59d4592 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.egg-info +*.swp +__pycache__/ +build/ +dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bdd8f40 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +--- +repos: + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: check-json + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt + rev: 0.1.0 + hooks: + - id: yamlfmt + + - repo: https://github.com/mgedmin/check-manifest + rev: '0.44' + hooks: + - id: check-manifest + + - repo: https://github.com/psf/black + rev: 22.1.0 + hooks: + - id: black + + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 + args: [--count, --show-source, --statistics] + additional_dependencies: [flake8-bugbear==21.3.1] + + - repo: https://github.com/asottile/setup-cfg-fmt + rev: v1.20.0 + hooks: + - id: setup-cfg-fmt + + - repo: https://github.com/pycqa/isort + rev: 5.9.3 + hooks: + - id: isort + args: [--profile, black, --filter-files] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..65f2292 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Carl Simon Adorf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e591242 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +exclude tests +recursive-exclude tests * + +exclude .pre-commit-config.yaml +exclude .gitlab-ci.yml + +include LICENSE +include README.md +include example.py +recursive-include logos *.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..b510098 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# MarketPlace SDK + +Python Software Development Toolkit (SDK) to communicate with the Materials MarketPlace platform. + +## Installation + +To install the package, execute: + +```console +pip install git+https://github.com/materials-marketplace/python-sdk.git +``` + +## Usage +The [MarketPlace documentation](https://materials-marketplace.readthedocs.io/en/latest/) contains [a tutorial](https://materials-marketplace.readthedocs.io/en/latest/jupyter/sdk.html) on how to configure and use this package. + +## Authors + +* **Carl Simon Adorf (EPFL)** - [@csadorf](https://github.com/csadorf) +* **Pablo de Andres (Fraunhofer IWM)** - [@pablo-de-andres](https://github.com/pablo-de-andres) +* **Pranjali Singh (Fraunhofer IWM)** - [@singhpranjali](https://github.com/singhpranjali) + +See also the list of [contributors](https://github.com/materials-marketplace/python-sdk/contributors). + +## Contact +- simon.adorf@epfl.ch +- pablo.de.andres@iwm.fraunhofer.de +- pranjali.singh@iwm.fraunhofer.de + +## For maintainers + +To create a new release, clone the repository, install development dependencies with `pip install -e '.[dev]'`, and then execute `bumpver update --[major|minor|patch]`. +This will: + + 1. Create a tagged release with bumped version and push it to the repository. + 2. Trigger a GitLab CI workflow that publishes the package on the [GitLab package registry](https://gitlab.cc-asp.fraunhofer.de/MarketPlace/python-sdk/-/packages). + 2. Trigger a GitHub actions workflow that creates a GitHub release and publishes it on PyPI. + +Additional notes: + + - The project follows semantic versioning. + - Use the `--dry` option to preview the release change. + - The release tag (e.g. a/b/rc) is determined from the last release. + Use the `--tag` option to switch the release tag. + +## MIT License + +Copyright (c) 2021 Carl Simon Adorf (EPFL) + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Acknowledgements + +This work is supported by the +[MARVEL National Centre for Competency in Research]() funded by the [Swiss National Science Foundation](), +and the MarketPlace project funded by [Horizon 2020](https://ec.europa.eu/programmes/horizon2020/) under the H2020-NMBP-25-2017 call (Grant No. 760173). + +
+ MARVEL + MarketPlace +
diff --git a/example.py b/example.py new file mode 100644 index 0000000..1742a1a --- /dev/null +++ b/example.py @@ -0,0 +1,28 @@ +"""Simple script illustrating some of the features of this package.""" +from pprint import pprint + +from marketplace.app.marketplace_app import MarketPlaceApp +from marketplace.core import MarketPlaceClient + +# General MarketPlaceClient for simple requests like user info +# Remember to save your access token in an environment variable with +# export MP_ACCESS_TOKEN="" +mp_client = MarketPlaceClient() +# Show the user information +pprint(mp_client.userinfo) + + +# To simply instantiate a MarketPlaceApp with a client id +mp = MarketPlaceApp(client_id="") +print(mp.heartbeat()) + + +# To extend the MarketPlaceApp with custom implementations +class MyMarketPlaceApp(MarketPlaceApp): + def heartbeat(self) -> str: + res = super().heartbeat() + return f"heartbeat response: {res}" + + +my_mp_app = MyMarketPlaceApp(client_id="") +print(my_mp_app.heartbeat()) diff --git a/logos/MARVEL.png b/logos/MARVEL.png new file mode 100644 index 0000000..d17a6ed Binary files /dev/null and b/logos/MARVEL.png differ diff --git a/logos/MarketPlace.png b/logos/MarketPlace.png new file mode 100644 index 0000000..450146b Binary files /dev/null and b/logos/MarketPlace.png differ diff --git a/marketplace/__init__.py b/marketplace/__init__.py new file mode 100644 index 0000000..fc9b4eb --- /dev/null +++ b/marketplace/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Software Development Toolkit to communicate with the Materials MarketPlace +platform. + +.. currentmodule:: marketplace +.. moduleauthor:: Carl Simon Adorf +""" + +from .core import MarketPlaceClient +from .version import __version__ + +__all__ = [ + "MarketPlaceClient", + "__version__", +] diff --git a/marketplace/app/__init__.py b/marketplace/app/__init__.py new file mode 100644 index 0000000..d4f5ef8 --- /dev/null +++ b/marketplace/app/__init__.py @@ -0,0 +1,10 @@ +"""Module for handling the different types of MarketPlace apps and their +capabilities. +.. currentmodule:: marketplace.app +.. moduleauthor:: Pablo de Andres, Pranjali Singh (Fraunhofer IWM) +""" +from marketplace.app.marketplace_app import MarketPlaceApp + +__all__ = [ + "MarketPlaceApp", +] diff --git a/marketplace/app/data_sink_app.py b/marketplace/app/data_sink_app.py new file mode 100644 index 0000000..064a7c6 --- /dev/null +++ b/marketplace/app/data_sink_app.py @@ -0,0 +1,124 @@ +"""This module contains all functionality regarding data sink apps.. + +.. currentmodule:: marketplace.app.data_sink_app +.. moduleauthor:: Pablo de Andres, Pranjali Singh (Fraunhofer IWM) +""" +from typing import Dict, Union + +from marketplace.app.utils import check_capability_availability +from marketplace.core import MarketPlaceClient + + +class DataSinkApp(MarketPlaceClient): + """General data sink app with all the supported capabilities.""" + + @check_capability_availability + def create_dataset(self, config: Dict) -> str: + """Store a dataset. + + Args: + config (Dict): data payload + + Returns: + str: resourceId of the created dataset + """ + return self.post(path="createDataset", json=config).text + + @check_capability_availability + def create_cuds_dataset(self, config: Dict) -> Union[Dict, str]: + """Store a CUDS dataset + Args: + config (Dict): creation data + + Returns: + Dict: response object + """ + return self.post(path="createCudsDataset", json=config).text + + @check_capability_availability + def create_collection(self, config: Dict) -> str: + """Create a collection (used for workflows). + + Returns: + str: response string (success/error) + """ + return self.post(path="createCollection", json=config).text + + @check_capability_availability + def create_dataset_from_URI(self, uri: str) -> str: + """Store a dataset by fetching the data from a URI. + + Args: + uri (str): URI of the location to fetch data + + Returns: + str: resourceId of the created dataset + """ + return self.post(path="createDatasetFromURI", data=uri).text + + @check_capability_availability + def update_dataset(self, resourceId: str, config: Dict, **kwargs) -> str: + """Upload a new dataset to replace an existing one. + + Args: + resourceId (str): id of the dataset + config (Dict): update data + + Returns: + str: response string (success/error) + """ + params = {"resourceId": resourceId, **kwargs} + return self.put(path="updateDataset", params=params, json=config).text + + @check_capability_availability + def update_cuds_dataset(self, resourceId: str, config: Dict, **kwargs) -> str: + """Upload a new CUDS dataset to replace an existing one. + + Args: + resourceId (str): id of the CUDS dataset + config (Dict): update data + + Returns: + str: response string (success/error) + """ + params = {"resourceId": resourceId, **kwargs} + return self.put(path="updateCudsDatset", params=params, json=config).text + + @check_capability_availability + def update_dataset_from_URI(self, resourceId: str, uri: str, **kwargs) -> str: + """Update a dataset by fetching the data from a URI. + + Args: + resourceId (str): id of the dataset + uri (str): location of the data + + Returns: + str: response string (success/error) + """ + params = {"resourceId": resourceId, **kwargs} + return self.post(path="updateDatasetFromURI", params=params, data=uri).text + + @check_capability_availability + def delete_dataset(self, resourceId: str, **kwargs) -> str: + """Delete a dataset. + + Args: + resourceId (str): id of the dataset + Returns: + str: response string (success/error) + """ + params = {"resourceId": resourceId, **kwargs} + return self.delete(path="deleteDataset", params=params).text + + @check_capability_availability + def delete_cuds_dataset(self, resourceId: str, **kwargs) -> str: + """Delete a CUDS dataset. + + Args: + resourceId (str): id of the CUDS dataset + + Returns: + str: response string (success/error) + """ + params = {"resourceId": resourceId, **kwargs} + return self.delete(path="deleteCudsDataset", params=params).text diff --git a/marketplace/app/data_source_app.py b/marketplace/app/data_source_app.py new file mode 100644 index 0000000..a92dcbc --- /dev/null +++ b/marketplace/app/data_source_app.py @@ -0,0 +1,157 @@ +"""This module contains all functionality regarding data source apps.. + +.. currentmodule:: marketplace.app.data_source_app +.. moduleauthor:: Pablo de Andres, Pranjali Singh (Fraunhofer IWM) +""" + +from typing import Dict, List, Union + +from marketplace.app.utils import check_capability_availability +from marketplace.core import MarketPlaceClient + + +class DataSourceApp(MarketPlaceClient): + """General data source app with all the supported capabilities.""" + + @check_capability_availability + def get_collection(self) -> List: + """Fetches list of datasets. + + Returns: + List: [list of dataset names] + """ + return self.get(path="getCollection").json() + + @check_capability_availability + def get_cuds_collection(self) -> Union[Dict, str]: + """Fetches list of CUDS datasets. + + Returns: + str: [description] + """ + return self.get(path="getCudsCollection").text + + @check_capability_availability + def get_dataset(self, resource_id: str, **kwargs) -> Union[Dict, str]: + """Fetches a particular Dataset. + + Args: + resource_id (str): [id of dataset] + + Returns: + Dict: [json response object as Dict] + """ + return self.get( + path="getDataset", params={"resourceId": resource_id, **kwargs} + ).json() + + @check_capability_availability + def get_cuds_dataset(self, resource_id: str, **kwargs) -> Union[Dict, str]: + """Fetches a particular CUDS Dataset. + + Args: + resource_id (str): id of CUDS dataset + + Returns: + Dict: json response + """ + params = {"resourceId": resource_id, **kwargs} + return self.get(path="getCudsDataset", params=params).json() + + @check_capability_availability + def get_metadata(self, datatype: str, **kwargs) -> Union[Dict, str]: + """Fetch information about certain sets of data. + + Args: + datatype (str): datatype of metadata + + Returns: json response + """ + params = {"datatype": datatype, **kwargs} + return self.get(path="getMetadata", params=params).json() + + @check_capability_availability + def query_dataset(self, resource_id: str, query: str, **kwargs) -> Union[Dict, str]: + """Execute search query on datasets. + + Args: + resource_id (str): id of dataset to query on + query (str): query + + Returns: + Dict: json response object + """ + params = {"resourceId": resource_id, "query": query, **kwargs} + return self.get(path="queryDataset", params=params).json() + + @check_capability_availability + def post_query_dataset( + self, schema_id: str, config: Dict, **kwargs + ) -> Union[Dict, str]: + """Execute search query on datasets. + + Args: + schema_id (str): id of schema + config (Dict): json to post on schema + + Returns: + Dict: json response + """ + params = {"schema_id": schema_id, **kwargs} + return self.post(path="postQueryDataset", params=params, json=config).json() + + @check_capability_availability + def export_dataset_with_attributes( + self, schema_id: str, config: Dict, **kwargs + ) -> Union[Dict, str]: + """Export data with attribute values of datasets. + + Args: + schema_id (str): id of schema (similar to datasetId) + config (Dict): Export data request + + Returns: + Dict: json response + """ + params = {"schema_id": schema_id, **kwargs} + return self.post( + path="exportDatasetWithAttributes", params=params, json=config + ).json() + + @check_capability_availability + def get_dataset_attributes(self, schema_id: str, **kwargs) -> Union[Dict, str]: + """List attributes included in specified dataset. + + Args: + schema_id (str): Schema ID (similar to datasetId) + + Returns: + Dict: json response object + """ + params = {"schema_id": schema_id, **kwargs} + return self.get(path="getDatasetAttributes", params=params).json() + + @check_capability_availability + def query_collection(self, query: str, **kwargs) -> Union[Dict, str]: + """Query a collection. + + Args: + query (str): query to execute + """ + params = {"query": query, **kwargs} + return self.get(path="queryCollection", params=params).text + + def post_query_collection( + self, query: str, config: Dict, **kwargs + ) -> Union[Dict, str]: + """Query a collection(Post for GraphQL) + + Args: + query (str): query to post + config (Dict): ? TO BE CONFIRMED ? + + Returns: + Dict: json response + """ + params = {"query": query, **kwargs} + return self.post(path="postQueryCollection", params=params, json=config).json() diff --git a/marketplace/app/marketplace_app.py b/marketplace/app/marketplace_app.py new file mode 100644 index 0000000..4daa15a --- /dev/null +++ b/marketplace/app/marketplace_app.py @@ -0,0 +1,49 @@ +"""This module contains all functionality for MarketPlace apps.. + +.. currentmodule:: marketplace.app.marketplace_app +.. moduleauthor:: Pablo de Andres, Pranjali Singh (Fraunhofer IWM) +""" + + +from urllib.parse import urljoin + +from marketplace.app.data_sink_app import DataSinkApp +from marketplace.app.data_source_app import DataSourceApp +from marketplace.app.transformation_app import TransformationApp +from marketplace.app.utils import camel_to_snake, check_capability_availability + + +class MarketPlaceApp(DataSinkApp, DataSourceApp, TransformationApp): + """Base MarketPlace app. + + Includes the heartbeat capability and extends the MarketPlace class + to use the authentication mechanism. + """ + + def __init__(self, client_id, **kwargs): + super().__init__(**kwargs) + self.client_id = client_id + # Must be run before the marketplace_host_url is updated to include the proxy. + self.set_capabilities() + self.marketplace_host_url = urljoin( + self.marketplace_host_url, f"mp-api/proxy/{self.client_id}/" + ) + + def set_capabilities(self): + """Query the platform to get the capabilities supported by a certain + app.""" + app_service_path = f"application-service/applications/{self.client_id}" + response = self.get(path=app_service_path).json() + capability_info = response["capabilities"] + self.capabilities = [] + for capability in capability_info: + self.capabilities.append(camel_to_snake(capability["name"])) + + @check_capability_availability + def heartbeat(self) -> str: + """Check the heartbeat of the application. + + Returns: + str: heartbeat + """ + return self.get(path="heartbeat").text diff --git a/marketplace/app/transformation_app.py b/marketplace/app/transformation_app.py new file mode 100644 index 0000000..c42f1f3 --- /dev/null +++ b/marketplace/app/transformation_app.py @@ -0,0 +1,88 @@ +"""This module contains all functionality regarding transformation apps.. + +.. currentmodule:: marketplace.app.transformation_app +.. moduleauthor:: Pablo de Andres, Pranjali Singh (Fraunhofer IWM) +""" + + +from typing import Dict, List + +from marketplace.app.utils import check_capability_availability +from marketplace.core import MarketPlaceClient + + +class TransformationApp(MarketPlaceClient): + """General transformation app with all the supported capabilities.""" + + @check_capability_availability + def new_transformation(self, config: Dict) -> str: + """Set up a new transformation. + + Args: + config (Dict): Set up configuration + + Returns: + str: uuid of the new transformation + """ + return self.post(path="newTransformation", json=config).text + + @check_capability_availability + def start_transformation(self, transformation_id: str, **kwargs) -> str: + """Start a configured transformation. + + Args: + transformation_id (str): id of the transformation to start + + Returns: + str: Success/Fail message + """ + params = {"transformationId": transformation_id, **kwargs} + return self.post(path="startTransformation", params=params).text + + @check_capability_availability + def stop_transformation(self, transformation_id: str, **kwargs) -> str: + """Stop a running transformation. + + Args: + transformation_id (str): id of the transformation to stop + + Returns: + str: Success/Fail message + """ + params = {"transformationId": transformation_id, **kwargs} + return self.post(path="stopTransformation", params=params).text + + @check_capability_availability + def delete_transformation(self, transformation_id: str, **kwargs) -> str: + """Delete a running transformation. + + Args: + transformation_id (str): id of the transformation to delete + + Returns: + str: Success/Fail message + """ + params = {"transformationId": transformation_id, **kwargs} + return self.post(path="deleteTransformation", params=params).text + + @check_capability_availability + def get_transformation_status(self, transformation_id: str, **kwargs) -> str: + """Get the status of a certain transformation. + + Args: + transformation_id (str): transformation being queried + + Returns: + str: status of the transformation + """ + params = {"transformationId": transformation_id, **kwargs} + return self.get(path="getTransformationStatus", params=params).text + + @check_capability_availability + def get_transformation_list(self) -> List[str]: + """List all the existing transformations. + + Returns: + List[str]: [description] + """ + return self.get(path="getTransformationList").json() diff --git a/marketplace/app/utils.py b/marketplace/app/utils.py new file mode 100644 index 0000000..6857cca --- /dev/null +++ b/marketplace/app/utils.py @@ -0,0 +1,28 @@ +"""This moduke contains utilities. + +.. currentmodule:: marketplace.app.utils +.. moduleauthor:: Pablo de Andres, Pranjali Singh (Fraunhofer IWM) +""" + +import re +from functools import wraps + + +def check_capability_availability(capability): + """Decorator for checking that a certain app supports a given capability. + + Args: + capability (str): capability that should be in capabilities + """ + + @wraps(capability) + def wrapper(instance, *args, **kwargs): + if capability.__name__ not in instance.capabilities: + raise NotImplementedError("The app does not support this capability.") + return capability(instance, *args, **kwargs) + + return wrapper + + +def camel_to_snake(name): + return re.sub(r"(?, + Pablo de Andres +""" + +import os +from urllib.parse import urljoin + +import requests + +from .version import __version__ + + +class MarketPlaceClient: + """Interact with the MarketPlace platform.""" + + def __init__(self, marketplace_host_url=None, access_token=None): + marketplace_host_url = marketplace_host_url or os.environ.get( + "MP_HOST", "https://www.materials-marketplace.eu/" + ) + access_token = access_token or os.environ["MP_ACCESS_TOKEN"] + + self.marketplace_host_url = marketplace_host_url + self.access_token = access_token + + @property + def default_headers(self): + """Generate default headers to be used with every request.""" + return { + "User-Agent": f"MarketPlace Python SDK {__version__}", + "Authorization": f"Bearer {self.access_token}", + } + + @property + def url_userinfo(self): + return ( + f"{self.marketplace_host_url}" + "auth/realms/marketplace/protocol/openid-connect/userinfo" + ) + + @property + def userinfo(self): + userinfo = self.get(self.url_userinfo) + userinfo.raise_for_status() + return userinfo.json() + + def _request(self, op, path, **kwargs): + kwargs.setdefault("headers", {}).update(self.default_headers) + full_url = urljoin(self.marketplace_host_url, path) + response = op(url=full_url, **kwargs) + if response.status_code != 200: + message = ( + f"Querying MarketPlace for {full_url} returned {response.status_code} " + f"because: {response.text}." + "Please check the host, client_id and token validity." + ) + raise RuntimeError(message) + return response + + def get(self, path: str, **kwargs): + return self._request(requests.get, path, **kwargs) + + def post(self, path: str, **kwargs): + return self._request(requests.post, path, **kwargs) + + def put(self, path: str, **kwargs): + return self._request(requests.put, path, **kwargs) + + def delete(self, path: str, **kwargs): + return self._request(requests.delete, path, **kwargs) diff --git a/marketplace/version.py b/marketplace/version.py new file mode 100644 index 0000000..e2af8e6 --- /dev/null +++ b/marketplace/version.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""This module contains project version information. + +.. currentmodule:: marketplace.version +.. moduleauthor:: Carl Simon Adorf +""" + +try: + from dunamai import Version, get_version + + __version__ = Version.from_git().serialize() +except RuntimeError: + __version__ = get_version("marketplace-sdk").serialize() +except ImportError: + __version__ = "v0.1.0" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6f76ed6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,56 @@ +[metadata] +name = marketplace_sdk +version = v0.1.1 +description = Software Development Toolkit to communicate with the Materials MarketPlace platform. +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/materials-marketplace/python-sdk +author = Carl Simon Adorf, Pablo de Andres, Pranjali Singh and the AiiDAlab team +author_email = simon.adorf@epfl.ch, pablo.de.andres@iwm.fraunhofer.de, pranjali.singh@iwm.fraunhofer.de +license = MIT +license_file = LICENSE +classifiers = + Development Status :: 2 - Pre-Alpha + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: Implementation :: CPython + +[options] +packages = find: +install_requires = + requests~=2.26.0 +python_requires = >=3.7 + +[options.extras_require] +dev = + bumpver==2021.1114 + dunamai==1.7.0 +pre_commit = + pre-commit==2.15.0 +tests = + pytest==6.2.5 + +[flake8] +ignore = + E501 # Line length handled by black. + W503 # Line break before binary operator, preferred formatting for black. + E203 # Whitespace before ':', preferred formatting for black. + +[bumpver] +current_version = "v0.1.1" +version_pattern = "vMAJOR.MINOR.PATCH[PYTAGNUM]" +commit_message = "Bump version {old_version} -> {new_version}" +commit = True +tag = True +push = False + +[bumpver:file_patterns] +marketplace/version.py = + __version__ = "{version}" +setup.cfg = + version = {version} diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..59b746b --- /dev/null +++ b/setup.py @@ -0,0 +1,5 @@ +# -*- coding: utf8 -*- +"""This file is required for editable installs of the package.""" +from setuptools import setup + +setup() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..333363f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""".. currentmodule:: conftest. + +.. moduleauthor:: Carl Simon Adorf + +Provide fixtures for all tests. +""" + +import pytest + + +@pytest.fixture +def environment(monkeypatch): + # Full tests will eventually require a local development deployment of the + # MarketPlace platform. + monkeypatch.setenv("MP_HOST", "https://lvh.me") + # For now, we are not providing a mock marketplace, all tests will + # therefore be runnable with empty tokens. + monkeypatch.setenv("MP_ACCESS_TOKEN", "") + monkeypatch.setenv("MP_REFRESH_TOKEN", "") diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..10dcc3a --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""".. currentmodule:: test_core. + +.. moduleauthor:: Carl Simon Adorf +""" +import pytest + +from marketplace.core import MarketPlaceClient + + +@pytest.fixture +def marketplace(environment): + instance = MarketPlaceClient() + yield instance + + +def test_constructor(marketplace): + pass + + +def test_default_headers(marketplace): + headers = marketplace.default_headers + assert len(headers) >= 2 + for key in ("User-Agent", "Authorization"): + assert key in headers