CMIP7-data-request-api 1.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. CMIP7_data_request_api-1.1.2.dist-info/LICENSE +21 -0
  2. CMIP7_data_request_api-1.1.2.dist-info/METADATA +210 -0
  3. CMIP7_data_request_api-1.1.2.dist-info/RECORD +36 -0
  4. CMIP7_data_request_api-1.1.2.dist-info/WHEEL +5 -0
  5. CMIP7_data_request_api-1.1.2.dist-info/entry_points.txt +2 -0
  6. CMIP7_data_request_api-1.1.2.dist-info/top_level.txt +1 -0
  7. data_request_api/__init__.py +1 -0
  8. data_request_api/command_line/__init__.py +0 -0
  9. data_request_api/command_line/export_dreq_lists_json.py +136 -0
  10. data_request_api/dev/JA/__init__.py +0 -0
  11. data_request_api/dev/JA/check_plev_requests.py +416 -0
  12. data_request_api/dev/JA/read_feedback_spreadsheet.py +141 -0
  13. data_request_api/dev/JA/workflow_example_GRtest.py +275 -0
  14. data_request_api/dev/JA/workflow_example_test.py +222 -0
  15. data_request_api/dev/MM/checksum.py +68 -0
  16. data_request_api/dev/MM/walking_data_request.ipynb +380 -0
  17. data_request_api/dev/MS/dreq_content_and_walking_data_request.ipynb +3755 -0
  18. data_request_api/dev/__init__.py +0 -0
  19. data_request_api/stable/__init__.py +0 -0
  20. data_request_api/stable/content/README.MD +106 -0
  21. data_request_api/stable/content/__init__.py +0 -0
  22. data_request_api/stable/content/dreq_api/__init__.py +0 -0
  23. data_request_api/stable/content/dreq_api/consolidate_export.py +488 -0
  24. data_request_api/stable/content/dreq_api/dreq_content.py +593 -0
  25. data_request_api/stable/content/dreq_api/mapping_table.py +335 -0
  26. data_request_api/stable/content/dreq_api/test_dreq_content.py +194 -0
  27. data_request_api/stable/content/dump_transformation.py +550 -0
  28. data_request_api/stable/query/__init__.py +0 -0
  29. data_request_api/stable/query/data_request.py +1120 -0
  30. data_request_api/stable/query/dreq_classes.py +372 -0
  31. data_request_api/stable/query/dreq_query.py +981 -0
  32. data_request_api/stable/query/vocabulary_server.py +208 -0
  33. data_request_api/stable/utilities/__init__.py +0 -0
  34. data_request_api/stable/utilities/logger.py +71 -0
  35. data_request_api/stable/utilities/tools.py +49 -0
  36. data_request_api/version.py +16 -0
