diff --git a/eossr/api/apizenodo.py b/eossr/api/apizenodo.py new file mode 100644 index 0000000000000000000000000000000000000000..7eea1f13bca82099fd5521a3e58e010f45f39a3e --- /dev/null +++ b/eossr/api/apizenodo.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python + +import sys +import json +import pprint +import requests +from os.path import abspath +from pathlib import Path +from ..metadata.codemeta2zenodo import parse_codemeta_and_write_zenodo_metadata_file + + +class ZenodoAPI: + def __init__(self, access_token, sandbox=True, proj_root_dir='./'): + """ + Manages the communication with the (sandbox.)zenodo REST API through the Python request library. + This class is **EXCLUSIVELY** developed to be used within a CI/CD pipeline and to **EXCLUSIVELY PERFORM** + the following tasks within the (sandbox.)zenodo api environment: + + - Fetches a user's published entries, + - Creates a new deposit, + - Fetches any published record, + - Creates a new version of an existing deposit, + - Uploads files to a specific Zenodo entry, + - Erases a non-published entry / new version draft, + - Erases (old version) files from an entry (when creating a new_version entry and uploading + new_version files), + - Uploads information to the entry (Zenodo compulsory deposit information), + - Publishes an entry + - Find all the published community entries + * per title + * per entry_id + + + :param access_token: str + Personal access token to (sandbox.)zenodo.org/api + :param sandbox: bool + Communicates with either zenodo or sandbox.zenodo api + """ + + if sandbox: + zenodo_api_url = "https://sandbox.zenodo.org/api" + else: + zenodo_api_url = "https://zenodo.org/api" + + self.zenodo_api_url = zenodo_api_url + self.access_token = access_token + self.parameters = {'access_token': self.access_token} + + self.proj_root_dir = Path(proj_root_dir) + self.exist_codemeta_file = False + self.path_codemeta_file = self.proj_root_dir + self.exist_zenodo_metadata_file = False + self.path_zenodo_metadata_file = self.proj_root_dir + + def fetch_user_entries(self): + """ + Fetch the published entries of an user. Works to tests connection to Zenodo too. + + GET method to {zenodo_api_url}/deposit/depositions + + :return: request.get method + """ + url = f"{self.zenodo_api_url}/deposit/depositions" + + return requests.get(url, params=self.parameters) + + def create_new_entry(self): + """ + Create a new entry / deposition in (sandbox.)zenodo + + POST method to {zenodo_api_url}/deposit/depositions + + :return: request.put method + """ + url = f"{self.zenodo_api_url}/deposit/depositions" + headers = {"Content-Type": "application/json"} + + return requests.post(url, json={}, headers=headers, params=self.parameters) + + def fetch_entry(self, entry_id): + """ + Fetches (recovers all the existing information, as well as links) of an existing Zenodo entry. + + GET method to {zenodo_api_url}/deposit/depositions/{entry_id} + + :param entry_id: str + entry_id of the entry to fetch + + :return: request.get method + """ + # In case of entries created by oneself, or entries in the process of being created, the method to fetch + # a record is request.get('api/deposit/deposition/{entry_id}') - see also the upload_file_entry method. + + # To fetch any other entry, already published, use: + url = f"{self.zenodo_api_url}/records/{entry_id}" + return requests.get(url, params=self.parameters) + + def upload_file_entry(self, entry_id, name_file, path_file): + """ + Upload a file to a Zenodo entry. If first retrieve the entry by a GET method to the + {zenodo_api_url}/deposit/depositions/{entry_id}. + + PUT method to {bucket_url}/{filename}. The full api url is recovered when the entry is firstly retrieved. + + :param entry_id: str + deposition_id of the Zenodo entry + :param name_file: str + File name of the file when uploaded + :param path_file: str + Path to the file to be uploaded + + :return: request.put method + """ + # 1 - Retrieve and recover information of a record that is in process of being published + fetch = requests.get(f"{self.zenodo_api_url}/deposit/depositions/{entry_id}", + params=self.parameters) + + # 2 - Upload the files + bucket_url = fetch.json()['links']['bucket'] # full url is recovered from previous GET method + url = f"{bucket_url}/{name_file}" + + with open(path_file, 'rb') as upload_file: + upload = requests.put(url, data=upload_file, params=self.parameters) + + return upload.json() + + def update_metadata_entry(self, entry_id, json_metadata): + """ + Update an entry resource. Data should be the entry information that will be shown when a deposition is visited + at the Zenodo site. + + PUT method to {zenodo_api_url}/deposit/depositions/{entry_id}. `data` MUST be included as json.dump(data) + + :param entry_id: str + deposition_id of the Zenodo entry + :param json_metadata: object + json object containing the metadata (compulsory fields) that are enclosed when a new entry is created. + + :return: request.put method + """ + url = f"{self.zenodo_api_url}/deposit/depositions/{entry_id}" + headers = {"Content-Type": "application/json"} + + # The metadata field is already created, just need to be updated. + # Thus the root 'metadata' key need to be kept, to indicate the field to be updated. + data = {"metadata": json_metadata} + + return requests.put(url, data=json.dumps(data), + headers=headers, params=self.parameters) + + def erase_entry(self, entry_id): + """ + Erase an entry/new version of an entry that HAS NOT BEEN published yet. + Any new upload/version will be first saved as 'draft' and not published until confirmation (i.e, requests.post) + + DELETE method to {zenodo_api_url}/deposit/depositions/{entry_id}. + + :param entry_id: str + deposition_id of the Zenodo entry to be erased + + :return: request.delete method + """ + url = f"{self.zenodo_api_url}/deposit/depositions/{entry_id}" + return requests.delete(url, params=self.parameters) + + def erase_file_entry(self, entry_id, file_id): + """ + Erase a file from an entry resource. + This method is intended to be used for substitution of files (deletion) within an entry by their correspondent + new versions. + DELETE method to {zenodo_api_url}/deposit/depositions/{entry_id}/files/{file_id} + + :param entry_id: str + deposition_id of the Zenodo entry + :param file_id: str + Id of the files stored in Zenodo + + :return: requests.delete method + """ + url = f"{self.zenodo_api_url}/deposit/depositions/{entry_id}/files/{file_id}" + return requests.delete(url, params=self.parameters) + + def publish_entry(self, entry_id): + """ + Publishes an entry in (sandbox.)zenodo + POST method to {zenodo_api_url}/deposit/depositions/{entry_id}/actions/publish + + :param entry_id: str + deposition_id of the Zenodo entry + + :return: requests.put method + """ + url = f"{self.zenodo_api_url}/deposit/depositions/{entry_id}/actions/publish" + return requests.post(url, params=self.parameters) + + def new_version_entry(self, entry_id): + """ + Creates a new version of AN EXISTING entry resource. + POST method to {zenodo_api_url}/deposit/depositions/{entry_id}/actions/newversion + + :param entry_id: str + deposition_id of the Zenodo entry + + :return: requests.post method + """ + url = f"{self.zenodo_api_url}/deposit/depositions/{entry_id}/actions/newversion" + parameters = {'access_token': self.access_token} + + return requests.post(url, params=parameters) + + def fetch_community_entries(self, community_name='escape2020', results_per_query=100): + """ + Query the entries within a community. + GET method, previous modification of the query arguments, to {zenodo_api_url}/records + + + :param community_name: str + Community name. DEFAULT='escape2020' + :param results_per_query: int + Number of entries returned per call to the REST API. DEFAULT=100. + + :return: requests.get method + """ + # https://developers.zenodo.org/#list36 + update_query_args = {'communities': str(community_name), + 'size': int(results_per_query) + } + self.parameters.update(update_query_args) + + # Full answer + # content = requests.post(url, params=self.parameters) + # Answer items + # content.json().keys() + # Stats + # content.json()['aggregations'] + # Total num of entries + # content.json()['hits']['total'] + # Zenodo metadata of each entry + # [item['metadata'] for item in content.json()['hits']['hits']] + + return requests.get(f"{self.zenodo_api_url}/records", params=self.parameters) + + def fetch_community_entries_per_id(self, community_name='escape2020', results_per_query=100): + """ + Query the `entries ids` of all the entries within a community + + :param community_name: str + Community name. DEFAULT='escape2020' + :param results_per_query: int + Number of entries returned per call to the REST API. DEFAULT=100. + + :return: list + List containing the `id`s of each community entry + """ + return [entry['id'] for entry in + self.fetch_community_entries(community_name, results_per_query).json()['hits']['hits']] + + def fetch_community_entries_per_title(self, community_name='escape2020', results_per_query=100): + """ + Query the title of all the entries within a community + + :param community_name: str + Community name. DEFAULT='escape2020' + :param results_per_query: int + Number of entries returned per call to the REST API. DEFAULT=100. + + :return: list + List containing the title of each community entry + """ + return [entry['metadata']['title'] for entry in + self.fetch_community_entries(community_name, results_per_query).json()['hits']['hits']] + + def search_codemeta_file(self): + """Check if a `codemeta.json` files exists in the ROOT directory of the project""" + + # root_dir = find_root_directory() + root_dir = self.proj_root_dir + print(f'\nProject root directory {abspath(root_dir)}') # DEBUG + + codemeta_file = root_dir / 'codemeta.json' + + if codemeta_file.exists(): + print("\n * Found codemeta.json file within the project !") + self.exist_codemeta_file = True + self.path_codemeta_file = codemeta_file + else: + print("\n ! codemeta.json file NOT found in the root directory of the project !") + + def search_zenodo_json_file(self): + """Check if a `.zenodo.json` files exists in the ROOT directory of the project""" + + # root_dir = find_root_directory() + root_dir = self.proj_root_dir + + zenodo_metadata_file = root_dir / '.zenodo.json' + + if zenodo_metadata_file.exists(): + print("\n * Found .zenodo.json file within the project !") + self.exist_zenodo_metadata_file = True + self.path_zenodo_metadata_file = zenodo_metadata_file + else: + print("\n ! .zenodo.json file NOT found in the root directory of the project !") + + def conversion_codemeta2zenodo(self): + """Perform the codemeta2zenodo conversion if a codemeta.json file is found""" + + if self.exist_codemeta_file: + print("\n * Creating a .zenodo.json file from your codemeta.json file...") + self.path_zenodo_metadata_file = self.path_codemeta_file.parent / '.zenodo.json' + + parse_codemeta_and_write_zenodo_metadata_file(self.path_codemeta_file, + self.path_zenodo_metadata_file) + else: + pass + + def test_upload_to_zenodo(self): + """ + `Tests` the different stages of the GitLab-Zenodo connection and that the status_code returned by every + stage is the correct one. + + Checks: + - The existence of a `.zenodo.json` file in the ROOT dir of the project + - If not, it checks if it exists a `codemeta.json` file + - If it exists it performs the codemeta2zenodo conversion + - If not, it exits the program + + - The communication with Zenodo through its API to verify that: + - You can fetch an user entries + - You can create a new entry + - The provided zenodo metadata can be digested, and not errors appear + - Finally erases the test entry - because IT HAS NOT BEEN PUBLISHED ! + """ + # Search for the codemeta.json and the .zenodo.json files within the project + self.search_codemeta_file() + self.search_zenodo_json_file() + + if not self.exist_zenodo_metadata_file: + + if self.exist_codemeta_file: + self.conversion_codemeta2zenodo() + else: + print("\n ! NO codemeta.json NOR .zenodo.json file found. " + "Please add one to the ROOT directory of your project.") + sys.exit(-1) + + print("\n * Using the .zenodo.json file to simulate a new upload to Zenodo... \n") + + # 1 - Test connection + print("1 --> Testing communication with Zenodo...") + + test_connection = self.fetch_user_entries() + if test_connection.status_code == 200: + print(" * Test connection status OK !") + else: + print(" ! ERROR while testing connection status\n", test_connection.json()) + + # 2 - Test new entry + print("2 --> Testing the creation of a dummy entry to (sandbox)Zenodo...") + + new_entry = self.create_new_entry() + if new_entry.status_code == 201: + print(" * Test new entry status OK !") + else: + print(" ! ERROR while testing the creation of new entry\n", new_entry.json()) + + # 3 - Test upload metadata + print("3 --> Testing the ingestion of the Zenodo metadata...") + + test_entry_id = new_entry.json()['id'] + with open(self.path_zenodo_metadata_file) as file: + metadata_entry = json.load(file) + update_metadata = self.update_metadata_entry(test_entry_id, + json_metadata=metadata_entry) + + if update_metadata.status_code == 200: + print(" * Update metadata status OK !") + pprint.pprint(metadata_entry) + else: + print(" ! ERROR while testing update of metadata\n", + update_metadata.json(), "\n", metadata_entry) + print(" ! Erasing dummy test entry...\n") + erase_error = self.erase_entry(test_entry_id) + if erase_error.status_code != 204: + print(f" !! ERROR erasing dummy test entry. Please erase it manually at\n " + f"{self.zenodo_api_url[:-4]}/deposit") + else: + print(" - Done.\n") + sys.exit(-1) + + # 4 - Test delete entry + print("4 --> Testing the deletion of the dummy entry...") + + delete_test_entry = self.erase_entry(test_entry_id) + if delete_test_entry.status_code == 204: + print(" * Delete test entry status OK !") + else: + print(" ! ERROR while deleting test entry\n", delete_test_entry.json()) + + print("\n\tYAY ! Successful testing of the connection to Zenodo ! \n\n" + "You should not face any trouble when uploading a project to Zenodo - if you followed the " + "`OSSR how to publish tutorial`:\n" + "\t https://escape2020.pages.in2p3.fr/wp3/ossr-pages/page/contribute/publish_tutorial/#3-add-the-following-code-snippet \n" + "In case you do, please contact us !\n") diff --git a/eossr/api/tests/__init__.py b/eossr/api/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/eossr/api/tests/test_apizenodo.py b/eossr/api/tests/test_apizenodo.py new file mode 100644 index 0000000000000000000000000000000000000000..1f7712fbb155a7273eba21e224446cbb8889892e --- /dev/null +++ b/eossr/api/tests/test_apizenodo.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python + +import json +import unittest +import requests +from pathlib import Path +from os import remove +from os.path import dirname, realpath, join +from ..apizenodo import ZenodoAPI + +ROOT_DIR = dirname(realpath("codemeta.json")) + + +class TestZenodoApiSandbox(unittest.TestCase): + def test_initialization_sandbox(self): + token = 'FakeToken' + z = ZenodoAPI(access_token=token, + sandbox=True, + proj_root_dir=ROOT_DIR) + + assert isinstance(z, ZenodoAPI) + assert z.zenodo_api_url == 'https://sandbox.zenodo.org/api' + assert z.access_token == token + assert type(z.exist_codemeta_file) == bool + assert type(z.exist_zenodo_metadata_file) == bool + assert z.proj_root_dir == Path(ROOT_DIR) + assert z.path_codemeta_file == z.proj_root_dir + assert z.path_zenodo_metadata_file == z.proj_root_dir + assert isinstance(z.proj_root_dir, (Path, str)) + + +class TestZenodoAPI(unittest.TestCase): + def test_initialization(self): + token = 'FakeToken' + z = ZenodoAPI(access_token=token, + sandbox=False, + proj_root_dir=ROOT_DIR) + + assert isinstance(z, ZenodoAPI) + assert z.zenodo_api_url == 'https://zenodo.org/api' + assert z.access_token == token + assert type(z.exist_codemeta_file) == bool + assert type(z.exist_zenodo_metadata_file) == bool + assert z.proj_root_dir == Path(ROOT_DIR) + assert z.path_codemeta_file == z.proj_root_dir + assert z.path_zenodo_metadata_file == z.proj_root_dir + assert isinstance(z.proj_root_dir, (Path, str)) + + def test_zenodo_api_methods(self): + token = 'FakeToken' + z = ZenodoAPI(access_token=token, + sandbox=False, + proj_root_dir=ROOT_DIR) + + test_id = '42' + z.search_codemeta_file() + test_filename = join(ROOT_DIR, z.path_codemeta_file) + path_test_filename = './' + + fetch_user_entry = z.fetch_user_entries() + create_new_entry = z.create_new_entry() + fetch_single_entry = z.fetch_entry( + entry_id=test_id + ) + # upload_file_entry = z.upload_file_entry( + # entry_id=test_id, + # name_file=test_filename, + # path_file=path_test_filename + # ) + upload_metadata_entry = z.update_metadata_entry( + entry_id=test_id, + json_metadata=test_filename + ) + erase_entry = z.erase_entry( + entry_id=test_id + ) + erase_file_entry = z.erase_file_entry( + entry_id=test_id, + file_id=test_id + ) + publish_entry = z.publish_entry( + entry_id=test_id + ) + new_version_entry = z.new_version_entry( + entry_id=test_id + ) + community_entries = z.fetch_community_entries() + fetch_ids = z.fetch_community_entries_per_id() + fetch_filenames = z.fetch_community_entries_per_title() + + assert isinstance(fetch_user_entry, requests.models.Response) + assert isinstance(create_new_entry, requests.models.Response) + assert isinstance(fetch_single_entry, requests.models.Response) + # assert isinstance(upload_file_entry, requests.models.Response) + assert isinstance(upload_metadata_entry, requests.models.Response) + assert isinstance(erase_entry, requests.models.Response) + assert isinstance(erase_file_entry, requests.models.Response) + assert isinstance(publish_entry, requests.models.Response) + assert isinstance(new_version_entry, requests.models.Response) + assert isinstance(community_entries, requests.models.Response) + assert isinstance(fetch_ids, list) + assert isinstance(fetch_filenames, list) + + def test_search_codemeta_file(self): + token = 'FakeToken' + z = ZenodoAPI(access_token=token, + sandbox=False, + proj_root_dir=ROOT_DIR) + + assert z.exist_codemeta_file is False + z.search_codemeta_file() + assert z.exist_codemeta_file is True + + codemeta_file_path = Path(ROOT_DIR) / 'codemeta.json' + assert z.path_codemeta_file == codemeta_file_path + assert codemeta_file_path.is_file() + print(z.path_codemeta_file, type(z.path_codemeta_file)) + with open(z.path_codemeta_file) as f: + json.load(f) + + def test_search_zenodo_json_file(self): + token = 'FakeToken' + z = ZenodoAPI(access_token=token, + sandbox=False, + proj_root_dir=ROOT_DIR) + + assert z.exist_zenodo_metadata_file is False + z.search_zenodo_json_file() + assert z.exist_zenodo_metadata_file is False + + def test_conversion_codemeta2zenodo_and_search_zenodo_file(self): + token = 'FakeToken' + z = ZenodoAPI(access_token=token, + sandbox=False, + proj_root_dir=ROOT_DIR) + + z.search_codemeta_file() + z.conversion_codemeta2zenodo() + + z.search_zenodo_json_file() + assert z.exist_zenodo_metadata_file is True + + zenodo_file_path = Path(ROOT_DIR) / '.zenodo.json' + assert z.path_zenodo_metadata_file == zenodo_file_path + assert zenodo_file_path.is_file() + with open(z.path_zenodo_metadata_file) as f: + json.load(f) + + remove(z.path_zenodo_metadata_file) diff --git a/setup.py b/setup.py index 5a3b1bcce2e80aa72dd9d3bac15d70587c993ed5..1a5f5f5cdc94cdb32dff4c42e07199ef74057fc3 100644 --- a/setup.py +++ b/setup.py @@ -19,10 +19,12 @@ setup( name='eossr', version=get_property('__version__', 'eossr'), description="ESCAPE OSSR library", - # install_requires=[], + install_requires=[ + "requests" + ], packages=find_packages(), # scripts=[], - # tests_require=['pytest'], + tests_require=['pytest'], author='Thomas Vuillaume & Enrique Garcia', author_email='vuillaume<at>lapp.in2p3.fr', url='https://gitlab.in2p3.fr/escape2020/wp3/eossr',