tol-sdk 1.7.4__py3-none-any.whl → 1.7.5b0__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 (45) hide show
  1. tol/api_base/__init__.py +1 -0
  2. tol/api_base/blueprint.py +19 -8
  3. tol/{s3/data_upload/blueprint.py → api_base/data_upload.py} +21 -6
  4. tol/api_base/pipeline_steps.py +4 -4
  5. tol/api_client/api_datasource.py +8 -8
  6. tol/api_client/converter.py +38 -52
  7. tol/api_client/factory.py +21 -19
  8. tol/api_client/parser.py +138 -98
  9. tol/api_client/view.py +118 -43
  10. tol/core/__init__.py +2 -1
  11. tol/core/data_object.py +27 -9
  12. tol/core/data_object_converter.py +37 -2
  13. tol/core/factory.py +51 -62
  14. tol/core/validate.py +1 -0
  15. tol/ena/client.py +60 -10
  16. tol/ena/ena_datasource.py +16 -10
  17. tol/ena/ena_methods.py +33 -32
  18. tol/ena/parser.py +15 -2
  19. tol/s3/__init__.py +0 -1
  20. tol/sql/model.py +1 -1
  21. tol/sql/pipeline_step/factory.py +1 -1
  22. tol/sql/sql_converter.py +7 -1
  23. tol/validators/__init__.py +12 -1
  24. tol/validators/allowed_keys.py +17 -12
  25. tol/validators/allowed_values.py +21 -63
  26. tol/validators/allowed_values_from_datasource.py +91 -0
  27. tol/validators/assert_on_condition.py +56 -0
  28. tol/validators/ena_submittable.py +61 -0
  29. tol/{s3/data_upload → validators/interfaces}/__init__.py +2 -0
  30. tol/validators/interfaces/condition_evaluator.py +61 -0
  31. tol/validators/min_one_valid_value.py +55 -0
  32. tol/validators/mutually_exclusive.py +107 -0
  33. tol/validators/regex.py +10 -24
  34. tol/validators/regex_by_value.py +14 -33
  35. tol/validators/specimens_have_same_taxon.py +60 -0
  36. tol/validators/sts_fields.py +88 -0
  37. tol/validators/tolid.py +110 -0
  38. tol/validators/unique_values.py +25 -17
  39. tol/validators/unique_whole_organisms.py +109 -0
  40. {tol_sdk-1.7.4.dist-info → tol_sdk-1.7.5b0.dist-info}/METADATA +1 -1
  41. {tol_sdk-1.7.4.dist-info → tol_sdk-1.7.5b0.dist-info}/RECORD +45 -35
  42. {tol_sdk-1.7.4.dist-info → tol_sdk-1.7.5b0.dist-info}/WHEEL +0 -0
  43. {tol_sdk-1.7.4.dist-info → tol_sdk-1.7.5b0.dist-info}/entry_points.txt +0 -0
  44. {tol_sdk-1.7.4.dist-info → tol_sdk-1.7.5b0.dist-info}/licenses/LICENSE +0 -0
  45. {tol_sdk-1.7.4.dist-info → tol_sdk-1.7.5b0.dist-info}/top_level.txt +0 -0
tol/api_base/__init__.py CHANGED
@@ -7,5 +7,6 @@ from .blueprint import ( # noqa
7
7
  custom_blueprint,
8
8
  data_blueprint
9
9
  )
10
+ from .data_upload import data_upload_blueprint # noqa
10
11
  from .pipeline_steps import pipeline_steps_blueprint # noqa
11
12
  from .system import system_blueprint # noqa
tol/api_base/blueprint.py CHANGED
@@ -251,6 +251,18 @@ def _core_blueprint(
251
251
  )
252
252
  return Controller(data_source, view, req_fields_tree, auth_inspector=auth_inspector)
253
253
 
254
+ def __new_parser(
255
+ object_type: str,
256
+ ):
257
+ data_source = data_source_dict[object_type]
258
+ # Build a ReqFieldsTree template for the request
259
+ req_fields_tree = ReqFieldsTree(
260
+ object_type,
261
+ data_source,
262
+ include_all_to_ones=include_all_to_ones,
263
+ )
264
+ return DefaultParser(data_source_dict, requested_tree=req_fields_tree)
265
+
254
266
  @data_handler.route('/<object_type>/<path:object_id>', methods=['GET']) # Allow slashes