@@ -0,0 +1,593 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import time
5
+ import warnings
6
+ from filecmp import cmp
7
+ from shutil import move
8
+
9
+ import pooch
10
+ import requests
11
+ from bs4 import BeautifulSoup
12
+
13
+ from . import consolidate_export as ce
14
+ from data_request_api.stable.content.dreq_api.mapping_table import mapping_table
15
+ from data_request_api.stable.utilities.logger import get_logger # noqa
16
+
17
+
18
+ # Suppress pooch info output
19
+ pooch.get_logger().setLevel("WARNING")
20
+
21
+ # File names of Airtable exports in JSON format
22
+ _json_raw = "dreq_raw_export.json"
23
+ _json_release = "dreq_release_export.json"
24
+
25
+ # Base URL template for fetching Dreq content json files from GitHub
26
+ # _github_org = "WCRP-CMIP"
27
+ _github_org = "CMIP-Data-Request"
28
+ REPO_RAW_URL = "https://raw.githubusercontent.com/{_github_org}/CMIP7_DReq_Content/{version}/airtable_export/{_json_export}"
29
+ _dev_branch = "main"
30
+
31
+ # API URL for fetching tags or branches
32
+ REPO_API_URL = f"https://api.github.com/repos/{_github_org}/CMIP7_DReq_content/"
33
+
34
+ # Alternative Repo URL for fetching tags
35
+ REPO_PAGE_URL = f"https://github.com/{_github_org}/CMIP7_DReq_content/"
36
+
37
+ # List of versions (tags, branches) - will be populated by get_versions(target="tags" or "branches")
38
+ versions = {"tags": [], "branches": []}
39
+ _versions_retrieved_last = {"tags": 0, "branches": 0}
40
+
41
+ # When retrieving versions (tags, branches), fall back to parsing the public GitHub page for
42
+ # the GitHub API returning the following status codes:
43
+ # 403 Forbidden
44
+ # 429 Too Many Requests
45
+ # 500 Internal Server Error
46
+ # 502 Bad Gateway
47
+ # 503 Service Unavailable
48
+ # 504 Gateway Timeout
49
+ _fallback_status_codes = [403, 429, 500, 502, 503, 504]
50
+
51
+ # Regex pattern for version parsing (captures major, minor, patch and optional pre-release parts)
52
+ _version_pattern = re.compile(
53
+ r"^v?(\d+)\.(\d+)(?:\.(\d+))?((?:alpha|beta|a|b)?)?(\d*)$", re.IGNORECASE
54
+ )
55
+
56
+ # Directory where to find/store the data request JSON files
57
+ _dreq_res = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dreq_res")
58
+
59
+ _dreq_content_loaded = {}
60
+
61
+
62
+ def _parse_version(version):
63
+ """Parse a version tag and return a tuple for sorting.
64
+
65
+ Parameters
66
+ ----------
67
+ version : str
68
+ The version tag to parse.
69
+
70
+ Returns
71
+ -------
72
+ tuple
73
+ The parsed version tuple:
74
+ (major, minor, patch, pre_release_type, pre_release_number)
75
+ """
76
+ match = _version_pattern.match(version)
77
+ if match:
78
+ major, minor, patch = map(lambda x: int(x) if x else 0, match.groups()[:3])
79
+ # 'a' for alpha, 'b' for beta, or None
80
+ pre_release_type = match.group(4)[0] if match.group(4) else None
81
+ # alpha/beta version number or 0
82
+ pre_release_number = (
83
+ int(match.group(5)) if match.group(5) and pre_release_type else 0
84
+ )
85
+ return (major, minor, patch, pre_release_type or "", pre_release_number)
86
+ # if no valid version
87
+ return (0, 0, 0, "", 0)
88
+
89
+
90
+ def get_cached(**kwargs):
91
+ """Get list of cached versions.
92
+
93
+ Parameters
94
+ ----------
95
+ kwargs : dict, optional
96
+ Additional parameters to pass to the function.
97
+
98
+ Returns
99
+ -------
100
+ list
101
+ The list of cached versions.
102
+
103
+ Raises
104
+ ------
105
+ Warning
106
+ If known kwargs have an invalid value.
107
+ """
108
+ local_versions = []
109
+ if os.path.isdir(_dreq_res):
110
+ # List all subdirectories in the dreq_res directory that include both dreq.json files
111
+ # - the subdirectory name is the tag name
112
+ json_export = False
113
+ if "export" in kwargs:
114
+ if kwargs["export"] == "raw":
115
+ json_export = _json_raw
116
+ elif kwargs["export"] == "release":
117
+ json_export = _json_release
118
+ else:
119
+ warnings.warn(f"Unknown export type '{kwargs['export']}'.")
120
+ if json_export:
121
+ local_versions = [
122
+ name
123
+ for name in os.listdir(_dreq_res)
124
+ if os.path.isfile(os.path.join(_dreq_res, name, json_export))
125
+ ]
126
+ else:
127
+ local_versions = [
128
+ name
129
+ for name in os.listdir(_dreq_res)
130
+ if (
131
+ os.path.isfile(os.path.join(_dreq_res, name, _json_raw))
132
+ and not _version_pattern.match(name)
133
+ )
134
+ or (
135
+ os.path.isfile(os.path.join(_dreq_res, name, _json_release))
136
+ and _version_pattern.match(name)
137
+ )
138
+ ]
139
+ return local_versions
140
+
141
+
142
+ def _send_api_request(api_url, page_url, target="tags"):
143
+ """
144
+ Send a request to the GitHub API for a list of tags or branches.
145
+
146
+ Parameters
147
+ ----------
148
+ api_url : str
149
+ The base URL to send the request to.
150
+ page_url : str
151
+ The page URL to send the request to.
152
+ target : str, optional
153
+ The target to send the request for, either 'tags' or 'branches' (default is 'tags').
154
+
155
+ Returns
156
+ -------
157
+ list
158
+ A list of tags (or optionally branches).
159
+
160
+ Raises
161
+ ------
162
+ Warning
163
+ If the GitHub API is not accessible.
164
+ Warning
165
+ If a HTTP error occurs when retrieving the list of tags or branches.
166
+ Warning
167
+ If an exception occurs when retrieving the list of tags or branches.
168
+ """
169
+ # Request the list of tags or branches via the GitHub API
170
+ global _fallback_status_codes
171
+ results = []
172
+ response = requests.get(api_url + target)
173
+ try:
174
+ # Raise an error for bad responses
175
+ response.raise_for_status()
176
+
177
+ # Extract the list of tags or branches from the response
178
+ results = [
179
+ entry["name"]
180
+ for entry in response.json()
181
+ if "name" in entry and entry["name"] != _dev_branch
182
+ ] or []
183
+
184
+ except requests.exceptions.HTTPError as http_err:
185
+ if response.status_code in _fallback_status_codes:
186
+ warnings.warn(
187
+ f"GitHub API not accessible, falling back to parsing the public GitHub page: {http_err}"
188
+ )
189
+ results = _send_html_request(page_url, target)
190
+ else:
191
+ warnings.warn(
192
+ f"A HTTP error occurred when retrieving '{target}' ({response.status_code}): {http_err}"
193
+ )
194
+ except Exception as e:
195
+ warnings.warn(f"An error occurred when retrieving '{target}': {e}")
196
+
197
+ return results
198
+
199
+
200
+ def _send_html_request(page_url, target="tags"):
201
+ """
202
+ Fallback method: Parse the the public GitHub page to get the list of tags or branches.
203
+
204
+ Parameters
205
+ ----------
206
+ page_url : str
207
+ The base URL to send the request to.
208
+ target : str, optional
209
+ The target to send the request for, either 'tags' or 'branches' (default is 'tags').
210
+
211
+ Returns
212
+ -------
213
+ list
214
+ A list of tags (or optionally branches).
215
+
216
+ Raises
217
+ ------
218
+ ValueError
219
+ If the html response cannot be parsed.
220
+ Warning
221
+ If a HTTP error occurs when retrieving the list of tags or branches.
222
+
223
+ Notes
224
+ -----
225
+ Making use of the pagination mechanism of GitHub could only be tested for tags
226
+ so might not work for branches.
227
+ """
228
+ # Request the list of tags or (active) branches via the GitHub Page
229
+ results = []
230
+ addon = ""
231
+ if target == "branches":
232
+ addon = "/active"
233
+ current_url = page_url + target + addon
234
+ current_urls = list()
235
+ while current_url:
236
+ response = requests.get(current_url)
237
+ try:
238
+ # Raise an error for bad responses
239
+ response.raise_for_status()
240
+
241
+ soup = BeautifulSoup(response.content, "html.parser")
242
+
243
+ if target == "branches":
244
+ # Find the branches on the page - GitHub embeds json data under the script tag
245
+ script_tag = soup.find(
246
+ "script", {"data-target": "react-app.embeddedData"}
247
+ )
248
+ if not script_tag:
249
+ raise ValueError(
250
+ "Could not find the 'script' tag in the html response."
251
+ )
252
+ json_response = json.loads(script_tag.string)
253
+ results_json = json_response["payload"][target]
254
+ results += [
255
+ entry["name"]
256
+ for entry in results_json
257
+ if "name" in entry and entry["name"] != _dev_branch
258
+ ] or []
259
+ else:
260
+ # Find the tags on the page - GitHub uses "Link--primary" class for tags / branches
261
+ results += [
262
+ entry.text.strip()
263
+ for entry in soup.find_all("a", class_="Link--primary")
264
+ ]
265
+
266
+ # Check for pagination links and construct URL for the next page
267
+ # ToDo: I could not find a repo with more branches than fit on a single page
268
+ # so the next_page_links may have to be adapted for branches
269
+ next_page_links = soup.find_all(
270
+ "a", {"href": lambda x: x and "after=" in x}
271
+ )
272
+ if next_page_links:
273
+ current_urls.append(current_url)
274
+ current_url = "https://github.com" + next_page_links[-1]["href"]
275
+ if current_url in current_urls:
276
+ current_url = None
277
+ else:
278
+ current_url = None
279
+ except Exception as e:
280
+ warnings.warn(f"An error occurred when retrieving '{target}': {e}")
281
+ current_url = None
282
+
283
+ return results
284
+
285
+
286
+ def get_versions(target="tags"):
287
+ """Fetch list of tags from the GitHub repository using the GitHub API.
288
+
289
+ Args:
290
+ target (str): The target to send the request for, either 'tags' or 'branches'.
291
+ The default is 'tags'.
292
+
293
+ Parameters
294
+ ----------
295
+ target : str, optional
296
+ The target to send the request for, either 'tags' or 'branches' (default is 'tags').
297
+ Please note that the main development branch is excluded from the list of branches
298
+ and is included in the list of tags.
299
+
300
+ Returns
301
+ -------
302
+ list
303
+ A list of tags or branches.
304
+
305
+ Raises
306
+ ------
307
+ ValueError
308
+ If target is not 'tags' or 'branches'.
309
+ """
310
+ global versions
311
+ global _versions_retrieved_last
312
+
313
+ if target not in ["tags", "branches"]:
314
+ raise ValueError("target must be 'tags' or 'branches'.")
315
+
316
+ # Retrieve the list of tags or branches from the GitHub API
317
+ if not versions[target] or _versions_retrieved_last[target] - time.time() > 60 * 60:
318
+ versions[target] = _send_api_request(REPO_API_URL, REPO_PAGE_URL, target)
319
+
320
+ # Update the last time the tags/branches were retrieved
321
+ _versions_retrieved_last[target] = time.time()
322
+
323
+ if target == "tags" and "dev" not in versions[target]:
324
+ versions[target].append("dev")
325
+
326
+ # List tags hosted on GitHub
327
+ return versions[target]
328
+
329
+
330
+ def _get_latest_version(stable=True):
331
+ """Get the latest version
332
+
333
+ Parameters
334
+ ----------
335
+ stable : bool, optional
336
+ If True, return the latest stable version. If False, return the latest version
337
+ (i.e. incl. alpha/beta versions) (default is True).
338
+
339
+ Returns
340
+ -------
341
+ str
342
+ The latest version, or None if no versions are found.
343
+ """
344
+ versions = get_versions()
345
+ if stable:
346
+ sversions = [
347
+ version
348
+ for version in versions
349
+ if all([x not in version for x in ["a", "b", "dev"]])
350
+ ]
351
+ return max(sversions, key=_parse_version) if sversions else None
352
+ return max(versions, key=_parse_version)
353
+
354
+
355
+ def retrieve(version="latest_stable", **kwargs):
356
+ """Retrieve the JSON file for the specified version
357
+
358
+ Parameters
359
+ ----------
360
+ version: str, optional
361
+ The version to retrieve. Can be 'latest', 'latest_stable',
362
+ 'dev', or 'all' or a specific version, eg. '1.0.0'.
363
+ (default is 'latest_stable').
364
+ kwargs: dict, optional:
365
+ Additional parameters to pass to the retrieve function.
366
+
367
+ Returns
368
+ -------
369
+ dict
370
+ The path to the retrieved JSON file.
371
+
372
+ Raises
373
+ ------
374
+ ValueError
375
+ If the specified version is not found.
376
+ Warning
377
+ If the specified version does not have the specified export type.
378
+ Warning
379
+ If the known kwargs have an invalid value.
380
+ Warning
381
+ If the specified version could not be downloaded or (if applicable) updated.
382
+ """
383
+ logger = get_logger()
384
+ if version == "latest":
385
+ versions = [_get_latest_version(stable=False)]
386
+ elif version == "latest_stable":
387
+ versions = [_get_latest_version(stable=True)]
388
+ elif version == "dev":
389
+ versions = ["dev"]
390
+ elif version == "all":
391
+ versions = get_versions()
392
+ else:
393
+ if version not in get_versions() + get_versions(target="branches"):
394
+ if version not in get_cached(**kwargs):
395
+ raise ValueError(f"Version '{version}' not found.")
396
+ versions = [version]
397
+
398
+ if versions == [None] or not versions:
399
+ raise ValueError(f"Version '{version}' not found.")
400
+ elif version in ["v1.0alpha"] and "export" in kwargs and kwargs["export"] == "raw":
401
+ warnings.warn(f"For version '{version}' no raw export exists.")
402
+
403
+ json_paths = dict()
404
+ for version in versions:
405
+ # Define the path for storing the dreq.json in the installation directory
406
+ # Store it as path_to_dreqapi/dreq_api/dreq_res/version/{_json_raw/release}
407
+ retrieve_to_dir = os.path.join(_dreq_res, version)
408
+ # Decide whether to download release or raw json file
409
+ if "export" in kwargs:
410
+ if kwargs["export"] == "release" or version == "v1.0alpha":
411
+ json_export = _json_release
412
+ elif kwargs["export"] == "raw":
413
+ json_export = _json_raw
414
+ else:
415
+ warnings.warn(f"Unknown export type '{kwargs['export']}'.")
416
+ elif _version_pattern.match(version):
417
+ json_export = _json_release
418
+ else:
419
+ json_export = _json_raw
420
+ json_path = os.path.join(retrieve_to_dir, json_export)
421
+ os.makedirs(retrieve_to_dir, exist_ok=True)
422
+
423
+ # If not already cached download with POOCH
424
+ if not os.path.isfile(json_path):
425
+ # Download with pooch - use "main" branch for "dev"
426
+ try:
427
+ json_path = pooch.retrieve(
428
+ path=retrieve_to_dir,
429
+ url=REPO_RAW_URL.format(
430
+ version=_dev_branch if version == "dev" else version,
431
+ _json_export=json_export,
432
+ _github_org=_github_org,
433
+ ),
434
+ known_hash=None,
435
+ fname=json_export,
436
+ )
437
+ except Exception as e:
438
+ warnings.warn(f"Could not retrieve version '{version}': {e}")
439
+ continue
440
+ logger.info(f"Retrieved version '{version}'.")
441
+
442
+ # or if the version is "dev" or a branch rather than a tag
443
+ elif version == "dev" or version not in get_versions():
444
+ # Download with pooch to temporary file and compare to cached version
445
+ json_path_temp = json_path + ".tmp"
446
+ try:
447
+ # Delete temp file if it exists
448
+ if os.path.exists(json_path_temp):
449
+ os.remove(json_path_temp)
450
+ # Retrieve
451
+ json_path_temp = pooch.retrieve(
452
+ path=retrieve_to_dir,
453
+ url=REPO_RAW_URL.format(
454
+ version=_dev_branch if version == "dev" else version,
455
+ _json_export=json_export,
456
+ _github_org=_github_org,
457
+ ),
458
+ known_hash=None,
459
+ fname=json_export + ".tmp",
460
+ )
461
+ # Compare files
462
+ if not cmp(json_path, json_path_temp, shallow=False):
463
+ move(json_path_temp, json_path)
464
+ logger.info(f"Updated version '{version}'.")
465
+ else:
466
+ os.remove(json_path_temp)
467
+ except Exception as e:
468
+ warnings.warn(f"Potential update for version '{version}' failed: {e}")
469
+
470
+ # Store the path to the dreq.json in the json_paths dictionary
471
+ json_paths[version] = json_path
472
+
473
+ return json_paths
474
+
475
+
476
+ def delete(version="all", keep_latest=False, **kwargs):
477
+ """Delete one or all cached versions with option to keep latest versions.
478
+
479
+ Parameters
480
+ ----------
481
+ version : str, optional
482
+ The version to delete. Can be 'all' or a specific version,
483
+ eg. '1.0.0' (default is 'all').
484
+ keep_latest : bool, optional
485
+ If True, keep the latest stable, prerelease and "dev" versions.
486
+ If False, delete all locally cached versions (default is False).
487
+ kwargs : dict, optional
488
+ Additional parameters to pass to the function.
489
+
490
+ Returns
491
+ -------
492
+ None
493
+
494
+ Raises
495
+ ------
496
+ ValueError
497
+ If the known kwargs have an invalid value.
498
+ Warning
499
+ If 'keep_latest' option is active when 'version' is not 'all'.
500
+ """
501
+ logger = get_logger()
502
+ # Get locally cached versions
503
+ local_versions = get_cached(**kwargs)
504
+
505
+ if version == "all":
506
+ if keep_latest:
507
+ # Identify the latest stable and prerelease versions
508
+ valid_versions = [v for v in local_versions if _version_pattern.match(v)]
509
+ valid_sversions = [
510
+ v for v in valid_versions if "a" not in v and "b" not in v
511
+ ]
512
+ latest = False
513
+ latest_stable = False
514
+ if valid_versions:
515
+ latest = max(valid_versions, key=_parse_version)
516
+ if valid_sversions:
517
+ latest_stable = max(valid_sversions, key=_parse_version)
518
+ to_keep = [v for v in ["dev", latest, latest_stable] if v]
519
+ local_versions = [v for v in local_versions if v not in to_keep]
520
+ else:
521
+ if keep_latest:
522
+ warnings.warn(
523
+ "'keep_latest' option is ignored when 'version' is not 'all'."
524
+ )
525
+ local_versions = [version] if version in local_versions else []
526
+
527
+ # Deletion
528
+ if local_versions:
529
+ logger.info("Deleting the following version(s):")
530
+ logger.info(local_versions)
531
+ else:
532
+ logger.info("No version(s) found to delete.")
533
+ return
534
+
535
+ # Compile file paths
536
+ cached_files = []
537
+ cached_files_raw = [os.path.join(_dreq_res, v, _json_raw) for v in local_versions]
538
+ cached_files_release = [
539
+ os.path.join(_dreq_res, v, _json_release) for v in local_versions
540
+ ]
541
+ if "export" in kwargs:
542
+ if kwargs["export"] == "raw":
543
+ cached_files = cached_files_raw
544
+ elif kwargs["export"] == "release":
545
+ cached_files = cached_files_release
546
+ else:
547
+ raise ValueError(f"Unknown export type '{kwargs['export']}'.")
548
+ else:
549
+ cached_files = cached_files_raw + cached_files_release
550
+
551
+ # Delete files
552
+ for f in cached_files:
553
+ if os.path.isfile(f):
554
+ if "dryrun" in kwargs and kwargs["dryrun"]:
555
+ logger.info(f"Dryrun: would delete '{f}'.")
556
+ else:
557
+ os.remove(f)
558
+
559
+
560
+ def load(version="latest_stable", **kwargs):
561
+ """Load the JSON file for the specified version.
562
+
563
+ Args:
564
+ version (str): The version to load.
565
+ Can be 'latest', 'latest_stable', 'dev',
566
+ or a specific version, eg. '1.0.0'.
567
+ The default is 'latest_stable'.
568
+ kwargs (dict): Additional parameters to pass to the retrieve function
569
+ Returns:
570
+ dict: of the loaded JSON file.
571
+ """
572
+ _dreq_content_loaded['json_path'] = ''
573
+ logger = get_logger()
574
+ if version == "all":
575
+ raise ValueError("Cannot load 'all' versions.")
576
+
577
+ version_dict = retrieve(version, **kwargs)
578
+ if version_dict == {}:
579
+ logger.info(f"Version '{version}' could not be loaded.")
580
+ return {}
581
+ else:
582
+ json_path = next(iter(version_dict.values()))
583
+ logger.info(f"Loading version {next(iter(version_dict.keys()))}'.")
584
+
585
+ _dreq_content_loaded['json_path'] = json_path
586
+ with open(json_path) as f:
587
+ if "consolidate" in kwargs:
588
+ if kwargs["consolidate"]:
589
+ return ce.map_data(json.load(f), mapping_table)
590
+ else:
591
+ return json.load(f)
592
+ else:
593
+ return ce.map_data(json.load(f), mapping_table)