deriva 1.7.1__py3-none-any.whl → 1.7.3__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.
- deriva/config/annotation_config.py +2 -2
- deriva/core/__init__.py +1 -1
- deriva/core/datapath.py +205 -23
- deriva/core/ermrest_catalog.py +103 -23
- deriva/core/ermrest_model.py +387 -7
- deriva/core/hatrac_cli.py +5 -3
- deriva/core/utils/globus_auth_utils.py +3 -1
- deriva/transfer/upload/deriva_upload.py +5 -2
- {deriva-1.7.1.dist-info → deriva-1.7.3.dist-info}/METADATA +1 -1
- {deriva-1.7.1.dist-info → deriva-1.7.3.dist-info}/RECORD +14 -14
- {deriva-1.7.1.dist-info → deriva-1.7.3.dist-info}/LICENSE +0 -0
- {deriva-1.7.1.dist-info → deriva-1.7.3.dist-info}/WHEEL +0 -0
- {deriva-1.7.1.dist-info → deriva-1.7.3.dist-info}/entry_points.txt +0 -0
- {deriva-1.7.1.dist-info → deriva-1.7.3.dist-info}/top_level.txt +0 -0
|
@@ -33,7 +33,7 @@ class AttrSpecList(BaseSpecList):
|
|
|
33
33
|
return None
|
|
34
34
|
new = []
|
|
35
35
|
for item in orig_list:
|
|
36
|
-
new.append(
|
|
36
|
+
new.append(item)
|
|
37
37
|
return new
|
|
38
38
|
|
|
39
39
|
def add_list(self, dictlist):
|
|
@@ -85,7 +85,7 @@ class AttrConfig:
|
|
|
85
85
|
self.toplevel_config = ConfigUtil.find_toplevel_node(self.catalog.getCatalogModel(), schema_name, table_name)
|
|
86
86
|
|
|
87
87
|
def make_speclist(self, name):
|
|
88
|
-
d = self.config.get(
|
|
88
|
+
d = self.config.get(name)
|
|
89
89
|
if d is None:
|
|
90
90
|
d = [dict()]
|
|
91
91
|
return AttrSpecList(self.known_attrs, d)
|
deriva/core/__init__.py
CHANGED
deriva/core/datapath.py
CHANGED
|
@@ -5,6 +5,7 @@ import copy
|
|
|
5
5
|
from datetime import date
|
|
6
6
|
import itertools
|
|
7
7
|
import logging
|
|
8
|
+
import time
|
|
8
9
|
import re
|
|
9
10
|
from requests import HTTPError
|
|
10
11
|
import warnings
|
|
@@ -695,6 +696,102 @@ class _ResultSet (object):
|
|
|
695
696
|
logger.debug("Fetched %d entities" % len(self._results_doc))
|
|
696
697
|
return self
|
|
697
698
|
|
|
699
|
+
def _json_size_approx(data):
|
|
700
|
+
"""Return approximate byte count for minimal JSON encoding of data
|
|
701
|
+
|
|
702
|
+
Minimal encoding has no optional whitespace/indentation.
|
|
703
|
+
"""
|
|
704
|
+
nbytes = 0
|
|
705
|
+
|
|
706
|
+
if isinstance(data, (list, tuple)):
|
|
707
|
+
nbytes += 2
|
|
708
|
+
for elem in data:
|
|
709
|
+
nbytes += _json_size_approx(elem) + 1
|
|
710
|
+
elif isinstance(data, dict):
|
|
711
|
+
nbytes += 2
|
|
712
|
+
for k, v in data.items():
|
|
713
|
+
nbytes += _json_size_approx(k) + _json_size_approx(v) + 2
|
|
714
|
+
elif isinstance(data, str):
|
|
715
|
+
nbytes += len(data.encode("utf-8")) + 2
|
|
716
|
+
else:
|
|
717
|
+
nbytes += len(str(data))
|
|
718
|
+
|
|
719
|
+
return nbytes
|
|
720
|
+
|
|
721
|
+
def _generate_batches(entities, max_batch_rows=1000, max_batch_bytes=250*1024):
|
|
722
|
+
"""Generate a series of entity batches as slices of the input entities
|
|
723
|
+
|
|
724
|
+
"""
|
|
725
|
+
if not isinstance(entities, (list, tuple)):
|
|
726
|
+
raise TypeError('invalid type %s for entities, list or tuple expected' % (type(entities),))
|
|
727
|
+
|
|
728
|
+
if not max_batch_rows:
|
|
729
|
+
logger.debug("disabling batching due to max_batch_rows=%r" % (max_batch_rows,))
|
|
730
|
+
return entities
|
|
731
|
+
|
|
732
|
+
top = len(entities)
|
|
733
|
+
lower = 0
|
|
734
|
+
|
|
735
|
+
while lower < top:
|
|
736
|
+
# to ensure progress, always use at least one row per batch regardless of nbytes
|
|
737
|
+
upper = lower + 1
|
|
738
|
+
batch_nbytes = _json_size_approx(entities[lower])
|
|
739
|
+
|
|
740
|
+
# advance upper position until a batch size limit is reached
|
|
741
|
+
while (upper - lower) < max_batch_rows:
|
|
742
|
+
if upper >= top:
|
|
743
|
+
break
|
|
744
|
+
batch_nbytes += _json_size_approx(entities[upper])
|
|
745
|
+
if batch_nbytes > max_batch_bytes:
|
|
746
|
+
break
|
|
747
|
+
upper += 1
|
|
748
|
+
|
|
749
|
+
# generate one batch and advance for next batch
|
|
750
|
+
logger.debug("yielding batch of %d/%d entities (%d:%d)" % (upper-lower, top, lower, upper))
|
|
751
|
+
yield entities[lower:upper]
|
|
752
|
+
lower = upper
|
|
753
|
+
|
|
754
|
+
def _request_with_retry(request_func, retry_codes={408, 429, 500, 502, 503, 504}, backoff_factor=4, max_attempts=5):
|
|
755
|
+
"""Perform request func with exponential backoff and retry.
|
|
756
|
+
|
|
757
|
+
:param request_func: A function returning a requests.Response object or raising HTTPError
|
|
758
|
+
:param retry_codes: HTTPError status codes on which to attempt retry
|
|
759
|
+
:param backoff_factor: Base number of seconds for factor**attempt exponential backoff
|
|
760
|
+
:param max_attempts: Max number of request attempts.
|
|
761
|
+
|
|
762
|
+
Retry will be attempted on HTTPError exceptions which match retry_codes and
|
|
763
|
+
also on other unknown exceptions, presumed to be transport errors.
|
|
764
|
+
|
|
765
|
+
The request_func should do the equivalent of resp.raise_on_status() so that
|
|
766
|
+
it only returns a response object for successful requests.
|
|
767
|
+
"""
|
|
768
|
+
attempt = 0
|
|
769
|
+
last_ex = None
|
|
770
|
+
|
|
771
|
+
while attempt < max_attempts:
|
|
772
|
+
try:
|
|
773
|
+
if attempt > 0:
|
|
774
|
+
delay = backoff_factor**(attempt-1)
|
|
775
|
+
logger.debug("sleeping %d seconds before retry %d..." % (delay, attempt))
|
|
776
|
+
time.sleep(delay)
|
|
777
|
+
attempt += 1
|
|
778
|
+
return request_func()
|
|
779
|
+
except HTTPError as e:
|
|
780
|
+
logger.debug(e.response.text)
|
|
781
|
+
last_ex = e
|
|
782
|
+
if 400 <= e.response.status_code < 500:
|
|
783
|
+
last_ex = DataPathException(_http_error_message(e), e)
|
|
784
|
+
if int(e.response.status_code) not in retry_codes:
|
|
785
|
+
raise last_ex
|
|
786
|
+
except Exception as e:
|
|
787
|
+
logger.debug(e.response.text)
|
|
788
|
+
last_ex = e
|
|
789
|
+
|
|
790
|
+
# early return means we don't get here on successful requests
|
|
791
|
+
logger.warning("maximum request retry limit %d exceeded" % (max_attempts,))
|
|
792
|
+
if last_ex is None:
|
|
793
|
+
raise ValueError('exceeded max_attempts without catching a request exception')
|
|
794
|
+
raise last_ex
|
|
698
795
|
|
|
699
796
|
class _TableWrapper (object):
|
|
700
797
|
"""Wraps a Table for datapath expressions.
|
|
@@ -836,7 +933,7 @@ class _TableWrapper (object):
|
|
|
836
933
|
"""
|
|
837
934
|
return self.path.denormalize(context_name=context_name, heuristic=heuristic, groupkey_name=groupkey_name)
|
|
838
935
|
|
|
839
|
-
def insert(self, entities, defaults=set(), nondefaults=set(), add_system_defaults=True, on_conflict_skip=False):
|
|
936
|
+
def insert(self, entities, defaults=set(), nondefaults=set(), add_system_defaults=True, on_conflict_skip=False, retry_codes={408, 429, 500, 502, 503, 504}, backoff_factor=4, max_attempts=5, max_batch_rows=1000, max_batch_bytes=250*1024):
|
|
840
937
|
"""Inserts entities into the table.
|
|
841
938
|
|
|
842
939
|
:param entities: an iterable collection of entities (i.e., rows) to be inserted into the table.
|
|
@@ -844,7 +941,23 @@ class _TableWrapper (object):
|
|
|
844
941
|
:param nondefaults: optional, set of columns names to override implicit system defaults
|
|
845
942
|
:param add_system_defaults: flag to add system columns to the set of default columns.
|
|
846
943
|
:param on_conflict_skip: flag to skip entities that violate uniqueness constraints.
|
|
944
|
+
:param retry_codes: set of HTTP status codes for which retry should be considered.
|
|
945
|
+
:param backoff_factor: number of seconds for base of exponential retry backoff.
|
|
946
|
+
:param max_attempts: maximum number of requests attempts with retry.
|
|
947
|
+
:param max_batch_rows: maximum number of rows for one request, or False to disable batching.
|
|
948
|
+
:param max_batch_bytes: approximate maximum number of bytes for one request.
|
|
847
949
|
:return a collection of newly created entities.
|
|
950
|
+
|
|
951
|
+
Retry will only be attempted for idempotent insertion
|
|
952
|
+
requests, which are when a user-controlled, non-nullable key
|
|
953
|
+
is present in the table and the key's constituent column(s)
|
|
954
|
+
are not listed as defaults, and on_conflict_skip=True.
|
|
955
|
+
|
|
956
|
+
When performing retries, an exponential backoff delay is
|
|
957
|
+
introduced after each failed attempt. The delay is
|
|
958
|
+
backoff_factor**attempt_number seconds for attempts 0 through
|
|
959
|
+
max_attempts-1.
|
|
960
|
+
|
|
848
961
|
"""
|
|
849
962
|
# empty entities will be accepted but results are therefore an empty entity set
|
|
850
963
|
if not entities:
|
|
@@ -879,17 +992,55 @@ class _TableWrapper (object):
|
|
|
879
992
|
if not hasattr(entities[0], 'keys'):
|
|
880
993
|
raise TypeError('entities[0] does not look like a dictionary -- does not have a "keys()" method')
|
|
881
994
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
return
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
if
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
995
|
+
# perform one batch request in a helper we can hand to retry helper
|
|
996
|
+
def request_func(batch):
|
|
997
|
+
return self._schema._catalog._wrapped_catalog.post(path, json=batch, headers={'Content-Type': 'application/json'})
|
|
998
|
+
|
|
999
|
+
def _has_user_pkey(table):
|
|
1000
|
+
"""Return True if table has at least one primary key other than the system RID key"""
|
|
1001
|
+
for key in table.keys:
|
|
1002
|
+
if { c.name for c in key.unique_columns } != {'RID'}:
|
|
1003
|
+
if all([ not c.nullok for c in key.unique_columns ]) \
|
|
1004
|
+
and all([ c.name not in defaults for c in key.unique_columns ]):
|
|
1005
|
+
return True
|
|
1006
|
+
return False
|
|
1007
|
+
|
|
1008
|
+
# determine whether insert is idempotent and therefore retry safe
|
|
1009
|
+
retry_safe = on_conflict_skip and _has_user_pkey(self._wrapped_table)
|
|
1010
|
+
|
|
1011
|
+
# perform all requests in a helper we can hand to _ResultSet
|
|
1012
|
+
def results_func(ignore1, ignore2, ignore3):
|
|
1013
|
+
results = []
|
|
1014
|
+
for batch in _generate_batches(
|
|
1015
|
+
entities,
|
|
1016
|
+
max_batch_rows=max_batch_rows,
|
|
1017
|
+
max_batch_bytes=max_batch_bytes
|
|
1018
|
+
):
|
|
1019
|
+
try:
|
|
1020
|
+
if retry_safe:
|
|
1021
|
+
resp = _request_with_retry(
|
|
1022
|
+
lambda: request_func(batch),
|
|
1023
|
+
retry_codes=retry_codes,
|
|
1024
|
+
backoff_factor=backoff_factor,
|
|
1025
|
+
max_attempts=max_attempts
|
|
1026
|
+
)
|
|
1027
|
+
else:
|
|
1028
|
+
resp = request_func(batch)
|
|
1029
|
+
results.extend(resp.json())
|
|
1030
|
+
except HTTPError as e:
|
|
1031
|
+
logger.debug(e.response.text)
|
|
1032
|
+
if 400 <= e.response.status_code < 500:
|
|
1033
|
+
raise DataPathException(_http_error_message(e), e)
|
|
1034
|
+
else:
|
|
1035
|
+
raise e
|
|
1036
|
+
return results
|
|
1037
|
+
|
|
1038
|
+
result = _ResultSet(self.path.uri, results_func)
|
|
1039
|
+
result.fetch()
|
|
1040
|
+
return result
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def update(self, entities, correlation={'RID'}, targets=None, retry_codes={408, 429, 500, 502, 503, 504}, backoff_factor=4, max_attempts=5, max_batch_rows=1000, max_batch_bytes=250*1024):
|
|
893
1044
|
"""Update entities of a table.
|
|
894
1045
|
|
|
895
1046
|
For more information see the ERMrest protocol for the `attributegroup` interface. By default, this method will
|
|
@@ -901,7 +1052,17 @@ class _TableWrapper (object):
|
|
|
901
1052
|
:param correlation: an iterable collection of column names used to correlate input set to the set of rows to be
|
|
902
1053
|
updated in the catalog. E.g., `{'col name'}` or `{mytable.mycolumn}` will work if you pass a _ColumnWrapper object.
|
|
903
1054
|
:param targets: an iterable collection of column names used as the targets of the update operation.
|
|
904
|
-
:
|
|
1055
|
+
:param retry_codes: set of HTTP status codes for which retry should be considered.
|
|
1056
|
+
:param backoff_factor: number of seconds for base of exponential retry backoff.
|
|
1057
|
+
:param max_attempts: maximum number of requests attempts with retry.
|
|
1058
|
+
:param max_batch_rows: maximum number of rows for one request, or False to disable batching.
|
|
1059
|
+
:param max_batch_bytes: approximate maximum number of bytes for one request.
|
|
1060
|
+
:return a collection of newly created entities.
|
|
1061
|
+
|
|
1062
|
+
When performing retries, an exponential backoff delay is
|
|
1063
|
+
introduced after each failed attempt. The delay is
|
|
1064
|
+
backoff_factor**attempt_number seconds for attempts 0 through
|
|
1065
|
+
max_attempts-1.
|
|
905
1066
|
"""
|
|
906
1067
|
# empty entities will be accepted but results are therefore an empty entity set
|
|
907
1068
|
if not entities:
|
|
@@ -936,16 +1097,37 @@ class _TableWrapper (object):
|
|
|
936
1097
|
targets=','.join(target_cnames)
|
|
937
1098
|
)
|
|
938
1099
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
return
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1100
|
+
# perform one batch request in a helper we can hand to retry helper
|
|
1101
|
+
def request_func(batch):
|
|
1102
|
+
return self._schema._catalog._wrapped_catalog.put(path, json=batch, headers={'Content-Type': 'application/json'})
|
|
1103
|
+
|
|
1104
|
+
# perform all requests in a helper we can hand to _ResultSet
|
|
1105
|
+
def results_func(ignore1, ignore2, ignore3):
|
|
1106
|
+
results = []
|
|
1107
|
+
for batch in _generate_batches(
|
|
1108
|
+
entities,
|
|
1109
|
+
max_batch_rows=max_batch_rows,
|
|
1110
|
+
max_batch_bytes=max_batch_bytes
|
|
1111
|
+
):
|
|
1112
|
+
try:
|
|
1113
|
+
resp = _request_with_retry(
|
|
1114
|
+
lambda: request_func(batch),
|
|
1115
|
+
retry_codes=retry_codes,
|
|
1116
|
+
backoff_factor=backoff_factor,
|
|
1117
|
+
max_attempts=max_attempts
|
|
1118
|
+
)
|
|
1119
|
+
results.extend(resp.json())
|
|
1120
|
+
except HTTPError as e:
|
|
1121
|
+
logger.debug(e.response.text)
|
|
1122
|
+
if 400 <= e.response.status_code < 500:
|
|
1123
|
+
raise DataPathException(_http_error_message(e), e)
|
|
1124
|
+
else:
|
|
1125
|
+
raise e
|
|
1126
|
+
return results
|
|
1127
|
+
|
|
1128
|
+
result = _ResultSet(self.path.uri, results_func)
|
|
1129
|
+
result.fetch()
|
|
1130
|
+
return result
|
|
949
1131
|
|
|
950
1132
|
class _TableAlias (_TableWrapper):
|
|
951
1133
|
"""Represents a table alias in datapath expressions.
|
deriva/core/ermrest_catalog.py
CHANGED
|
@@ -53,11 +53,15 @@ class DerivaServer (DerivaBinding):
|
|
|
53
53
|
"""
|
|
54
54
|
return ErmrestCatalog.connect(self, catalog_id, snaptime)
|
|
55
55
|
|
|
56
|
-
def create_ermrest_catalog(self, id=None, owner=None):
|
|
56
|
+
def create_ermrest_catalog(self, id=None, owner=None, name=None, description=None, is_persistent=None, clone_source=None):
|
|
57
57
|
"""Create an ERMrest catalog.
|
|
58
58
|
|
|
59
59
|
:param id: The (str) id desired by the client (default None)
|
|
60
60
|
:param owner: The initial (list of str) ACL desired by the client (default None)
|
|
61
|
+
:param name: Initial (str) catalog name if not None
|
|
62
|
+
:param description: Initial (str) catalog description if not None
|
|
63
|
+
:param is_persistent: Initial (bool) catalog persistence flag if not None
|
|
64
|
+
:param clone_source: Initial catalog clone_source if not None
|
|
61
65
|
|
|
62
66
|
The new catalog id will be returned in the response, and used
|
|
63
67
|
in future catalog access. The use of the id parameter
|
|
@@ -77,8 +81,17 @@ class DerivaServer (DerivaBinding):
|
|
|
77
81
|
owner ACL influences which client(s) are allowed to retry
|
|
78
82
|
creation with the same id.
|
|
79
83
|
|
|
84
|
+
The name, description, is_persistent, and clone_source
|
|
85
|
+
parameters are passed through to the catalog creation service
|
|
86
|
+
to initialize those respective metadata fields of the new
|
|
87
|
+
catalog's registry entry. See ERMrest documentation for more
|
|
88
|
+
detail. Authorization failures may occur when attempting to
|
|
89
|
+
set the is_persistent flag. By default, these fields are not
|
|
90
|
+
initialized in the catalog creation request, and they instead
|
|
91
|
+
receive server-assigned defaults.
|
|
92
|
+
|
|
80
93
|
"""
|
|
81
|
-
return ErmrestCatalog.create(self, id, owner)
|
|
94
|
+
return ErmrestCatalog.create(self, id, owner, name, description, is_persistent, clone_source)
|
|
82
95
|
|
|
83
96
|
def connect_ermrest_alias(self, id):
|
|
84
97
|
"""Connect to an ERMrest alias and return the alias binding.
|
|
@@ -88,12 +101,14 @@ class DerivaServer (DerivaBinding):
|
|
|
88
101
|
"""
|
|
89
102
|
return ErmrestAlias.connect(self, id)
|
|
90
103
|
|
|
91
|
-
def create_ermrest_alias(self, id=None, owner=None, alias_target=None):
|
|
104
|
+
def create_ermrest_alias(self, id=None, owner=None, alias_target=None, name=None, description=None):
|
|
92
105
|
"""Create an ERMrest catalog alias.
|
|
93
106
|
|
|
94
107
|
:param id: The (str) id desired by the client (default None)
|
|
95
108
|
:param owner: The initial (list of str) ACL desired by the client (default None)
|
|
96
109
|
:param alias_target: The initial target catalog id binding desired by the client (default None)
|
|
110
|
+
:param name: Initial (str) catalog name if not None
|
|
111
|
+
:param description: Initial (str) catalog description if not None
|
|
97
112
|
|
|
98
113
|
The new alias id will be returned in the response, and used
|
|
99
114
|
in future alias access. The use of the id parameter
|
|
@@ -118,8 +133,13 @@ class DerivaServer (DerivaBinding):
|
|
|
118
133
|
influences which client(s) are allowed to retry creation with
|
|
119
134
|
the same id.
|
|
120
135
|
|
|
136
|
+
The name and description parameters are passed through to the
|
|
137
|
+
alias creation service to initialize those respective metadata
|
|
138
|
+
fields of the new aliase's registry entry. See ERMrest
|
|
139
|
+
documentation for more detail.
|
|
140
|
+
|
|
121
141
|
"""
|
|
122
|
-
return ErmrestAlias.create(self, id, owner, alias_target)
|
|
142
|
+
return ErmrestAlias.create(self, id, owner, alias_target, name, description)
|
|
123
143
|
|
|
124
144
|
class ErmrestCatalogMutationError(Exception):
|
|
125
145
|
pass
|
|
@@ -204,15 +224,22 @@ class ErmrestCatalog(DerivaBinding):
|
|
|
204
224
|
)
|
|
205
225
|
|
|
206
226
|
@classmethod
|
|
207
|
-
def _digest_catalog_args(cls, id, owner):
|
|
227
|
+
def _digest_catalog_args(cls, id, owner, name=None, description=None, is_persistent=None, clone_source=None):
|
|
208
228
|
rep = dict()
|
|
209
229
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
230
|
+
for v, k, typ in [
|
|
231
|
+
(id, 'id', str),
|
|
232
|
+
(name, 'name', str),
|
|
233
|
+
(description, 'description', str),
|
|
234
|
+
(is_persistent, 'is_persistent', bool),
|
|
235
|
+
(clone_source, 'clone_source', str),
|
|
236
|
+
]:
|
|
237
|
+
if isinstance(v, typ):
|
|
238
|
+
rep[k] = v
|
|
239
|
+
elif isinstance(v, (type(nochange), type(None))):
|
|
240
|
+
pass
|
|
241
|
+
else:
|
|
242
|
+
raise TypeError('%s must be of type %s or None or nochange, not %s' % (k, typ.__name__, type(v)))
|
|
216
243
|
|
|
217
244
|
if isinstance(owner, list):
|
|
218
245
|
for e in owner:
|
|
@@ -227,12 +254,16 @@ class ErmrestCatalog(DerivaBinding):
|
|
|
227
254
|
return rep
|
|
228
255
|
|
|
229
256
|
@classmethod
|
|
230
|
-
def create(cls, deriva_server, id=None, owner=None):
|
|
257
|
+
def create(cls, deriva_server, id=None, owner=None, name=None, description=None, is_persistent=None, clone_source=None):
|
|
231
258
|
"""Create an ERMrest catalog and return the ERMrest catalog binding.
|
|
232
259
|
|
|
233
260
|
:param deriva_server: The DerivaServer binding which hosts ermrest.
|
|
234
261
|
:param id: The (str) id desired by the client (default None)
|
|
235
262
|
:param owner: The initial (list of str) ACL desired by the client (default None)
|
|
263
|
+
:param name: Initial (str) catalog name if not None
|
|
264
|
+
:param description: Initial (str) catalog description if not None
|
|
265
|
+
:param is_persistent: Initial (bool) catalog persistence flag if not None
|
|
266
|
+
:param clone_source: Initial catalog clone_source if not None
|
|
236
267
|
|
|
237
268
|
The new catalog id will be returned in the response, and used
|
|
238
269
|
in future catalog access. The use of the id parameter
|
|
@@ -252,9 +283,18 @@ class ErmrestCatalog(DerivaBinding):
|
|
|
252
283
|
influences which client(s) are allowed to retry creation with
|
|
253
284
|
the same id.
|
|
254
285
|
|
|
286
|
+
The name, description, is_persistent, and clone_source
|
|
287
|
+
parameters are passed through to the catalog creation service
|
|
288
|
+
to initialize those respective metadata fields of the new
|
|
289
|
+
catalog's registry entry. See ERMrest documentation for more
|
|
290
|
+
detail. Authorization failures may occur when attempting to
|
|
291
|
+
set the is_persistent flag. By default, these fields are not
|
|
292
|
+
initialized in the catalog creation request, and they instead
|
|
293
|
+
receive server-assigned defaults.
|
|
294
|
+
|
|
255
295
|
"""
|
|
256
296
|
path = '/ermrest/catalog'
|
|
257
|
-
r = deriva_server.post(path, json=cls._digest_catalog_args(id, owner))
|
|
297
|
+
r = deriva_server.post(path, json=cls._digest_catalog_args(id, owner, name, description, is_persistent, clone_source))
|
|
258
298
|
r.raise_for_status()
|
|
259
299
|
return cls.connect(deriva_server, r.json()['id'])
|
|
260
300
|
|
|
@@ -655,7 +695,8 @@ class ErmrestCatalog(DerivaBinding):
|
|
|
655
695
|
copy_annotations=True,
|
|
656
696
|
copy_policy=True,
|
|
657
697
|
truncate_after=True,
|
|
658
|
-
exclude_schemas=None
|
|
698
|
+
exclude_schemas=None,
|
|
699
|
+
dst_properties=None):
|
|
659
700
|
"""Clone this catalog's content into dest_catalog, creating a new catalog if needed.
|
|
660
701
|
|
|
661
702
|
:param dst_catalog: Destination catalog or None to request creation of new destination (default).
|
|
@@ -664,13 +705,22 @@ class ErmrestCatalog(DerivaBinding):
|
|
|
664
705
|
:param copy_policy: Copy access-control policies when True (default).
|
|
665
706
|
:param truncate_after: Truncate destination history after cloning when True (default).
|
|
666
707
|
:param exclude_schemas: A list of schema names to exclude from the cloning process.
|
|
708
|
+
:param dst_properties: A dictionary of custom catalog-creation properties.
|
|
667
709
|
|
|
668
|
-
When
|
|
710
|
+
When dst_catalog is provided, attempt an idempotent clone,
|
|
669
711
|
assuming content MAY be partially cloned already using the
|
|
670
712
|
same parameters. This routine uses a table-level annotation
|
|
671
713
|
"tag:isrd.isi.edu,2018:clone-state" to save progress markers
|
|
672
714
|
which help it restart efficiently if interrupted.
|
|
673
715
|
|
|
716
|
+
When dst_catalog is not provided, a new catalog is
|
|
717
|
+
provisioned. The optional dst_properties can customize
|
|
718
|
+
metadata properties during this step:
|
|
719
|
+
|
|
720
|
+
- name: str
|
|
721
|
+
- description: str (markdown-formatted)
|
|
722
|
+
- is_persistent: boolean
|
|
723
|
+
|
|
674
724
|
Cloning preserves source row RID values for application tables
|
|
675
725
|
so that any RID-based foreign keys are still valid. It is not
|
|
676
726
|
generally advisable to try to merge more than one source into
|
|
@@ -692,10 +742,33 @@ class ErmrestCatalog(DerivaBinding):
|
|
|
692
742
|
session_config["allow_retry_on_all_methods"] = True
|
|
693
743
|
|
|
694
744
|
if dst_catalog is None:
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
745
|
+
if dst_properties is not None:
|
|
746
|
+
if not isinstance(dst_properties, dict):
|
|
747
|
+
raise TypeError('dst_properties must be of type dict or None, not %s' % (type(dst_properties),))
|
|
748
|
+
else:
|
|
749
|
+
dst_properties = {}
|
|
750
|
+
kwargs = {
|
|
751
|
+
"name": dst_properties.get('name', 'Clone of %r' % (self._catalog_id,)),
|
|
752
|
+
"description": dst_properties.get(
|
|
753
|
+
'description',
|
|
754
|
+
'''A cloned copy of catalog %r made with ErmrestCatalog.clone_catalog() using the following parameters:
|
|
755
|
+
- `copy_data`: %r
|
|
756
|
+
- `copy_annotations`: %r
|
|
757
|
+
- `copy_policy`: %r
|
|
758
|
+
- `truncate_after`: %r
|
|
759
|
+
- `exclude_schemas`: %r
|
|
760
|
+
''' % (
|
|
761
|
+
self._catalog_id,
|
|
762
|
+
copy_data,
|
|
763
|
+
copy_annotations,
|
|
764
|
+
copy_policy,
|
|
765
|
+
truncate_after,
|
|
766
|
+
exclude_schemas,
|
|
767
|
+
)),
|
|
768
|
+
"clone_source": dst_properties.get('clone_source', self._catalog_id),
|
|
769
|
+
}
|
|
770
|
+
server = self.deriva_server
|
|
771
|
+
dst_catalog = server.create_ermrest_catalog(**kwargs)
|
|
699
772
|
|
|
700
773
|
# set top-level config right away and find fatal usage errors...
|
|
701
774
|
if copy_policy:
|
|
@@ -1051,8 +1124,8 @@ class ErmrestAlias(DerivaBinding):
|
|
|
1051
1124
|
)
|
|
1052
1125
|
|
|
1053
1126
|
@classmethod
|
|
1054
|
-
def _digest_alias_args(cls, id, owner, alias_target):
|
|
1055
|
-
rep = ErmrestCatalog._digest_catalog_args(id, owner)
|
|
1127
|
+
def _digest_alias_args(cls, id, owner, alias_target, name, description):
|
|
1128
|
+
rep = ErmrestCatalog._digest_catalog_args(id, owner, name, description)
|
|
1056
1129
|
|
|
1057
1130
|
if isinstance(alias_target, (str, type(None))):
|
|
1058
1131
|
rep['alias_target'] = alias_target
|
|
@@ -1064,13 +1137,15 @@ class ErmrestAlias(DerivaBinding):
|
|
|
1064
1137
|
return rep
|
|
1065
1138
|
|
|
1066
1139
|
@classmethod
|
|
1067
|
-
def create(cls, deriva_server, id=None, owner=None, alias_target=None):
|
|
1140
|
+
def create(cls, deriva_server, id=None, owner=None, alias_target=None, name=None, description=None):
|
|
1068
1141
|
"""Create an ERMrest catalog alias.
|
|
1069
1142
|
|
|
1070
1143
|
:param deriva_server: The DerivaServer binding which hosts ermrest
|
|
1071
1144
|
:param id: The (str) id desired by the client (default None)
|
|
1072
1145
|
:param owner: The initial (list of str) ACL desired by the client (default None)
|
|
1073
1146
|
:param alias_target: The initial target catalog id desired by the client (default None)
|
|
1147
|
+
:param name: Initial (str) catalog name if not None
|
|
1148
|
+
:param description: Initial (str) catalog description if not None
|
|
1074
1149
|
|
|
1075
1150
|
The new alias id will be returned in the response, and used
|
|
1076
1151
|
in future alias access. The use of the id parameter
|
|
@@ -1095,9 +1170,14 @@ class ErmrestAlias(DerivaBinding):
|
|
|
1095
1170
|
influences which client(s) are allowed to retry creation with
|
|
1096
1171
|
the same id.
|
|
1097
1172
|
|
|
1173
|
+
The name and description parameters are passed through to the
|
|
1174
|
+
alias creation service to initialize those respective metadata
|
|
1175
|
+
fields of the new aliase's registry entry. See ERMrest
|
|
1176
|
+
documentation for more detail.
|
|
1177
|
+
|
|
1098
1178
|
"""
|
|
1099
1179
|
path = '/ermrest/alias'
|
|
1100
|
-
r = deriva_server.post(path, json=cls._digest_alias_args(id, owner, alias_target))
|
|
1180
|
+
r = deriva_server.post(path, json=cls._digest_alias_args(id, owner, alias_target, name, description))
|
|
1101
1181
|
r.raise_for_status()
|
|
1102
1182
|
return cls.connect(deriva_server, r.json()['id'])
|
|
1103
1183
|
|
deriva/core/ermrest_model.py
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
2
4
|
from collections import OrderedDict
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from enum import Enum
|
|
3
7
|
import json
|
|
4
8
|
import re
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
5
11
|
|
|
6
12
|
from . import AttrDict, tag, urlquote, stob
|
|
7
13
|
|
|
@@ -17,6 +23,74 @@ class NoChange (object):
|
|
|
17
23
|
# singletone to use in APIs below
|
|
18
24
|
nochange = NoChange()
|
|
19
25
|
|
|
26
|
+
def make_id(*components):
|
|
27
|
+
"""Build an identifier that will be OK for ERMrest and Postgres.
|
|
28
|
+
|
|
29
|
+
Naively, append as '_'.join(components).
|
|
30
|
+
|
|
31
|
+
Fallback to heuristics mixing truncation with short hashes.
|
|
32
|
+
"""
|
|
33
|
+
# accept lists at top-level for convenience (compound keys, etc.)
|
|
34
|
+
expanded = []
|
|
35
|
+
for e in components:
|
|
36
|
+
if isinstance(e, list):
|
|
37
|
+
expanded.extend(e)
|
|
38
|
+
else:
|
|
39
|
+
expanded.append(e)
|
|
40
|
+
|
|
41
|
+
# prefer to use naive name as requested
|
|
42
|
+
naive_result = '_'.join(expanded)
|
|
43
|
+
naive_len = len(naive_result.encode('utf8'))
|
|
44
|
+
if naive_len <= 63:
|
|
45
|
+
return naive_result
|
|
46
|
+
|
|
47
|
+
# we'll need to truncate and hash in some way...
|
|
48
|
+
def hash(s, nbytes):
|
|
49
|
+
return base64.urlsafe_b64encode(hashlib.md5(s.encode('utf8')).digest()).decode()[0:nbytes]
|
|
50
|
+
|
|
51
|
+
def truncate(s, maxlen):
|
|
52
|
+
encoded_len = len(s.encode('utf8'))
|
|
53
|
+
# we need to chop whole (unicode) chars but test encoded byte lengths!
|
|
54
|
+
for i in range(max(1, len(s) - maxlen), len(s) - 1):
|
|
55
|
+
result = s[0:-1 * i].rstrip()
|
|
56
|
+
if len(result.encode('utf8')) <= (maxlen - 2):
|
|
57
|
+
return result + '..'
|
|
58
|
+
return s
|
|
59
|
+
|
|
60
|
+
naive_hash = hash(naive_result, 5)
|
|
61
|
+
parts = [
|
|
62
|
+
(i, expanded[i])
|
|
63
|
+
for i in range(len(expanded))
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# try to find a solution truncating individual fields
|
|
67
|
+
for maxlen in [15, 12, 9]:
|
|
68
|
+
parts.sort(key=lambda p: (len(p[1].encode('utf8')), p[0]), reverse=True)
|
|
69
|
+
for i in range(len(parts)):
|
|
70
|
+
idx, part = parts[i]
|
|
71
|
+
if len(part.encode('utf8')) > maxlen:
|
|
72
|
+
parts[i] = (idx, truncate(part, maxlen))
|
|
73
|
+
candidate_result = '_'.join([
|
|
74
|
+
p[1]
|
|
75
|
+
for p in sorted(parts, key=lambda p: p[0])
|
|
76
|
+
] + [naive_hash])
|
|
77
|
+
if len(candidate_result.encode('utf8')) < 63:
|
|
78
|
+
return candidate_result
|
|
79
|
+
|
|
80
|
+
# fallback to truncating original naive name
|
|
81
|
+
# try to preserve suffix and trim in middle
|
|
82
|
+
result = ''.join([
|
|
83
|
+
truncate(naive_result, len(naive_result)//3),
|
|
84
|
+
naive_result[-len(naive_result)//3:],
|
|
85
|
+
'_',
|
|
86
|
+
naive_hash
|
|
87
|
+
])
|
|
88
|
+
if len(result.encode('utf8')) <= 63:
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
# last-ditch (e.g. multibyte unicode suffix worst case)
|
|
92
|
+
return truncate(naive_result, 55) + naive_hash
|
|
93
|
+
|
|
20
94
|
def presence_annotation(tag_uri):
|
|
21
95
|
"""Decorator to establish property getter/setter/deleter for presence annotations.
|
|
22
96
|
|
|
@@ -684,6 +758,15 @@ class KeyedList (list):
|
|
|
684
758
|
list.append(self, e)
|
|
685
759
|
self.elements[e.name] = e
|
|
686
760
|
|
|
761
|
+
class FindAssociationResult (object):
|
|
762
|
+
"""Wrapper for results of Table.find_associations()"""
|
|
763
|
+
def __init__(self, table, self_fkey, other_fkeys):
|
|
764
|
+
self.table = table
|
|
765
|
+
self.name = table.name
|
|
766
|
+
self.schema = table.schema
|
|
767
|
+
self.self_fkey = self_fkey
|
|
768
|
+
self.other_fkeys = other_fkeys
|
|
769
|
+
|
|
687
770
|
class Table (object):
|
|
688
771
|
"""Named table.
|
|
689
772
|
"""
|
|
@@ -1075,6 +1158,184 @@ class Table (object):
|
|
|
1075
1158
|
provide_system
|
|
1076
1159
|
)
|
|
1077
1160
|
|
|
1161
|
+
@classmethod
|
|
1162
|
+
def define_association(
|
|
1163
|
+
cls,
|
|
1164
|
+
associates: Iterable[Key | Table | tuple[str, Key | Table]],
|
|
1165
|
+
metadata: Iterable[Key | Table | dict | tuple[str, bool, Key | Table]] = [],
|
|
1166
|
+
table_name: str | None = None,
|
|
1167
|
+
comment: str | None = None,
|
|
1168
|
+
provide_system: bool = True) -> dict:
|
|
1169
|
+
"""Build an association table definition.
|
|
1170
|
+
|
|
1171
|
+
:param associates: the existing Key instances being associated
|
|
1172
|
+
:param metadata: additional metadata fields for impure associations
|
|
1173
|
+
:param table_name: name for the association table or None for default naming
|
|
1174
|
+
:param comment: comment for the association table or None for default comment
|
|
1175
|
+
:param provide_system: add ERMrest system columns when True
|
|
1176
|
+
|
|
1177
|
+
This is a utility function to help build an association table
|
|
1178
|
+
definition. It simplifies the task, but removes some
|
|
1179
|
+
control. For full customization, consider using Table.define()
|
|
1180
|
+
directly instead.
|
|
1181
|
+
|
|
1182
|
+
A normal ("pure") N-ary association is a table with N foreign
|
|
1183
|
+
keys referencing N primary keys in referenced tables, with a
|
|
1184
|
+
composite primary key covering the N foreign keys. These pure
|
|
1185
|
+
association tables manage a set of distinct combinations of
|
|
1186
|
+
the associated foreign key values.
|
|
1187
|
+
|
|
1188
|
+
An "impure" association table adds additional metadata
|
|
1189
|
+
alongside the N foreign keys.
|
|
1190
|
+
|
|
1191
|
+
The "associates" parameter takes an iterable of Key instances
|
|
1192
|
+
from other tables. The association will be comprised of
|
|
1193
|
+
foreign keys referencing these associates. Optionally, a tuple
|
|
1194
|
+
of (str, Key) can supply a string _base name_ to influence how
|
|
1195
|
+
the foreign key columns and constraint will be named in the
|
|
1196
|
+
new association table. A bare Key instance will get a base
|
|
1197
|
+
name derived from the referenced table name.
|
|
1198
|
+
|
|
1199
|
+
The "metadata" parameter takes an iterable of plain dict
|
|
1200
|
+
column definitions or Key instances. Each dict must be a
|
|
1201
|
+
scalar column definition, such as produced by the
|
|
1202
|
+
Column.define() class method. Key instance will cause
|
|
1203
|
+
corresponding columns and foreign keys to be added to the
|
|
1204
|
+
association table to act as metadata. Optionally, a tuple of
|
|
1205
|
+
(str, bool, Key) can supply a string _base name_ and a boolean
|
|
1206
|
+
_nullok_ property to influence how the foreign key columns and
|
|
1207
|
+
constraint will be constructed and named. A bare Key instance
|
|
1208
|
+
will get a base name derived from the referened table name,
|
|
1209
|
+
and presumed as nullok=False.
|
|
1210
|
+
|
|
1211
|
+
If a Table instance is supplied instead of a Key instance for
|
|
1212
|
+
associates or metadata inputs, an attempt will be made to
|
|
1213
|
+
locate a key based on the RID system column. If this key
|
|
1214
|
+
cannot be found, a KeyError will be raised.
|
|
1215
|
+
|
|
1216
|
+
"""
|
|
1217
|
+
associates = list(associates)
|
|
1218
|
+
metadata = list(metadata)
|
|
1219
|
+
|
|
1220
|
+
if len(associates) < 2:
|
|
1221
|
+
raise ValueError('An association table requires at least 2 associates')
|
|
1222
|
+
|
|
1223
|
+
cdefs = []
|
|
1224
|
+
kdefs = []
|
|
1225
|
+
fkdefs = []
|
|
1226
|
+
|
|
1227
|
+
used_names = set()
|
|
1228
|
+
|
|
1229
|
+
def check_basename(basename):
|
|
1230
|
+
if not isinstance(base_name, str):
|
|
1231
|
+
raise TypeError('Base name %r is not of required type str' % (base_name,))
|
|
1232
|
+
if base_name in used_names:
|
|
1233
|
+
raise ValueError('Base name %r is not unique among associates and metadata' % (base_name,))
|
|
1234
|
+
used_names.add(base_name)
|
|
1235
|
+
|
|
1236
|
+
def choose_basename(key):
|
|
1237
|
+
base_name = key.table.name
|
|
1238
|
+
n = 2
|
|
1239
|
+
while base_name in used_names:
|
|
1240
|
+
base_name = '%s%d' % (key.table.name, n)
|
|
1241
|
+
n += 1
|
|
1242
|
+
used_names.add(base_name)
|
|
1243
|
+
return base_name
|
|
1244
|
+
|
|
1245
|
+
def check_key(key):
|
|
1246
|
+
if isinstance(key, Table):
|
|
1247
|
+
return key.key_by_columns(["RID"])
|
|
1248
|
+
return key
|
|
1249
|
+
|
|
1250
|
+
# check and normalize associates into list[(str, Key)] with distinct base names
|
|
1251
|
+
for i in range(len(associates)):
|
|
1252
|
+
if isinstance(associates[i], tuple):
|
|
1253
|
+
base_name, key = associates[i]
|
|
1254
|
+
check_basename(base_name)
|
|
1255
|
+
key = check_key(key)
|
|
1256
|
+
associates[i] = (base_name, key)
|
|
1257
|
+
else:
|
|
1258
|
+
key = check_key(associates[i])
|
|
1259
|
+
base_name = choose_basename(key)
|
|
1260
|
+
associates[i] = (base_name, key)
|
|
1261
|
+
|
|
1262
|
+
# build assoc table name if not provided
|
|
1263
|
+
if table_name is None:
|
|
1264
|
+
table_name = make_id(*[ assoc[1].table.name for assoc in associates ])
|
|
1265
|
+
|
|
1266
|
+
def simplify_type(ctype):
|
|
1267
|
+
if ctype.is_domain and ctype.typename.startswith('ermrest_'):
|
|
1268
|
+
return ctype.base_type
|
|
1269
|
+
|
|
1270
|
+
return ctype
|
|
1271
|
+
|
|
1272
|
+
def cdefs_for_key(base_name, key, nullok=False):
|
|
1273
|
+
return [
|
|
1274
|
+
Column.define(
|
|
1275
|
+
'%s_%s' % (base_name, col.name) if len(key.unique_columns) > 1 else base_name,
|
|
1276
|
+
simplify_type(col.type),
|
|
1277
|
+
nullok=nullok,
|
|
1278
|
+
)
|
|
1279
|
+
for col in key.unique_columns
|
|
1280
|
+
]
|
|
1281
|
+
|
|
1282
|
+
def fkdef_for_key(base_name, key):
|
|
1283
|
+
return ForeignKey.define(
|
|
1284
|
+
[
|
|
1285
|
+
'%s_%s' % (base_name, col.name) if len(key.unique_columns) > 1 else base_name
|
|
1286
|
+
for col in key.unique_columns
|
|
1287
|
+
],
|
|
1288
|
+
key.table.schema.name,
|
|
1289
|
+
key.table.name,
|
|
1290
|
+
[ col.name for col in key.unique_columns ],
|
|
1291
|
+
on_update='CASCADE',
|
|
1292
|
+
on_delete='CASCADE',
|
|
1293
|
+
constraint_name=make_id(table_name, base_name, 'fkey'),
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
# build core association definition (i.e. the "pure" parts)
|
|
1297
|
+
k_cnames = []
|
|
1298
|
+
for base_name, key in associates:
|
|
1299
|
+
cdefs.extend(cdefs_for_key(base_name, key))
|
|
1300
|
+
fkdefs.append(fkdef_for_key(base_name, key))
|
|
1301
|
+
|
|
1302
|
+
k_cnames.extend([
|
|
1303
|
+
'%s_%s' % (base_name, col.name) if len(key.unique_columns) > 1 else base_name
|
|
1304
|
+
for col in key.unique_columns
|
|
1305
|
+
])
|
|
1306
|
+
|
|
1307
|
+
kdefs.append(
|
|
1308
|
+
Key.define(
|
|
1309
|
+
k_cnames,
|
|
1310
|
+
constraint_name=make_id(table_name, 'assoc', 'key'),
|
|
1311
|
+
)
|
|
1312
|
+
)
|
|
1313
|
+
|
|
1314
|
+
# check and normalize metadata into list[dict | (str, bool, Key)]
|
|
1315
|
+
for i in range(len(metadata)):
|
|
1316
|
+
if isinstance(metadata[i], tuple):
|
|
1317
|
+
base_name, nullok, key = metadata[i]
|
|
1318
|
+
check_basename(base_name)
|
|
1319
|
+
key = check_key(key)
|
|
1320
|
+
metadata[i] = (base_name, nullok, key)
|
|
1321
|
+
elif isinstance(metadata[i], dict):
|
|
1322
|
+
pass
|
|
1323
|
+
else:
|
|
1324
|
+
key = check_key(metadata[i])
|
|
1325
|
+
base_name = choose_basename(key)
|
|
1326
|
+
metadata[i] = (base_name, False, key)
|
|
1327
|
+
|
|
1328
|
+
# add metadata to definition
|
|
1329
|
+
for md in metadata:
|
|
1330
|
+
if isinstance(md, dict):
|
|
1331
|
+
cdefs.append(md)
|
|
1332
|
+
else:
|
|
1333
|
+
base_name, nullok, key = md
|
|
1334
|
+
cdefs.extend(cdefs_for_key(base_name, key, nullok))
|
|
1335
|
+
fkdefs.append(fkdef_for_key(base_name, key))
|
|
1336
|
+
|
|
1337
|
+
return Table.define(table_name, cdefs, kdefs, fkdefs, comment=comment, provide_system=provide_system)
|
|
1338
|
+
|
|
1078
1339
|
def prejson(self, prune=True):
|
|
1079
1340
|
return {
|
|
1080
1341
|
"schema_name": self.schema.name,
|
|
@@ -1348,7 +1609,7 @@ class Table (object):
|
|
|
1348
1609
|
if raise_nomatch:
|
|
1349
1610
|
raise KeyError(from_to_map)
|
|
1350
1611
|
|
|
1351
|
-
def is_association(self, min_arity=2, max_arity=2, unqualified=True, pure=True, no_overlap=True):
|
|
1612
|
+
def is_association(self, min_arity=2, max_arity=2, unqualified=True, pure=True, no_overlap=True, return_fkeys=False):
|
|
1352
1613
|
"""Return (truthy) integer arity if self is a matching association, else False.
|
|
1353
1614
|
|
|
1354
1615
|
min_arity: minimum number of associated fkeys (default 2)
|
|
@@ -1356,6 +1617,7 @@ class Table (object):
|
|
|
1356
1617
|
unqualified: reject qualified associations when True (default True)
|
|
1357
1618
|
pure: reject impure assocations when True (default True)
|
|
1358
1619
|
no_overlap: reject overlapping associations when True (default True)
|
|
1620
|
+
return_fkeys: return the set of N associated ForeignKeys if True
|
|
1359
1621
|
|
|
1360
1622
|
The default behavior with no arguments is to test for pure,
|
|
1361
1623
|
unqualified, non-overlapping, binary assocations.
|
|
@@ -1444,9 +1706,43 @@ class Table (object):
|
|
|
1444
1706
|
# reject: impure association
|
|
1445
1707
|
return False
|
|
1446
1708
|
|
|
1447
|
-
# return (truthy) arity
|
|
1448
|
-
|
|
1709
|
+
# return (truthy) arity or fkeys
|
|
1710
|
+
if return_fkeys:
|
|
1711
|
+
return covered_fkeys
|
|
1712
|
+
else:
|
|
1713
|
+
return len(covered_fkeys)
|
|
1714
|
+
|
|
1715
|
+
def find_associations(self, min_arity=2, max_arity=2, unqualified=True, pure=True, no_overlap=True) -> Iterable[FindAssociationResult]:
|
|
1716
|
+
"""Yield (iterable) Association objects linking to this table and meeting all criteria.
|
|
1449
1717
|
|
|
1718
|
+
min_arity: minimum number of associated fkeys (default 2)
|
|
1719
|
+
max_arity: maximum number of associated fkeys (default 2) or None
|
|
1720
|
+
unqualified: reject qualified associations when True (default True)
|
|
1721
|
+
pure: reject impure assocations when True (default True)
|
|
1722
|
+
no_overlap: reject overlapping associations when True (default True)
|
|
1723
|
+
|
|
1724
|
+
See documentation for sibling method Table.is_association(...)
|
|
1725
|
+
for more explanation of these association detection criteria.
|
|
1726
|
+
|
|
1727
|
+
"""
|
|
1728
|
+
peer_tables = set()
|
|
1729
|
+
for fkey in self.referenced_by:
|
|
1730
|
+
peer = fkey.table
|
|
1731
|
+
if peer in peer_tables:
|
|
1732
|
+
# check each peer only once
|
|
1733
|
+
continue
|
|
1734
|
+
peer_tables.add(peer)
|
|
1735
|
+
answer = peer.is_association(min_arity=min_arity, max_arity=max_arity, unqualified=unqualified, pure=pure, no_overlap=no_overlap, return_fkeys=True)
|
|
1736
|
+
if answer:
|
|
1737
|
+
answer = set(answer)
|
|
1738
|
+
for fkey in answer:
|
|
1739
|
+
if fkey.pk_table == self:
|
|
1740
|
+
answer.remove(fkey)
|
|
1741
|
+
yield FindAssociationResult(peer, fkey, answer)
|
|
1742
|
+
# arbitrarily choose first fkey to self
|
|
1743
|
+
# in case association is back to same table
|
|
1744
|
+
break
|
|
1745
|
+
|
|
1450
1746
|
@presence_annotation(tag.immutable)
|
|
1451
1747
|
def immutable(self): pass
|
|
1452
1748
|
|
|
@@ -1495,6 +1791,40 @@ class Table (object):
|
|
|
1495
1791
|
@object_annotation(tag.viz_3d_display)
|
|
1496
1792
|
def viz_3d_display(self): pass
|
|
1497
1793
|
|
|
1794
|
+
class Quantifier (str, Enum):
|
|
1795
|
+
"""Logic quantifiers"""
|
|
1796
|
+
any = 'any'
|
|
1797
|
+
all = 'all'
|
|
1798
|
+
|
|
1799
|
+
def find_tables_with_foreign_keys(target_tables: Iterable[Table], quantifier: Quantifier=Quantifier.all) -> set[Table]:
|
|
1800
|
+
"""Return set of tables with foreign key references to target tables.
|
|
1801
|
+
|
|
1802
|
+
:param target_tables: an iterable of ermrest_model.Table instances
|
|
1803
|
+
:param quantifier: one of the Quantifiers 'any' or 'all' (default 'all')
|
|
1804
|
+
|
|
1805
|
+
Each returned Table instance will be a table that references the
|
|
1806
|
+
targets according to the selected quantifier. A reference is a
|
|
1807
|
+
direct foreign key in the returned table that refers to a primary
|
|
1808
|
+
key of the target table.
|
|
1809
|
+
|
|
1810
|
+
- quantifier==all: a returned table references ALL targets
|
|
1811
|
+
- quantifier==any: a returned table references AT LEAST ONE target
|
|
1812
|
+
|
|
1813
|
+
For proper function, all target_tables instances MUST come from
|
|
1814
|
+
the same root Model instance hierarchy.
|
|
1815
|
+
|
|
1816
|
+
"""
|
|
1817
|
+
candidates = None
|
|
1818
|
+
for table in target_tables:
|
|
1819
|
+
referring = { fkey.table for fkey in table.referenced_by }
|
|
1820
|
+
if candidates is None:
|
|
1821
|
+
candidates = referring
|
|
1822
|
+
elif quantifier == Quantifier.all:
|
|
1823
|
+
candidates.intersection_update(referring)
|
|
1824
|
+
else:
|
|
1825
|
+
candidates.update(referring)
|
|
1826
|
+
return candidates
|
|
1827
|
+
|
|
1498
1828
|
class Column (object):
|
|
1499
1829
|
"""Named column.
|
|
1500
1830
|
"""
|
|
@@ -1696,7 +2026,6 @@ class Column (object):
|
|
|
1696
2026
|
|
|
1697
2027
|
@object_annotation(tag.column_display)
|
|
1698
2028
|
def column_display(self): pass
|
|
1699
|
-
|
|
1700
2029
|
|
|
1701
2030
|
def _constraint_name_parts(constraint, doc):
|
|
1702
2031
|
# modern systems should have 0 or 1 names here
|
|
@@ -1781,10 +2110,29 @@ class Key (object):
|
|
|
1781
2110
|
}
|
|
1782
2111
|
|
|
1783
2112
|
@classmethod
|
|
1784
|
-
def define(cls, colnames, constraint_names=[], comment=None, annotations={}):
|
|
1785
|
-
"""Build a key definition.
|
|
2113
|
+
def define(cls, colnames, constraint_names=[], comment=None, annotations={}, constraint_name=None):
|
|
2114
|
+
"""Build a key definition.
|
|
2115
|
+
|
|
2116
|
+
:param colnames: List of names of columns participating in the key
|
|
2117
|
+
:param constraint_names: Legacy input [ [ schema_name, constraint_name ] ] (for API backwards-compatibility)
|
|
2118
|
+
:param comment: Comment string
|
|
2119
|
+
:param annotations: Dictionary of { annotation_uri: annotation_value, ... }
|
|
2120
|
+
:param constraint_name: Constraint name string
|
|
2121
|
+
|
|
2122
|
+
The constraint_name kwarg takes a bare constraint name string
|
|
2123
|
+
and acts the same as setting the legacy constraint_names kwarg
|
|
2124
|
+
to: [ [ "placeholder", constraint_name ] ]. This odd syntax
|
|
2125
|
+
is for backwards-compatibility with earlier API versions, and
|
|
2126
|
+
mirrors the structure of constraint names in ERMrest model
|
|
2127
|
+
description outputs. In those outputs, the "placeholder" field
|
|
2128
|
+
contains the schema name of the table containing the
|
|
2129
|
+
constraint.
|
|
2130
|
+
|
|
2131
|
+
"""
|
|
1786
2132
|
if not isinstance(colnames, list):
|
|
1787
2133
|
raise TypeError('Colnames should be a list.')
|
|
2134
|
+
if constraint_name is not None:
|
|
2135
|
+
constraint_names = [ [ "placeholder", constraint_name ] ]
|
|
1788
2136
|
return {
|
|
1789
2137
|
'unique_columns': list(colnames),
|
|
1790
2138
|
'names': constraint_names,
|
|
@@ -1983,9 +2331,41 @@ class ForeignKey (object):
|
|
|
1983
2331
|
}
|
|
1984
2332
|
|
|
1985
2333
|
@classmethod
|
|
1986
|
-
def define(cls, fk_colnames, pk_sname, pk_tname, pk_colnames, on_update='NO ACTION', on_delete='NO ACTION', constraint_names=[], comment=None, acls={}, acl_bindings={}, annotations={}):
|
|
2334
|
+
def define(cls, fk_colnames, pk_sname, pk_tname, pk_colnames, on_update='NO ACTION', on_delete='NO ACTION', constraint_names=[], comment=None, acls={}, acl_bindings={}, annotations={}, constraint_name=None):
|
|
2335
|
+
"""Define a foreign key.
|
|
2336
|
+
|
|
2337
|
+
:param fk_colnames: List of column names participating in the foreign key
|
|
2338
|
+
:param pk_sname: Schema name string of the referenced primary key
|
|
2339
|
+
:param pk_tname: Table name string of the referenced primary key
|
|
2340
|
+
:param pk_colnames: List of column names participating in the referenced primary key
|
|
2341
|
+
:param on_update: Constraint behavior when referenced primary keys are updated
|
|
2342
|
+
:param on_update: Constraint behavior when referenced primary keys are deleted
|
|
2343
|
+
:param constraint_names: Legacy input [ [ schema_name, constraint_name ] ] (for API backwards-compatibility)
|
|
2344
|
+
:param comment: Comment string
|
|
2345
|
+
:param acls: Dictionary of { acl_name: acl, ... }
|
|
2346
|
+
:param acl_bindings: Dictionary of { binding_name: acl_binding, ... }
|
|
2347
|
+
:param annotations: Dictionary of { annotation_uri: annotation_value, ... }
|
|
2348
|
+
:param constraint_name: Constraint name string
|
|
2349
|
+
|
|
2350
|
+
The contraint behavior values for on_update and on_delete must
|
|
2351
|
+
be one of the following literal strings:
|
|
2352
|
+
|
|
2353
|
+
'NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT'
|
|
2354
|
+
|
|
2355
|
+
The constraint_name kwarg takes a bare constraint name string
|
|
2356
|
+
and acts the same as setting the legacy constraint_names kwarg
|
|
2357
|
+
to: [ [ "placeholder", constraint_name ] ]. This odd syntax
|
|
2358
|
+
is for backwards-compatibility with earlier API versions, and
|
|
2359
|
+
mirrors the structure of constraint names in ERMrest model
|
|
2360
|
+
description outputs. In those outputs, the "placeholder" field
|
|
2361
|
+
contains the schema name of the table containing the
|
|
2362
|
+
constraint.
|
|
2363
|
+
|
|
2364
|
+
"""
|
|
1987
2365
|
if len(fk_colnames) != len(pk_colnames):
|
|
1988
2366
|
raise ValueError('The fk_colnames and pk_colnames lists must have the same length.')
|
|
2367
|
+
if constraint_name is not None:
|
|
2368
|
+
constraint_names = [ [ "placeholder", constraint_name ], ]
|
|
1989
2369
|
return {
|
|
1990
2370
|
'foreign_key_columns': [
|
|
1991
2371
|
{
|
deriva/core/hatrac_cli.py
CHANGED
|
@@ -185,14 +185,14 @@ class DerivaHatracCLI (BaseCLI):
|
|
|
185
185
|
|
|
186
186
|
try:
|
|
187
187
|
acls = self.store.get_acl(self.resource, args.access, args.role)
|
|
188
|
+
if acls is None:
|
|
189
|
+
raise DerivaHatracCLIException('No such object or namespace or ACL entry')
|
|
188
190
|
for access in acls:
|
|
189
191
|
print("%s:" % access)
|
|
190
192
|
for role in acls.get(access, []):
|
|
191
193
|
print(" %s" % role)
|
|
192
194
|
except HTTPError as e:
|
|
193
|
-
if e.response.status_code == requests.codes.
|
|
194
|
-
raise ResourceException('No such object or namespace or ACL entry', e)
|
|
195
|
-
elif e.response.status_code == requests.codes.bad_request:
|
|
195
|
+
if e.response.status_code == requests.codes.bad_request:
|
|
196
196
|
raise ResourceException('Invalid ACL name %s' % args.access, e)
|
|
197
197
|
else:
|
|
198
198
|
raise e
|
|
@@ -316,6 +316,8 @@ class DerivaHatracCLI (BaseCLI):
|
|
|
316
316
|
except HatracHashMismatch as e:
|
|
317
317
|
logging.debug(format_exception(e))
|
|
318
318
|
eprint(_resource_error_message('Checksum verification failed'))
|
|
319
|
+
except DerivaHatracCLIException as e:
|
|
320
|
+
eprint(e)
|
|
319
321
|
except RuntimeError as e:
|
|
320
322
|
logging.debug(format_exception(e))
|
|
321
323
|
eprint('Unexpected runtime error occurred')
|
|
@@ -52,7 +52,7 @@ class GlobusAuthUtil:
|
|
|
52
52
|
client_id = kwargs.get("client_id")
|
|
53
53
|
client_secret = kwargs.get("client_secret")
|
|
54
54
|
if not (client_id and client_secret):
|
|
55
|
-
cred_file = kwargs.get("credential_file", CLIENT_CRED_FILE)
|
|
55
|
+
cred_file = kwargs.get("credential_file", CLIENT_CRED_FILE) or CLIENT_CRED_FILE
|
|
56
56
|
if os.path.isfile(cred_file):
|
|
57
57
|
creds = read_config(cred_file)
|
|
58
58
|
if creds:
|
|
@@ -60,6 +60,8 @@ class GlobusAuthUtil:
|
|
|
60
60
|
if client:
|
|
61
61
|
client_id = client.get('client_id')
|
|
62
62
|
client_secret = client.get('client_secret')
|
|
63
|
+
else:
|
|
64
|
+
logging.warning("No Globus client credential file found at: %s" % cred_file)
|
|
63
65
|
|
|
64
66
|
if not (client_id and client_secret):
|
|
65
67
|
logging.warning("Client ID and secret not specified and/or could not be determined.")
|
|
@@ -233,8 +233,11 @@ class DerivaUpload(object):
|
|
|
233
233
|
self.credentials = credentials
|
|
234
234
|
self.catalog.set_credentials(self.credentials, host)
|
|
235
235
|
self.store.set_credentials(self.credentials, host)
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
try:
|
|
237
|
+
attributes = self.catalog.get_authn_session().json()
|
|
238
|
+
self.identity = attributes.get("client", self.identity)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.warning("Unable to determine user identity: %s" % e)
|
|
238
241
|
|
|
239
242
|
def setConfig(self, config_file):
|
|
240
243
|
if not config_file:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: deriva
|
|
3
|
-
Version: 1.7.
|
|
3
|
+
Version: 1.7.3
|
|
4
4
|
Summary: Python APIs and CLIs (Command-Line Interfaces) for the DERIVA platform.
|
|
5
5
|
Home-page: https://github.com/informatics-isi-edu/deriva-py
|
|
6
6
|
Author: USC Information Sciences Institute, Informatics Systems Research Division
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
deriva/__init__.py,sha256=h-QyvMVzDNpT3jyVskcSbUVFXxGCRxieFPrvTveZG9k,64
|
|
2
2
|
deriva/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
deriva/config/acl_config.py,sha256=yZ0g_Cwv1MZXajUP2sVbc928XakUDu5Q97w9aK3VCCE,23946
|
|
4
|
-
deriva/config/annotation_config.py,sha256=
|
|
4
|
+
deriva/config/annotation_config.py,sha256=sWcJnRfS5vcb85IdUOd420cmFHRyb4br5OxLCOssbec,11714
|
|
5
5
|
deriva/config/annotation_validate.py,sha256=ZN0jq6As49hfsAmWONUxAStqFG9TeYzvCmRU9TQ-zD8,5228
|
|
6
6
|
deriva/config/base_config.py,sha256=Y5sFotuAWWyo-LZvzbzVY2ZDaC-zhwbMXusgBF1OJYM,20724
|
|
7
7
|
deriva/config/dump_catalog_annotations.py,sha256=QzaWDLfWIAQ0eWVV11zeceWgwDBOYIePJnDQgRydwTE,9362
|
|
8
8
|
deriva/config/rollback_annotation.py,sha256=EMVpuaMh2VaXXoHhsr3ldc7g7e92ngszEswdGNEmGFQ,3020
|
|
9
9
|
deriva/config/examples/group_owner_policy.json,sha256=8v3GWM1F_BWnYD9x_f6Eo4kBDvyy8g7mRqujfoEKLNc,2408
|
|
10
10
|
deriva/config/examples/self_serve_policy.json,sha256=pW-cqWz4rJNNXwY4eVZFkQ8gKCHclC9yDa22ylfcDqY,1676
|
|
11
|
-
deriva/core/__init__.py,sha256=
|
|
11
|
+
deriva/core/__init__.py,sha256=ZjDR3suQoZV7qrKaA7JdSLjopu6XDHXRC7_yfM6rgmE,4945
|
|
12
12
|
deriva/core/annotation.py,sha256=PkAkPkxX1brQsb8_drR1Qj5QjQA5mjkpXhkq9NuZ1g8,13432
|
|
13
13
|
deriva/core/base_cli.py,sha256=EkLXOTeaFWUbPaYV-eLuLGga1PbkFVWi3Jjo-e_Vb-U,2681
|
|
14
14
|
deriva/core/catalog_cli.py,sha256=-6Bo6GLWFWap7y3VxkzPs73HAe_XzRXIJMW-Ri84m3M,23273
|
|
15
|
-
deriva/core/datapath.py,sha256=
|
|
15
|
+
deriva/core/datapath.py,sha256=HmmV3dZtEe9lHr2b9dYyyVdvzZwy6p07ZUF9ocbFvo0,89248
|
|
16
16
|
deriva/core/deriva_binding.py,sha256=_sA9HGrcVRqT-OhrneMDMOquyVOFOxLq3WzBQhasLIM,12970
|
|
17
17
|
deriva/core/deriva_server.py,sha256=nsW3gwg1sIaHl3BTf-nL41AkSj3dEpcEBlatvjvN8CQ,200
|
|
18
|
-
deriva/core/ermrest_catalog.py,sha256=
|
|
19
|
-
deriva/core/ermrest_model.py,sha256=
|
|
20
|
-
deriva/core/hatrac_cli.py,sha256=
|
|
18
|
+
deriva/core/ermrest_catalog.py,sha256=_eqQg16i1aA95R99B7tLZxHWQlYk-rLpN_0zghfNWRc,54991
|
|
19
|
+
deriva/core/ermrest_model.py,sha256=pf8HC_ecm2NiMF0rg6UjbAuTR4ZKMhf5o5JmMYYuV8I,101855
|
|
20
|
+
deriva/core/hatrac_cli.py,sha256=J8Vg2BPMqNrRC3egr0IUml-_Eo3UxuhCvk_dInjbCiw,14554
|
|
21
21
|
deriva/core/hatrac_store.py,sha256=t7gEsZ3SJqt1RefSGc3728PQoVs9E5UWEBNGGbbG6Bw,22582
|
|
22
22
|
deriva/core/polling_ermrest_catalog.py,sha256=KsjiFqPQaHWnJZCVF5i77sdzfubqZHgMBbQ1p8V8D3s,10351
|
|
23
23
|
deriva/core/schemas/app_links.schema.json,sha256=AxrkC2scxomM6N7jyjtdYA73BbZzPrmuqU8PYWe7okI,954
|
|
@@ -44,7 +44,7 @@ deriva/core/schemas/visible_foreign_keys.schema.json,sha256=K-oa2qzj5EbmJCEyN6mN
|
|
|
44
44
|
deriva/core/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
45
45
|
deriva/core/utils/__init__.py,sha256=XSbGaWe44hebxYvoh5huFzZkMY6TSKPOCRSjUOvaY70,124
|
|
46
46
|
deriva/core/utils/core_utils.py,sha256=5xUjJ-1QrPFKOiwfSWFDdH8Hm5a-AEhzCvy2GJPjx4A,19500
|
|
47
|
-
deriva/core/utils/globus_auth_utils.py,sha256=
|
|
47
|
+
deriva/core/utils/globus_auth_utils.py,sha256=huPzKSr0d2NirpLzyp6CSyR1gIAdlRuP-X0jRo5W_p8,55732
|
|
48
48
|
deriva/core/utils/hash_utils.py,sha256=JqUYVB3jXusCQYX9fkKmweUKBC0WQi8ZI2N8m-uKygQ,2299
|
|
49
49
|
deriva/core/utils/mime_utils.py,sha256=ZT7pMjY2kQWBsgsGC3jY6kjfygdsIyiYW3BKNw_pPyg,1128
|
|
50
50
|
deriva/core/utils/version_utils.py,sha256=HWNUQAZrPXu0oGjhG6gMNm3kWtfa3nR50vfH2uQxBA0,2954
|
|
@@ -85,7 +85,7 @@ deriva/transfer/restore/deriva_restore.py,sha256=s0h7cXit2USSdjrIfrj0dr7BJ0rrHHM
|
|
|
85
85
|
deriva/transfer/restore/deriva_restore_cli.py,sha256=2ViZ1Lyl5ndXPKeJFCHHGnwzkg3DfHhTuRa_bN7eJm8,5603
|
|
86
86
|
deriva/transfer/upload/__init__.py,sha256=4mlc_iUX-v7SpXzlCZmhxQtSiW5JeDGb2FX7bb1E6tY,304
|
|
87
87
|
deriva/transfer/upload/__main__.py,sha256=hqnXtGpRqPthwpO6uvrnf_TQm7McheeyOt960hStSMY,340
|
|
88
|
-
deriva/transfer/upload/deriva_upload.py,sha256=
|
|
88
|
+
deriva/transfer/upload/deriva_upload.py,sha256=GuQb_-PBKR0JstEifXqSOZpF1zTyaPLfM0PCDlu2Qw8,60500
|
|
89
89
|
deriva/transfer/upload/deriva_upload_cli.py,sha256=-Q6xgiYabQziTQcMQdGNDAv-eLxCCHO-BCSo4umbDE4,5082
|
|
90
90
|
deriva/transfer/upload/processors/__init__.py,sha256=sMM5xdJ82UIRdB1lGMKk7ft0BgtjS2oJ0sI4SQSqiIU,2481
|
|
91
91
|
deriva/transfer/upload/processors/archive_processor.py,sha256=ID0lDwDn4vPe5nbxy6m28Ssj_TsZpK4df2xRrM6nJRQ,2015
|
|
@@ -98,9 +98,9 @@ deriva/utils/__init__.py,sha256=jv2YF__bseklT3OWEzlqJ5qE24c4aWd5F4r0TTjOrWQ,65
|
|
|
98
98
|
tests/deriva/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
99
99
|
tests/deriva/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
100
100
|
tests/deriva/core/test_datapath.py,sha256=hC5PqyL9zqNOV4ydY5L4pHdt8r7Or7OgZnX-F52P2nU,37308
|
|
101
|
-
deriva-1.7.
|
|
102
|
-
deriva-1.7.
|
|
103
|
-
deriva-1.7.
|
|
104
|
-
deriva-1.7.
|
|
105
|
-
deriva-1.7.
|
|
106
|
-
deriva-1.7.
|
|
101
|
+
deriva-1.7.3.dist-info/LICENSE,sha256=tAkwu8-AdEyGxGoSvJ2gVmQdcicWw3j1ZZueVV74M-E,11357
|
|
102
|
+
deriva-1.7.3.dist-info/METADATA,sha256=u8fofL_SdxidGBInnPPmjbb5qDgZWN6iBFzFwGvsJT0,1623
|
|
103
|
+
deriva-1.7.3.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
104
|
+
deriva-1.7.3.dist-info/entry_points.txt,sha256=72BEmEE4Bes5QhVxUHrl7EvUARrgISWxI2KGa8BbNZ8,786
|
|
105
|
+
deriva-1.7.3.dist-info/top_level.txt,sha256=_LHDie5-O53wFlexfrxjewpVkf04oydf3CqX5h75DXE,13
|
|
106
|
+
deriva-1.7.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|