255
267
  def get_detail(*, object_type: str, object_id: str):
256
268
  """Get details of a specific object by ID."""
@@ -311,19 +323,17 @@ def _core_blueprint(
311
323
  def post_inserts(*, object_type: str):
312
324
  """Insert new objects of the specified type."""
313
325
  controller = __new_controller(object_type)
314
- request_body = JsonApiRequestBody(request.json)
315
- parser = DefaultParser(data_source_dict)
316
- objects = parser.parse_iterable(request_body.data)
326
+ parser = __new_parser(object_type)
327
+ objects = parser.parse_json_doc(request.json)
317
328
  return controller.post_inserts(object_type, objects)
318
329
 
319
330
  @data_handler.route('/<object_type>:upsert', methods=['POST'])
320
331
  def post_upserts(*, object_type: str):
321
332
  """Insert or update objects of the specified type."""
322
- controller = __new_controller(object_type)
323
333
  request_args = ListGetParameters(request.args)
324
- request_body = JsonApiRequestBody(request.json)
325
- parser = DefaultParser(data_source_dict)
326
- objects = parser.parse_iterable(request_body.data)
334
+ controller = __new_controller(object_type)
335
+ parser = __new_parser(object_type)
336
+ objects = parser.parse_json_doc(request.json)
327
337
  return controller.post_upserts(
328
338
  object_type,
329
339
  objects,
@@ -347,7 +357,8 @@ def _core_blueprint(
347
357
  requested_fields=request_args.requested_fields,
348
358
  )
349
359
  search_after = request.json.get('search_after')
350
- return controller.get_cursor_page(object_type, request_args, search_after)
360
+ page = controller.get_cursor_page(object_type, request_args, search_after)
361
+ return page
351
362
 
352
363
  @data_handler.route('/<object_type>:to-one/<object_id>/<path:hops_suffix>', methods=['GET'])
353
364
  def get_to_one_relation(*, object_type: str, object_id: str, hops_suffix: str):
@@ -2,15 +2,17 @@
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
4
 
5
+ import os
6
+ import uuid
5
7
  from tempfile import NamedTemporaryFile
6
8
  from typing import Any
7
9
 
8
10
  from flask import Blueprint, request, send_file
9
11
 
10
- from ...api_base import (
12
+ from .blueprint import (
11
13
  custom_blueprint,
12
14
  )
13
- from ...services.s3_client import S3Client
15
+ from ..services.s3_client import S3Client
14
16
 
15
17
 
16
18
  ALLOWED_EXTENSIONS: set[str] = {'csv', 'json', 'xlsx'}
@@ -20,6 +22,16 @@ def allowed_file(filename: str) -> bool:
20
22
  return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
21
23
 
22
24
 
25
+ def set_filename(filename: str) -> str:
26
+ environment = os.getenv('ENVIRONMENT', 'dev')
27
+ unique_id = str(uuid.uuid4())[:8]
28
+ return f'{environment}_{unique_id}_{filename}'
29
+
30
+
31
+ def get_original_filename(s3_filename: str) -> str:
32
+ return s3_filename.split('_', maxsplit=2)[2]
33
+
34
+
23
35
  def data_upload_blueprint(
24
36
  url_prefix: str = '/pipeline/data_upload',
25
37
  ) -> Blueprint:
@@ -45,11 +57,13 @@ def data_upload_blueprint(
45
57
 
46
58
  try:
47
59
  s3_client = S3Client()
60
+ new_filename = set_filename(file.filename)
61
+
48
62
  with NamedTemporaryFile() as temp_file:
49
63
  file.save(temp_file.name)
50
- s3_client.put_object(s3_bucket, file.filename, temp_file.name)
64
+ s3_client.put_object(s3_bucket, new_filename, temp_file.name)
51
65
 
52
- return {'message': 'File uploaded successfully'}, 200
66
+ return {'message': 'File uploaded successfully', 'file_name': new_filename}, 200
53
67
 
54
68
  except Exception as e:
55
69
  return {'error': f'Failed to upload file: {str(e)}'}, 500
@@ -69,12 +83,13 @@ def data_upload_blueprint(
69
83
 
70
84
  with NamedTemporaryFile() as temp_file:
71
85
  s3_client.get_object(bucket_name, file_name, temp_file.name)
72
- download_name = request.json.get('download_name', file_name)
86
+
87
+ original = get_original_filename(file_name)
73
88
 
74
89
  return send_file(
75
90
  temp_file.name,
76
91
  as_attachment=True,
77
- download_name=download_name,
92
+ download_name=original,
78
93
  mimetype='application/octet-stream'
79
94
  )
80
95
  except Exception as e:
@@ -27,7 +27,7 @@ if typing.TYPE_CHECKING:
27
27
 
28
28
  @dataclass
29
29
  class UploadData:
30
- s3_url: str
30
+ s3_bucket: str
31
31
  s3_filename: str
32
32
  spreadsheet_config: str
33
33
  pipeline_id: int
@@ -37,7 +37,7 @@ class UploadData:
37
37
 
38
38
 
39
39
  REQUIRED_FIELDS: List = [
40
- 's3_url',
40
+ 's3_bucket',
41
41
  's3_filename',
42
42
  'spreadsheet_config',
43
43
  'pipeline_id',
@@ -105,7 +105,7 @@ def pipeline_steps_blueprint(
105
105
  upload = sql_ds.data_object_factory(
106
106
  'upload',
107
107
  attributes={
108
- 's3_url': upload_data.s3_url,
108
+ 's3_bucket': upload_data.s3_bucket,
109
109
  's3_filename': upload_data.s3_filename,
110
110
  'spreadsheet_config': upload_data.spreadsheet_config,
111
111
  'pipeline_id': upload_data.pipeline_id,
@@ -207,7 +207,7 @@ def pipeline_steps_blueprint(
207
207
  __get_pipeline(pipeline_id)
208
208
 
209
209
  upload_data = UploadData(
210
- s3_url=body['s3_url'],
210
+ s3_bucket=body['s3_bucket'],
211
211
  s3_filename=body['s3_filename'],
212
212
  spreadsheet_config=body['spreadsheet_config'],
213
213
  pipeline_id=pipeline_id,
@@ -151,7 +151,7 @@ class ApiDataSource(
151
151
  )
152
152
  for id_ in object_ids
153
153
  )
154
- json_converter = self.__jc_factory()
154
+ json_converter = self.__jc_factory(object_type, requested_fields)
155
155
  return (
156
156
  json_converter.convert(r)
157
157
  if r is not None else None
@@ -179,7 +179,7 @@ class ApiDataSource(
179
179
  sort_string=sort_by,
180
180
  requested_fields=requested_fields,
181
181
  )
182
- return self.__jc_factory().convert_list(transfer)
182
+ return self.__jc_factory(object_type, requested_fields).convert_list(transfer)
183
183
 
184
184
  def get_list(
185
185
  self,
@@ -274,7 +274,7 @@ class ApiDataSource(
274
274
  filter_string=filter_string,
275
275
  requested_fields=requested_fields,
276
276
  )
277
- return self.__jc_factory().convert_cursor_page(transfer)
277
+ return self.__jc_factory(object_type, requested_fields).convert_cursor_page(transfer)
278
278
 
279
279
  @validate('delete')
280
280
  def delete(
@@ -304,7 +304,7 @@ class ApiDataSource(
304
304
  merge_collections=merge_collections,
305
305
  )
306
306
  if self.return_mode[object_type] == ReturnMode.POPULATED:
307
- converted, _ = self.__jc_factory().convert_list(returned)
307
+ converted, _ = self.__jc_factory(object_type).convert_list(returned)
308
308
  return converted
309
309
  return [] # when the underlying DataSource doesn't return anything
310
310
 
@@ -325,7 +325,7 @@ class ApiDataSource(
325
325
  )
326
326
  if transfer is None:
327
327
  return None
328
- return self.__jc_factory().convert(transfer)
328
+ return self.__jc_factory(source.type).convert(transfer)
329
329
 
330
330
  @validate('relational', direct_object=True)
331
331
  @validate_id
@@ -359,7 +359,7 @@ class ApiDataSource(
359
359
  page,
360
360
  page_size
361
361
  )
362
- return self.__jc_factory().convert_list(transfer)
362
+ return self.__jc_factory(source.type).convert_list(transfer)
363
363
 
364
364
  @validate('relational', direct_object=True)
365
365
  @validate_id
@@ -406,7 +406,7 @@ class ApiDataSource(
406
406
  transfer
407
407
  )
408
408
  if self.return_mode[object_type] == ReturnMode.POPULATED:
409
- converted, _ = self.__jc_factory().convert_list(returned)
409
+ converted, _ = self.__jc_factory(object_type).convert_list(returned)
410
410
  return converted
411
411
 
412
412
  @property
@@ -442,7 +442,7 @@ class ApiDataSource(
442
442
  page = 1
443
443
  page_size = self.get_page_size()
444
444
  client = self.__client_factory()
445
- jc_converter = self.__jc_factory()
445
+ jc_converter = self.__jc_factory(object_type, requested_fields)
446
446
  filter_string = self.__get_filter_string(object_filters)
447
447
 
448
448
  while True:
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
4
 
5
- from typing import Any, Dict, Optional, Union
5
+ from typing import Any
6
6
 
7
7
  from .parser import Parser
8
8
  from .view import DefaultView
@@ -11,22 +11,18 @@ from ..core.relationship import RelationshipConfig
11
11
 
12
12
 
13
13
  JsonApiObject = dict[str, Any]
14
- JsonApiTransfer = dict[
15
- str,
16
- Union[JsonApiObject, list[JsonApiObject]]
17
- ]
14
+ JsonApiTransfer = dict[str, JsonApiObject | list[JsonApiObject]]
18
15
  JsonRelationship = dict[
19
16
  str, # "one" or "many"
20
- dict[str, str] # relationship_name:target_type
17
+ dict[str, str], # relationship_name:target_type
21
18
  ]
22
19
  JsonRelationshipConfig = dict[
23
20
  str, # the object_type
24
- JsonRelationship
21
+ JsonRelationship,
25
22
  ]
26
23
 
27
24
 
28
25
  class JsonApiConverter:
29
-
30
26
  """
31
27
  Converts from JSON:API transfers to instances of
32
28
  `DataObject`.
@@ -35,89 +31,82 @@ class JsonApiConverter:
35
31
  def __init__(
36
32
  self,
37
33
  parser: Parser,
38
- data_key: str = 'data',
39
- meta_key: str = 'meta'
40
34
  ) -> None:
41
-
42
35
  self.__parser = parser
43
- self.__data_key = data_key
44
- self.__meta_key = meta_key
45
36
 
46
- def convert(self, input_: JsonApiTransfer) -> DataObject:
37
+ def convert(
38
+ self,
39
+ json_xfer: JsonApiTransfer,
40
+ ) -> DataObject:
47
41
  """
48
42
  Converts a JsonApiTransfer containing a detail (single) result
49
43
  """
50
44
 
51
- json_obj = input_[self.__data_key]
52
- return self.__parser.parse(json_obj)
45
+ obj_list = self.__parser.parse_json_doc(json_xfer)
46
+ return obj_list[0] if obj_list else None
53
47
 
54
48
  def convert_list(
55
49
  self,
56
- input_: JsonApiTransfer
57
- ) -> tuple[list[DataObject], Optional[int]]:
50
+ json_xfer: JsonApiTransfer,
51
+ ) -> tuple[list[DataObject], int | None]:
58
52
  """
59
53
  Converts a JsonApiTransfer containing a list of results. Also
60
54
  returns a count of the total results meeting.
61
55
  """
62
56
 
63
- json_obj_list = input_[self.__data_key]
64
- total_count = input_.get('meta', {}).get('total', None)
65
- return [
66
- self.__parser.parse(json_obj)
67
- for json_obj in json_obj_list
68
- ], total_count
57
+ objs = self.__parser.parse_json_doc(json_xfer)
58
+ total_count = json_xfer.get('meta', {}).get('total', None)
59
+ return objs, total_count
69
60
 
70
61
  def convert_count(
71
62
  self,
72
- input_: JsonApiTransfer
73
- ) -> Dict[str, Any]:
63
+ json_xfer: JsonApiTransfer,
64
+ ) -> dict[str, Any]:
74
65
  """
75
66
  Converts a JsonApiTransfer containing a list of stats.
76
67
  """
77
68
 
78
- stats = input_[self.__meta_key]
69
+ stats = json_xfer['meta']
79
70
  return stats['total']
80
71
 
81
72
  def convert_stats(
82
73
  self,
83
- input_: JsonApiTransfer
84
- ) -> Dict[str, Any]:
74
+ json_xfer: JsonApiTransfer,
75
+ ) -> dict[str, Any]:
85
76
  """
86
77
  Converts a JsonApiTransfer containing a list of stats.
87
78
  """
88
79
 
89
- stats = input_[self.__meta_key]
80
+ stats = json_xfer['meta']
90
81
  return self.__parser.parse_stats(stats)
91
82
 
92
83
  def convert_group_stats(
93
84
  self,
94
- input_: JsonApiTransfer
95
- ) -> Dict[str, Any]:
85
+ json_xfer: JsonApiTransfer,
86
+ ) -> dict[str, Any]:
96
87
  """
97
88
  Converts a JsonApiTransfer containing a list of grouped stats.
98
89
  """
99
90
 
100
- stats = input_[self.__meta_key]
91
+ stats = json_xfer['meta']
101
92
  return self.__parser.parse_group_stats(stats)
102
93
 
103
94
  def convert_cursor_page(
104
95
  self,
105
- input_: JsonApiTransfer
96
+ json_xfer: JsonApiTransfer,
106
97
  ) -> tuple[list[DataObject], list[str] | None]:
107
98
  """
108
99
  Converts a `JsonApiTransfer` of a cursor-page
109
100
  """
110
101
 
111
- objs = self.__parser.parse_iterable(
112
- input_[self.__data_key]
113
- )
114
- search_after = input_.get('meta', {}).get('search_after')
102
+ objs = self.__parser.parse_json_doc(json_xfer)
103
+ search_after = json_xfer.get('meta', {}).get('search_after')
115
104
 
116
105
  return objs, search_after
117
106
 
118
107
  def convert_relationship_config(
119
108
  self,
120
- config_transfer: JsonRelationshipConfig
109
+ config_transfer: JsonRelationshipConfig,
121
110
  ) -> dict[str, RelationshipConfig]:
122
111
  """
123
112
  Converts a `JsonRelationshipConfig` dict, returned from
@@ -127,23 +116,20 @@ class JsonApiConverter:
127
116
 
128
117
  return {
129
118
  type_: self.__convert_relationship(rel)
130
- for type_, rel
131
- in config_transfer.items()
119
+ for type_, rel in config_transfer.items()
132
120
  }
133
121
 
134
122
  def __convert_relationship(
135
123
  self,
136
- rel: JsonRelationship
124
+ rel: JsonRelationship,
137
125
  ) -> RelationshipConfig:
138
-
139
126
  return RelationshipConfig(
140
127
  to_one=rel.get('one'),
141
- to_many=rel.get('many')
128
+ to_many=rel.get('many'),
142
129
  )
143
130
 
144
131
 
145
132
  class DataObjectConverter:
146
-
147
133
  """
148
134
  Converts from instances of `DataObject` to
149
135
  JSON:API transfers.
@@ -161,21 +147,21 @@ class DataObjectConverter:
161
147
  req_fields_tree = ReqFieldsTree(object_type, self.__data_source)
162
148
  return DefaultView(req_fields_tree, self.__prefix)
163
149
 
164
- def convert(self, input_: DataObject) -> JsonApiTransfer:
150
+ def convert(self, data_obj: DataObject) -> JsonApiTransfer:
165
151
  """
166
152
  Converts a single `DataObject` instance to a JsonApiTransfer
167
153
  """
168
154
 
169
- view = self.__build_view(input_.type)
170
- return view.dump(input_)
155
+ view = self.__build_view(data_obj.type)
156
+ return view.dump(data_obj)
171
157
 
172
- def convert_list(self, input_: list[DataObject]) -> JsonApiTransfer:
158
+ def convert_list(self, data_obj_list: list[DataObject]) -> JsonApiTransfer:
173
159
  """
174
160
  Converts a `list` of `DataObject` instances to a JsonApiTransfer
175
161
  """
176
162
 
177
- if not input_:
163
+ if not data_obj_list:
178
164
  msg = 'Cannot convert empty list'
179
165
  raise ValueError(msg)
180
- view = self.__build_view(input_[0].type)
181
- return view.dump_bulk(input_)
166
+ view = self.__build_view(data_obj_list[0].type)
167
+ return view.dump_bulk(data_obj_list)
tol/api_client/factory.py CHANGED
@@ -5,19 +5,12 @@
5
5
  from collections.abc import Mapping
6
6
  from typing import Callable, Iterator, Optional
7
7
 
8
- from .api_datasource import (
9
- ApiDataSource,
10
- DOConverterFactory,
11
- JsonConverterFactory
12
- )
8
+ from .api_datasource import ApiDataSource, DOConverterFactory, JsonConverterFactory
13
9
  from .client import JsonApiClient
14
- from .converter import (
15
- DataObjectConverter,
16
- JsonApiConverter
17
- )
10
+ from .converter import DataObjectConverter, JsonApiConverter
18
11
  from .filter import DefaultApiFilter
19
12
  from .parser import DefaultParser
20
- from ..core import DataSource
13
+ from ..core import DataSource, ReqFieldsTree
21
14
 
22
15
 
23
16
  class _ApiDSDict(Mapping):
@@ -53,11 +46,7 @@ class _ConverterFactory:
53
46
  return self.__data_source
54
47
 
55
48
  @data_source.setter
56
- def data_source(
57
- self,
58
- ds: DataSource
59
- ) -> None:
60
-
49
+ def data_source(self, ds: DataSource) -> None:
61
50
  self.__data_source = ds
62
51
 
63
52
  def do_converter_factory(self) -> DOConverterFactory:
@@ -67,12 +56,26 @@ class _ConverterFactory:
67
56
 
68
57
  return DataObjectConverter(self.__data_source, prefix=self.__prefix)
69
58
 
70
- def json_converter_factory(self) -> JsonConverterFactory:
59
+ def json_converter_factory(
60
+ self,
61
+ object_type: str | None = None,
62
+ requested_fields: list[str] | None = None,
63
+ ) -> JsonConverterFactory:
71
64
  """
72
65
  Returns an instantiated `JsonApiConverter`.
73
66
  """
74
67
 
75
- parser = DefaultParser(self.__ds_dict)
68
+ req_fields_tree = (
69
+ ReqFieldsTree(
70
+ object_type,
71
+ self.__data_source,
72
+ requested_fields=requested_fields,
73
+ )
74
+ if object_type
75
+ else None
76
+ )
77
+
78
+ parser = DefaultParser(self.__ds_dict, req_fields_tree)
76
79
  return JsonApiConverter(parser)
77
80
 
78
81
  @property
@@ -110,7 +113,6 @@ def _filter_factory() -> DefaultApiFilter:
110
113
  def create_api_datasource(
111
114
  api_url: str,
112
115
  token: Optional[str] = None,
113
-
114
116
  data_prefix: str = '/data',
115
117
  retries: int = 5,
116
118
  status_forcelist: Optional[list[int]] = None,
@@ -137,7 +139,7 @@ def create_api_datasource(
137
139
  client_factory,
138
140
  manager.json_converter_factory,
139
141
  manager.do_converter_factory,
140
- _filter_factory
142
+ _filter_factory,
141
143
  )
142
144
 
143
145
  manager.data_source = api_ds