|
16 | 16 | # See the License for the specific language governing permissions and
|
17 | 17 | # limitations under the License.
|
18 | 18 | """Zenodo API integration."""
|
| 19 | +import json |
| 20 | +import os |
19 | 21 | import pathlib
|
20 | 22 | import urllib
|
21 | 23 | from urllib.parse import urlparse
|
22 | 24 |
|
23 | 25 | import attr
|
24 | 26 | import requests
|
| 27 | +from requests import HTTPError |
| 28 | +from tqdm import tqdm |
25 | 29 |
|
26 |
| -from renku.cli._providers.api import ProviderApi |
| 30 | +from renku._compat import Path |
| 31 | +from renku.cli._providers.api import ExporterApi, ProviderApi |
27 | 32 | from renku.cli._providers.doi import DOIProvider
|
28 | 33 | from renku.models.datasets import Dataset, DatasetFile
|
29 | 34 | from renku.utils.doi import is_doi
|
30 | 35 |
|
31 | 36 | ZENODO_BASE_URL = 'https://zenodo.org'
|
32 |
| -ZENODO_BASE_PATH = 'api' |
| 37 | +ZENODO_SANDBOX_URL = 'https://sandbox.zenodo.org/' |
| 38 | + |
| 39 | +ZENODO_API_PATH = 'api' |
| 40 | + |
| 41 | +ZENODO_DEPOSIT_PATH = 'deposit' |
| 42 | +ZENODO_PUBLISH_PATH = 'record' |
| 43 | + |
| 44 | +ZENODO_PUBLISH_ACTION_PATH = 'depositions/{0}/actions/publish' |
| 45 | +ZENODO_METADATA_URL = 'depositions/{0}' |
| 46 | +ZENODO_FILES_URL = 'depositions/{0}/files' |
| 47 | +ZENODO_NEW_DEPOSIT_URL = 'depositions' |
33 | 48 |
|
34 | 49 |
|
35 | 50 | def make_records_url(record_id):
|
36 | 51 | """Create URL to access record by ID."""
|
37 | 52 | return urllib.parse.urljoin(
|
38 | 53 | ZENODO_BASE_URL,
|
39 |
| - pathlib.posixpath.join(ZENODO_BASE_PATH, 'records', record_id) |
| 54 | + pathlib.posixpath.join(ZENODO_API_PATH, 'records', record_id) |
40 | 55 | )
|
41 | 56 |
|
42 | 57 |
|
| 58 | +def check_or_raise(response): |
| 59 | + """Check for expected response status code.""" |
| 60 | + if response.status_code not in [200, 201, 202]: |
| 61 | + if response.status_code == 401: |
| 62 | + raise HTTPError('Access unauthorized - update access token.') |
| 63 | + |
| 64 | + if response.status_code == 400: |
| 65 | + err_response = response.json() |
| 66 | + errors = [ |
| 67 | + '"{0}" failed with "{1}"'.format(err['field'], err['message']) |
| 68 | + for err in err_response['errors'] |
| 69 | + ] |
| 70 | + |
| 71 | + raise HTTPError('\n' + '\n'.join(errors)) |
| 72 | + |
| 73 | + else: |
| 74 | + raise HTTPError(response.content) |
| 75 | + |
| 76 | + |
43 | 77 | @attr.s
|
44 | 78 | class ZenodoFileSerializer:
|
45 | 79 | """Zenodo record file."""
|
@@ -224,6 +258,205 @@ def as_dataset(self):
|
224 | 258 | return dataset
|
225 | 259 |
|
226 | 260 |
|
| 261 | +@attr.s |
| 262 | +class ZenodoDeposition: |
| 263 | + """Zenodo record for deposit.""" |
| 264 | + |
| 265 | + exporter = attr.ib() |
| 266 | + id = attr.ib(default=None) |
| 267 | + |
| 268 | + @property |
| 269 | + def publish_url(self): |
| 270 | + """Returns publish URL.""" |
| 271 | + url = urllib.parse.urljoin( |
| 272 | + self.exporter.zenodo_url, |
| 273 | + pathlib.posixpath.join( |
| 274 | + ZENODO_API_PATH, ZENODO_DEPOSIT_PATH, |
| 275 | + ZENODO_PUBLISH_ACTION_PATH.format(self.id) |
| 276 | + ) |
| 277 | + ) |
| 278 | + |
| 279 | + return url |
| 280 | + |
| 281 | + @property |
| 282 | + def attach_metadata_url(self): |
| 283 | + """Return URL for attaching metadata.""" |
| 284 | + url = urllib.parse.urljoin( |
| 285 | + self.exporter.zenodo_url, |
| 286 | + pathlib.posixpath.join( |
| 287 | + ZENODO_API_PATH, ZENODO_DEPOSIT_PATH, |
| 288 | + ZENODO_METADATA_URL.format(self.id) |
| 289 | + ) |
| 290 | + ) |
| 291 | + return url |
| 292 | + |
| 293 | + @property |
| 294 | + def upload_file_url(self): |
| 295 | + """Return URL for uploading file.""" |
| 296 | + url = urllib.parse.urljoin( |
| 297 | + self.exporter.zenodo_url, |
| 298 | + pathlib.posixpath.join( |
| 299 | + ZENODO_API_PATH, ZENODO_DEPOSIT_PATH, |
| 300 | + ZENODO_FILES_URL.format(self.id) |
| 301 | + ) |
| 302 | + ) |
| 303 | + return url |
| 304 | + |
| 305 | + @property |
| 306 | + def new_deposit_url(self): |
| 307 | + """Return URL for creating new deposit.""" |
| 308 | + url = urllib.parse.urljoin( |
| 309 | + self.exporter.zenodo_url, |
| 310 | + pathlib.posixpath.join( |
| 311 | + ZENODO_API_PATH, ZENODO_DEPOSIT_PATH, ZENODO_NEW_DEPOSIT_URL |
| 312 | + ) |
| 313 | + ) |
| 314 | + return url |
| 315 | + |
| 316 | + @property |
| 317 | + def published_at(self): |
| 318 | + """Return published at URL.""" |
| 319 | + url = urllib.parse.urljoin( |
| 320 | + self.exporter.zenodo_url, |
| 321 | + pathlib.posixpath.join(ZENODO_PUBLISH_PATH, str(self.id)) |
| 322 | + ) |
| 323 | + return url |
| 324 | + |
| 325 | + @property |
| 326 | + def deposit_at(self): |
| 327 | + """Return deposit at URL.""" |
| 328 | + url = urllib.parse.urljoin( |
| 329 | + self.exporter.zenodo_url, |
| 330 | + pathlib.posixpath.join(ZENODO_DEPOSIT_PATH, str(self.id)) |
| 331 | + ) |
| 332 | + return url |
| 333 | + |
| 334 | + def new_deposition(self): |
| 335 | + """Create new deposition on Zenodo.""" |
| 336 | + response = requests.post( |
| 337 | + url=self.new_deposit_url, |
| 338 | + params=self.exporter.default_params, |
| 339 | + json={}, |
| 340 | + headers=self.exporter.HEADERS |
| 341 | + ) |
| 342 | + check_or_raise(response) |
| 343 | + |
| 344 | + return response |
| 345 | + |
| 346 | + def upload_file(self, filepath): |
| 347 | + """Upload and attach a file to existing deposition on Zenodo.""" |
| 348 | + request_payload = {'filename': Path(filepath).name} |
| 349 | + file = {'file': open(str(filepath), 'rb')} |
| 350 | + |
| 351 | + response = requests.post( |
| 352 | + url=self.upload_file_url, |
| 353 | + params=self.exporter.default_params, |
| 354 | + data=request_payload, |
| 355 | + files=file, |
| 356 | + ) |
| 357 | + check_or_raise(response) |
| 358 | + |
| 359 | + return response |
| 360 | + |
| 361 | + def attach_metadata(self, dataset): |
| 362 | + """Attach metadata to deposition on Zenodo.""" |
| 363 | + request_payload = { |
| 364 | + 'metadata': { |
| 365 | + 'title': dataset.name, |
| 366 | + 'upload_type': 'dataset', |
| 367 | + 'description': dataset.description, |
| 368 | + 'creators': [{ |
| 369 | + 'name': creator.name, |
| 370 | + 'affiliation': creator.affiliation |
| 371 | + } for creator in dataset.creator] |
| 372 | + } |
| 373 | + } |
| 374 | + |
| 375 | + response = requests.put( |
| 376 | + url=self.attach_metadata_url, |
| 377 | + params=self.exporter.default_params, |
| 378 | + data=json.dumps(request_payload), |
| 379 | + headers=self.exporter.HEADERS |
| 380 | + ) |
| 381 | + check_or_raise(response) |
| 382 | + |
| 383 | + return response |
| 384 | + |
| 385 | + def publish_deposition(self, secret): |
| 386 | + """Publish existing deposition.""" |
| 387 | + response = requests.post( |
| 388 | + url=self.publish_url, params=self.exporter.default_params |
| 389 | + ) |
| 390 | + check_or_raise(response) |
| 391 | + |
| 392 | + return response |
| 393 | + |
| 394 | + def __attrs_post_init__(self): |
| 395 | + """Post-Init hook to set _id field.""" |
| 396 | + response = self.new_deposition() |
| 397 | + self.id = response.json()['id'] |
| 398 | + |
| 399 | + |
| 400 | +@attr.s |
| 401 | +class ZenodoExporter(ExporterApi): |
| 402 | + """Zenodo export manager.""" |
| 403 | + |
| 404 | + HEADERS = {'Content-Type': 'application/json'} |
| 405 | + |
| 406 | + dataset = attr.ib() |
| 407 | + access_token = attr.ib() |
| 408 | + |
| 409 | + @property |
| 410 | + def zenodo_url(self): |
| 411 | + """Returns correct Zenodo URL based on environment.""" |
| 412 | + if 'ZENODO_USE_SANDBOX' in os.environ: |
| 413 | + return ZENODO_SANDBOX_URL |
| 414 | + |
| 415 | + return ZENODO_BASE_URL |
| 416 | + |
| 417 | + def set_access_token(self, access_token): |
| 418 | + """Set access token.""" |
| 419 | + self.access_token = access_token |
| 420 | + |
| 421 | + def access_token_url(self): |
| 422 | + """Return endpoint for creation of access token.""" |
| 423 | + return urllib.parse.urlparse( |
| 424 | + 'https://zenodo.org/account/settings/applications/tokens/new/' |
| 425 | + ).geturl() |
| 426 | + |
| 427 | + @property |
| 428 | + def default_params(self): |
| 429 | + """Create request default params.""" |
| 430 | + return {'access_token': self.access_token} |
| 431 | + |
| 432 | + def dataset_to_request(self): |
| 433 | + """Prepare dataset metadata for request.""" |
| 434 | + jsonld = self.dataset.asjsonld() |
| 435 | + jsonld['upload_type'] = 'dataset' |
| 436 | + return jsonld |
| 437 | + |
| 438 | + def export(self, publish): |
| 439 | + """Execute entire export process.""" |
| 440 | + # Step 1. Create new deposition |
| 441 | + deposition = ZenodoDeposition(exporter=self) |
| 442 | + |
| 443 | + # Step 2. Upload all files to created deposition |
| 444 | + with tqdm(total=len(self.dataset.files)) as progressbar: |
| 445 | + for file_ in self.dataset.files: |
| 446 | + deposition.upload_file(file_.full_path, ) |
| 447 | + progressbar.update(1) |
| 448 | + |
| 449 | + # Step 3. Attach metadata to deposition |
| 450 | + deposition.attach_metadata(self.dataset) |
| 451 | + |
| 452 | + # Step 4. Publish newly created deposition |
| 453 | + if publish: |
| 454 | + deposition.publish_deposition(self.access_token) |
| 455 | + return deposition.published_at |
| 456 | + |
| 457 | + return deposition.deposit_at |
| 458 | + |
| 459 | + |
227 | 460 | @attr.s
|
228 | 461 | class ZenodoProvider(ProviderApi):
|
229 | 462 | """zenodo.org registry API provider."""
|
@@ -278,3 +511,7 @@ def get_record(self, uri):
|
278 | 511 | response = self.make_request(uri)
|
279 | 512 |
|
280 | 513 | return ZenodoRecordSerializer(**response.json(), zenodo=self, uri=uri)
|
| 514 | + |
| 515 | + def get_exporter(self, dataset, access_token): |
| 516 | + """Create export manager for given dataset.""" |
| 517 | + return ZenodoExporter(dataset=dataset, access_token=access_token) |
0 commit comments