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.
- CMIP7_data_request_api-1.1.2.dist-info/LICENSE +21 -0
- CMIP7_data_request_api-1.1.2.dist-info/METADATA +210 -0
- CMIP7_data_request_api-1.1.2.dist-info/RECORD +36 -0
- CMIP7_data_request_api-1.1.2.dist-info/WHEEL +5 -0
- CMIP7_data_request_api-1.1.2.dist-info/entry_points.txt +2 -0
- CMIP7_data_request_api-1.1.2.dist-info/top_level.txt +1 -0
- data_request_api/__init__.py +1 -0
- data_request_api/command_line/__init__.py +0 -0
- data_request_api/command_line/export_dreq_lists_json.py +136 -0
- data_request_api/dev/JA/__init__.py +0 -0
- data_request_api/dev/JA/check_plev_requests.py +416 -0
- data_request_api/dev/JA/read_feedback_spreadsheet.py +141 -0
- data_request_api/dev/JA/workflow_example_GRtest.py +275 -0
- data_request_api/dev/JA/workflow_example_test.py +222 -0
- data_request_api/dev/MM/checksum.py +68 -0
- data_request_api/dev/MM/walking_data_request.ipynb +380 -0
- data_request_api/dev/MS/dreq_content_and_walking_data_request.ipynb +3755 -0
- data_request_api/dev/__init__.py +0 -0
- data_request_api/stable/__init__.py +0 -0
- data_request_api/stable/content/README.MD +106 -0
- data_request_api/stable/content/__init__.py +0 -0
- data_request_api/stable/content/dreq_api/__init__.py +0 -0
- data_request_api/stable/content/dreq_api/consolidate_export.py +488 -0
- data_request_api/stable/content/dreq_api/dreq_content.py +593 -0
- data_request_api/stable/content/dreq_api/mapping_table.py +335 -0
- data_request_api/stable/content/dreq_api/test_dreq_content.py +194 -0
- data_request_api/stable/content/dump_transformation.py +550 -0
- data_request_api/stable/query/__init__.py +0 -0
- data_request_api/stable/query/data_request.py +1120 -0
- data_request_api/stable/query/dreq_classes.py +372 -0
- data_request_api/stable/query/dreq_query.py +981 -0
- data_request_api/stable/query/vocabulary_server.py +208 -0
- data_request_api/stable/utilities/__init__.py +0 -0
- data_request_api/stable/utilities/logger.py +71 -0
- data_request_api/stable/utilities/tools.py +49 -0
- data_request_api/version.py +16 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'''
|
|
2
|
+
Flexible classes to represent tables, records & links in the data request,
|
|
3
|
+
as obtained from the Airtable json "raw export" of data request content.
|
|
4
|
+
|
|
5
|
+
The purpose is to create generic objects allowing intuitive navigation/coding
|
|
6
|
+
of the data request "network" (i.e., linked records). While the dict variables from
|
|
7
|
+
the export can be used directly for this, manipulating them is more complex and
|
|
8
|
+
error-prone.
|
|
9
|
+
|
|
10
|
+
Each record from a table is represented as a dreq_record object.
|
|
11
|
+
The object's attribute names are determined automatically from the Airtable field
|
|
12
|
+
names, which are the names of table columns in Airtable, following simple formatting
|
|
13
|
+
rules (e.g. change space to underscore). Original names of Airtable fields
|
|
14
|
+
are stored as well, allowing unambiguous comparison with Airtable content.
|
|
15
|
+
'''
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from dataclasses import field as dataclass_field # "field" is used often for Airtable column names, so need a different name here
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
PYTHON_VERSION = (sys.version_info.major, sys.version_info.minor)
|
|
22
|
+
if PYTHON_VERSION < (3,9):
|
|
23
|
+
from typing import Set
|
|
24
|
+
|
|
25
|
+
UNIQUE_VAR_NAME = 'compound name' # method used to uniquely name variables
|
|
26
|
+
|
|
27
|
+
PRIORITY_LEVELS = ('core', 'high', 'medium', 'low') # names of priority levels, ordered from highest to lowest priority
|
|
28
|
+
|
|
29
|
+
def format_attribute_name(k):
|
|
30
|
+
'''
|
|
31
|
+
Adjust input string so that it's suitable for use as an object attribute name using the dot syntax (object.attribute).
|
|
32
|
+
'''
|
|
33
|
+
k = k.strip()
|
|
34
|
+
k = k.lower()
|
|
35
|
+
substitute = {
|
|
36
|
+
# replacement character(s) : [characters to replace with the replacement character]
|
|
37
|
+
'_' : list(' .-+=?!@#$%^*:;') + ['_&_', '/', '\\'],
|
|
38
|
+
'' : list('(){}[]<>|,"~'),
|
|
39
|
+
# Note: list(str) = [single chars in the string], example: list('ab') = ['a', 'b']
|
|
40
|
+
}
|
|
41
|
+
for replacement in substitute:
|
|
42
|
+
for s in substitute[replacement]:
|
|
43
|
+
k = k.replace(s, replacement)
|
|
44
|
+
check_for_invalid_chars = ['&', '/', '-']
|
|
45
|
+
for s in check_for_invalid_chars:
|
|
46
|
+
assert s not in k, f'{s} is invalid character for attribute {k}'
|
|
47
|
+
return k
|
|
48
|
+
|
|
49
|
+
###############################################################################
|
|
50
|
+
# Generic classes
|
|
51
|
+
# (not specific to different data request tables)
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class dreq_link:
|
|
55
|
+
'''
|
|
56
|
+
Generic class to represent a link to a record in a table.
|
|
57
|
+
|
|
58
|
+
The table_id, record_id reference the record. They are used to locate a record.
|
|
59
|
+
'''
|
|
60
|
+
table_id : str
|
|
61
|
+
record_id : str
|
|
62
|
+
table_name : str # useful as a human-readable lable
|
|
63
|
+
# record_name : str # not all tables have a "name" attribute, so would need to choose this based on table
|
|
64
|
+
|
|
65
|
+
def __repr__(self):
|
|
66
|
+
# return self.record_id
|
|
67
|
+
return f'link: table={self.table_name}, record={self.record_id}'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class dreq_record:
|
|
71
|
+
'''
|
|
72
|
+
Generic class to represent a single record from a table.
|
|
73
|
+
'''
|
|
74
|
+
def __init__(self, record, field_info):
|
|
75
|
+
# Loop over fields in the record
|
|
76
|
+
for field_name, value in record.items():
|
|
77
|
+
|
|
78
|
+
# Check if the field contains links to records in other tables
|
|
79
|
+
if 'linked_table_id' in field_info[field_name]:
|
|
80
|
+
assert isinstance(value, list), 'links should be a list of record identifiers'
|
|
81
|
+
for m, record_id in enumerate(value):
|
|
82
|
+
# Change the record_id str into a more informative object representing the link
|
|
83
|
+
d = {
|
|
84
|
+
'table_id' : field_info[field_name]['linked_table_id'],
|
|
85
|
+
'table_name' : field_info[field_name]['linked_table_name'],
|
|
86
|
+
'record_id' : record_id,
|
|
87
|
+
# 'record_name' : '', # fill this in later if desired (finding it here would require access to whole base)
|
|
88
|
+
}
|
|
89
|
+
value[m] = dreq_link(**d)
|
|
90
|
+
|
|
91
|
+
# Adjust the field name so that it's accessible as an object attribute using the dot syntax (object.attribute)
|
|
92
|
+
key = field_info[field_name]['attribute_name']
|
|
93
|
+
assert not hasattr(self, key), f'for field {field_name}, key already exists: {key}'
|
|
94
|
+
setattr(self, key, value)
|
|
95
|
+
|
|
96
|
+
def __repr__(self):
|
|
97
|
+
# return pprint.pformat(vars(self))
|
|
98
|
+
l = []
|
|
99
|
+
show_list_entries = 2
|
|
100
|
+
for k,v in self.__dict__.items():
|
|
101
|
+
s = f' {k}: '
|
|
102
|
+
if isinstance(v, list):
|
|
103
|
+
# If attribute is a list of links, show only show_list_entries of them.
|
|
104
|
+
# This makes it easier to view records that contain very long lists of links.
|
|
105
|
+
indent = ' '*len(s)
|
|
106
|
+
n = len(v)
|
|
107
|
+
s += f'{v[0]}'
|
|
108
|
+
for m in range(1, min(show_list_entries,n)):
|
|
109
|
+
s += '\n' + indent + f'{v[m]}'
|
|
110
|
+
if n > show_list_entries:
|
|
111
|
+
# s += '\n' + indent + f'... ({n} entries)'
|
|
112
|
+
s += '\n' + indent + f'... ({n} in list, first {show_list_entries} shown)'
|
|
113
|
+
else:
|
|
114
|
+
# Attribute is just a regular string or number.
|
|
115
|
+
s = f'{s}{v}'
|
|
116
|
+
l.append(s)
|
|
117
|
+
return '\n' + '\n'.join(l)
|
|
118
|
+
|
|
119
|
+
def __eq__(self, other):
|
|
120
|
+
return self.__dict__ == other.__dict__
|
|
121
|
+
|
|
122
|
+
class dreq_table:
|
|
123
|
+
'''
|
|
124
|
+
Generic class to represent an table from the data request Airtable raw export json file (dict).
|
|
125
|
+
|
|
126
|
+
Here both "field" and "attribute" are used to refer to the columns in the table.
|
|
127
|
+
"field" refers to the name of a column as it appears in Airtable.
|
|
128
|
+
"attribute" refers to the name of the column converted to the name of a record object attribute.
|
|
129
|
+
'''
|
|
130
|
+
def __init__(self, table, table_id2name):
|
|
131
|
+
|
|
132
|
+
# Set attributes that describe the table
|
|
133
|
+
self.table_id = table['id']
|
|
134
|
+
self.table_name = table['name']
|
|
135
|
+
self.base_id = table['base_id']
|
|
136
|
+
self.base_name = table['base_name']
|
|
137
|
+
self.description = table['description']
|
|
138
|
+
|
|
139
|
+
# Get info about fields (columns) in the table records, which are used below when creating record objects
|
|
140
|
+
fields = table['fields'] # dict giving info on each field, keyed by field_id (example: 'fld61d8b5mzI45H8F')
|
|
141
|
+
field_info = {field['name'] : field for field in fields.values()} # as fields dict, but use field name as the key
|
|
142
|
+
assert len(fields) == len(field_info), 'field names are not unique!'
|
|
143
|
+
# (since field names are keys in record dicts, their names should be unique)
|
|
144
|
+
attr2field = {}
|
|
145
|
+
links = {}
|
|
146
|
+
for field_name, field in field_info.items():
|
|
147
|
+
# Determine an attribute name for the field.
|
|
148
|
+
# The field name is the name from Airtable, but it may include spaces or other forbidden characters.
|
|
149
|
+
attr = format_attribute_name(field_name)
|
|
150
|
+
field['attribute_name'] = attr
|
|
151
|
+
attr2field[attr] = field_name # remember the Airtable name, in case useful later
|
|
152
|
+
# If field is a link, add the name of the linked table to field_info.
|
|
153
|
+
if 'linked_table_id' in field:
|
|
154
|
+
field['linked_table_name'] = table_id2name[field['linked_table_id']]
|
|
155
|
+
links[attr] = field['linked_table_name']
|
|
156
|
+
|
|
157
|
+
# Loop over records to create a record object representing each one
|
|
158
|
+
records = table['records'] # dict giving info on each record, keyed by record_id (example: 'reczyxsKbAseqCisA')
|
|
159
|
+
for record_id, record in records.items():
|
|
160
|
+
if len(record) == 0:
|
|
161
|
+
# don't allow empty records!
|
|
162
|
+
# print(f'skipping empty record {record_id} in table {self.table_name}')
|
|
163
|
+
continue
|
|
164
|
+
# Replace record dict with a record object
|
|
165
|
+
records[record_id] = dreq_record(record, field_info)
|
|
166
|
+
|
|
167
|
+
# attributes for the collection of records (table rows)
|
|
168
|
+
self.records = records
|
|
169
|
+
self.record_ids = sorted(self.records.keys(), key=str.lower)
|
|
170
|
+
self.nrec = len(self.record_ids)
|
|
171
|
+
|
|
172
|
+
# attributes describing the attributes (columns) in each individual record
|
|
173
|
+
self.field_info = field_info
|
|
174
|
+
self.attr2field = attr2field
|
|
175
|
+
self.links = links
|
|
176
|
+
|
|
177
|
+
def rename_attr(self, old, new):
|
|
178
|
+
if old in self.attr2field:
|
|
179
|
+
assert new not in self.attr2field, 'Record attribute already exists: ' + new
|
|
180
|
+
|
|
181
|
+
field_name = self.attr2field[old]
|
|
182
|
+
self.field_info[field_name]['attribute_name'] = new
|
|
183
|
+
|
|
184
|
+
self.attr2field[new] = self.attr2field[old]
|
|
185
|
+
self.attr2field.pop(old)
|
|
186
|
+
|
|
187
|
+
if old in self.links:
|
|
188
|
+
self.links[new] = self.links[old]
|
|
189
|
+
self.links.pop(old)
|
|
190
|
+
|
|
191
|
+
for record in self.records.values():
|
|
192
|
+
if not hasattr(record, old):
|
|
193
|
+
continue
|
|
194
|
+
setattr(record, new, getattr(record, old))
|
|
195
|
+
delattr(record, old)
|
|
196
|
+
|
|
197
|
+
def __repr__(self):
|
|
198
|
+
#return f'Table: {self.table_name}, records: {self.nrec}'
|
|
199
|
+
s = f'table: {self.table_name}'
|
|
200
|
+
s += f'\ndescription: {self.description}'
|
|
201
|
+
s += f'\nrecords (rows): {self.nrec}'
|
|
202
|
+
s += '\nattributes (columns): ' + ', '.join(sorted(self.attr2field))
|
|
203
|
+
if len(self.links) > 0:
|
|
204
|
+
s += '\nlinks to other tables:' # ({}):'.format(len(self.links))
|
|
205
|
+
for attr, target in sorted(self.links.items()):
|
|
206
|
+
s += f'\n {attr} -> {target}'
|
|
207
|
+
return s
|
|
208
|
+
|
|
209
|
+
def get_record(self, m):
|
|
210
|
+
if isinstance(m, int):
|
|
211
|
+
# argument is index of the record in the list of record_ids
|
|
212
|
+
return self.records[self.record_ids[m]]
|
|
213
|
+
elif isinstance(m, str):
|
|
214
|
+
# argument is a record id string
|
|
215
|
+
return self.records[m]
|
|
216
|
+
elif isinstance(m, dreq_link):
|
|
217
|
+
# argument is dreq_link instance, which contains a record id
|
|
218
|
+
return self.records[m.record_id]
|
|
219
|
+
else:
|
|
220
|
+
raise TypeError(f'Error specifying record to retrieve from table {self.table_name}')
|
|
221
|
+
|
|
222
|
+
def get_attr_record(self, attr, value, unique=True):
|
|
223
|
+
if attr in self.attr2field:
|
|
224
|
+
records = [record for record in self.records.values() if getattr(record, attr) == value]
|
|
225
|
+
if len(records) == 0:
|
|
226
|
+
raise Exception(f'No record found for {attr}={value}')
|
|
227
|
+
if unique:
|
|
228
|
+
return records[0]
|
|
229
|
+
else:
|
|
230
|
+
return records
|
|
231
|
+
else:
|
|
232
|
+
raise Exception(f'Record attribute does not exist: {attr}')
|
|
233
|
+
|
|
234
|
+
def get_record_id(self, record):
|
|
235
|
+
# In case we need to get a record_id when we only have the record.
|
|
236
|
+
# For example, to use delete_record() to remove the record.
|
|
237
|
+
l = [record_id for record_id,rec in self.records.items() if rec == record]
|
|
238
|
+
if len(l) == 1:
|
|
239
|
+
return l[0]
|
|
240
|
+
else:
|
|
241
|
+
raise Exception('Could not find record_id matching the record')
|
|
242
|
+
|
|
243
|
+
def delete_record(self, record_id):
|
|
244
|
+
self.records.pop(record_id)
|
|
245
|
+
self.record_ids.remove(record_id)
|
|
246
|
+
self.nrec -= 1
|
|
247
|
+
|
|
248
|
+
def __eq__(self, other):
|
|
249
|
+
return self.__dict__ == other.__dict__
|
|
250
|
+
|
|
251
|
+
###############################################################################
|
|
252
|
+
# Non-generic classes, i.e. they have a specific function in the data request
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class expt_request:
|
|
256
|
+
'''
|
|
257
|
+
Object to store variables requested for an experiment.
|
|
258
|
+
Variable names are stored in seperate sets for different priority levels.
|
|
259
|
+
'''
|
|
260
|
+
experiment : str
|
|
261
|
+
if PYTHON_VERSION < (3,9):
|
|
262
|
+
# Required for python versions before 3.9, see:
|
|
263
|
+
# https://stackoverflow.com/questions/75202610/typeerror-type-object-is-not-subscriptable-python
|
|
264
|
+
# Should remove this if we decide not to support versions before 3.9.
|
|
265
|
+
core : Set[str] = dataclass_field(default_factory=set)
|
|
266
|
+
high : Set[str] = dataclass_field(default_factory=set)
|
|
267
|
+
medium : Set[str] = dataclass_field(default_factory=set)
|
|
268
|
+
low : Set[str] = dataclass_field(default_factory=set)
|
|
269
|
+
else:
|
|
270
|
+
core : set[str] = dataclass_field(default_factory=set)
|
|
271
|
+
high : set[str] = dataclass_field(default_factory=set)
|
|
272
|
+
medium : set[str] = dataclass_field(default_factory=set)
|
|
273
|
+
low : set[str] = dataclass_field(default_factory=set)
|
|
274
|
+
|
|
275
|
+
def __post_init__(self):
|
|
276
|
+
for p in PRIORITY_LEVELS:
|
|
277
|
+
assert hasattr(self, p), 'expt_request object missing priority level: ' + p
|
|
278
|
+
self.consistency_check()
|
|
279
|
+
|
|
280
|
+
def add_vars(self, var_names, priority_level):
|
|
281
|
+
'''
|
|
282
|
+
Add variables to output from the experiment, at the specified priority level.
|
|
283
|
+
Removes overlaps between priority levels (e.g., if adding a variable at high
|
|
284
|
+
priority that is already requested at medium priority, it is removed from the
|
|
285
|
+
medium priority list).
|
|
286
|
+
|
|
287
|
+
Parameters
|
|
288
|
+
----------
|
|
289
|
+
var_names : set
|
|
290
|
+
Set of unique variable names to be added.
|
|
291
|
+
priority_level : str
|
|
292
|
+
Priority level at which to add them.
|
|
293
|
+
Not case sensitive (will be rendered as lower case).
|
|
294
|
+
|
|
295
|
+
Returns
|
|
296
|
+
-------
|
|
297
|
+
expt_request object is updated with the new variables, and any overlaps removed.
|
|
298
|
+
'''
|
|
299
|
+
priority_level = priority_level.lower()
|
|
300
|
+
current_vars = getattr(self, priority_level)
|
|
301
|
+
current_vars.update(var_names)
|
|
302
|
+
# Remove any overlaps by ensuring a variable only appears at its highest
|
|
303
|
+
# requested priority level.
|
|
304
|
+
self.high = self.high.difference(self.core)
|
|
305
|
+
|
|
306
|
+
self.medium = self.medium.difference(self.core)
|
|
307
|
+
self.medium = self.medium.difference(self.high) # remove any high priority vars from medium priority group
|
|
308
|
+
|
|
309
|
+
self.low = self.low.difference(self.core)
|
|
310
|
+
self.low = self.low.difference(self.high) # remove any high priority vars from low priority group
|
|
311
|
+
self.low = self.low.difference(self.medium) # remove any medium priority vars from low priority group
|
|
312
|
+
|
|
313
|
+
self.consistency_check()
|
|
314
|
+
|
|
315
|
+
def consistency_check(self):
|
|
316
|
+
# Confirm that priority sets don't overlap
|
|
317
|
+
# assert self.high.intersection(self.medium.union(self.low)) == set()
|
|
318
|
+
# assert self.medium.intersection(self.high.union(self.low)) == set()
|
|
319
|
+
# assert self.low.intersection(self.high.union(self.medium)) == set()
|
|
320
|
+
for this_p in PRIORITY_LEVELS:
|
|
321
|
+
other_p = [p for p in PRIORITY_LEVELS if p != this_p]
|
|
322
|
+
for p in other_p:
|
|
323
|
+
assert getattr(self, this_p).intersection( getattr(self, p) ) == set()
|
|
324
|
+
|
|
325
|
+
# Also confirm object contains the expected priority levels
|
|
326
|
+
pl = list(vars(self))
|
|
327
|
+
pl.remove('experiment')
|
|
328
|
+
assert set(pl) == set(PRIORITY_LEVELS)
|
|
329
|
+
|
|
330
|
+
def __repr__(self):
|
|
331
|
+
self.consistency_check()
|
|
332
|
+
break_up_compound_name = not True
|
|
333
|
+
l = [f'Variables (by priority) for experiment: {self.experiment}']
|
|
334
|
+
for p in PRIORITY_LEVELS:
|
|
335
|
+
req = getattr(self, p)
|
|
336
|
+
if len(req) == 0:
|
|
337
|
+
continue
|
|
338
|
+
n = len(req)
|
|
339
|
+
s = f' {p} ({n}): '
|
|
340
|
+
indent = ' '*len(s)
|
|
341
|
+
sortby = str.lower
|
|
342
|
+
req = sorted(req, key=sortby)
|
|
343
|
+
if break_up_compound_name and UNIQUE_VAR_NAME == 'compound name':
|
|
344
|
+
# for better readability, show all vars in each cmor table on one line
|
|
345
|
+
separator = '.'
|
|
346
|
+
lt = [tuple(varname.split(separator)) for varname in req]
|
|
347
|
+
tables = sorted(set([t[0] for t in lt]), key=sortby)
|
|
348
|
+
req = []
|
|
349
|
+
for table in tables:
|
|
350
|
+
varnames = sorted(set([t[1] for t in lt if t[0] == table]), key=sortby)
|
|
351
|
+
n = len(varnames)
|
|
352
|
+
req.append(f'{table} ({n}): ' + ', '.join(varnames))
|
|
353
|
+
s += req[0]
|
|
354
|
+
for varname in req[1:]:
|
|
355
|
+
s += '\n' + indent + varname
|
|
356
|
+
l.append(s)
|
|
357
|
+
return '\n'.join(l)
|
|
358
|
+
|
|
359
|
+
def to_dict(self):
|
|
360
|
+
'''
|
|
361
|
+
Return dict equivalent of the object, suitable to write to json.
|
|
362
|
+
'''
|
|
363
|
+
sortby = str.lower
|
|
364
|
+
return {
|
|
365
|
+
self.experiment : {
|
|
366
|
+
'Core' : sorted(self.core, key=sortby),
|
|
367
|
+
'High' : sorted(self.high, key=sortby),
|
|
368
|
+
'Medium' : sorted(self.medium, key=sortby),
|
|
369
|
+
'Low' : sorted(self.low, key=sortby),
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|