deriva 1.7.11__py3-none-any.whl → 1.7.12__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/core/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "1.7.11"
1
+ __version__ = "1.7.12"
2
2
 
3
3
  from deriva.core.utils.core_utils import *
4
4
  from deriva.core.base_cli import BaseCLI, KeyValuePairArgs
@@ -1,9 +1,9 @@
1
1
  import logging
2
2
  import os
3
3
  import sys
4
+ import json
4
5
  import requests
5
6
  import traceback
6
- from pprint import pp
7
7
  from requests.exceptions import HTTPError, ConnectionError
8
8
  from deriva.core import __version__ as VERSION, BaseCLI, KeyValuePairArgs, DerivaServer, DerivaPathError, \
9
9
  get_credential, format_credential, format_exception, read_config, DEFAULT_SESSION_CONFIG, DEFAULT_HEADERS
@@ -12,7 +12,7 @@ from deriva.core.utils import eprint
12
12
 
13
13
 
14
14
  class DerivaCatalogCLIException (Exception):
15
- """Base exception class for DerivaHatracCli.
15
+ """Base exception class for DerivaCatalogCli.
16
16
  """
17
17
  def __init__(self, message):
18
18
  """Initializes the exception.
@@ -40,7 +40,7 @@ class ResourceException (DerivaCatalogCLIException):
40
40
 
41
41
 
42
42
  class DerivaCatalogCLI (BaseCLI):
43
- """Deriva Hatrac Command-line Interface.
43
+ """Deriva Catalog Utility Command-line Interface.
44
44
  """
45
45
  def __init__(self, description, epilog):
46
46
  """Initializes the CLI.
@@ -193,7 +193,7 @@ class DerivaCatalogCLI (BaseCLI):
193
193
 
194
194
  @staticmethod
195
195
  def _get_session_config():
196
- config = read_config()
196
+ config = read_config(create_default=True)
197
197
  return config.get("session", DEFAULT_SESSION_CONFIG)
198
198
 
199
199
  def _post_parser_init(self, args):
@@ -233,7 +233,7 @@ class DerivaCatalogCLI (BaseCLI):
233
233
  """Implements the catalog_exists sub-command.
234
234
  """
235
235
  catalog = self.server.connect_ermrest(self.id)
236
- pp(catalog.exists())
236
+ print(catalog.exists())
237
237
 
238
238
  def catalog_create(self, args):
239
239
  """Implements the catalog_create sub-command.
@@ -248,8 +248,7 @@ class DerivaCatalogCLI (BaseCLI):
248
248
  model = catalog.getCatalogModel()
249
249
  model.configure_baseline_catalog(**args.configure_args)
250
250
  if not args.quiet:
251
- print("Created new catalog %s with the following default configuration:\n" % catalog.catalog_id)
252
- pp(catalog.get('/').json())
251
+ print(json.dumps(catalog.get('/').json()))
253
252
  except HTTPError as e:
254
253
  if e.response.status_code == requests.codes.not_found:
255
254
  raise ResourceException('Catalog not found', e)
@@ -290,15 +289,16 @@ class DerivaCatalogCLI (BaseCLI):
290
289
  headers=headers,
291
290
  delete_if_empty=args.auto_delete)
292
291
  else:
293
- pp(catalog.get(args.path, headers=headers).json())
292
+ print(json.dumps(catalog.get(args.path, headers=headers).json()))
294
293
  except HTTPError as e:
295
294
  if e.response.status_code == requests.codes.not_found:
296
295
  raise ResourceException('Catalog not found', e)
297
- except:
296
+ else:
297
+ raise e
298
+ finally:
298
299
  if args.output_file and os.path.isfile(args.output_file):
299
- logging.info("Deleting empty file: %s" % args.output_file)
300
+ logging.info("Deleting empty output file: %s" % args.output_file)
300
301
  os.remove(args.output_file)
301
- raise
302
302
 
303
303
  def catalog_put(self, args):
304
304
  """Implements the catalog_put sub-command.
@@ -311,7 +311,7 @@ class DerivaCatalogCLI (BaseCLI):
311
311
  with open(args.input_file, "rb") as input_file:
312
312
  resp = catalog.put(args.path, data=input_file, headers=headers)
313
313
  if not args.quiet:
314
- pp(resp.json())
314
+ print(json.dumps(resp.json()))
315
315
  except HTTPError as e:
316
316
  if e.response.status_code == requests.codes.not_found:
317
317
  raise ResourceException('Catalog not found', e)
@@ -329,7 +329,7 @@ class DerivaCatalogCLI (BaseCLI):
329
329
  with open(args.input_file, "rb") as input_file:
330
330
  resp = catalog.post(args.path, data=input_file, headers=headers)
331
331
  if not args.quiet:
332
- pp(resp.json())
332
+ print(json.dumps(resp.json()))
333
333
  except HTTPError as e:
334
334
  if e.response.status_code == requests.codes.not_found:
335
335
  raise ResourceException('Catalog not found', e)
@@ -380,8 +380,7 @@ class DerivaCatalogCLI (BaseCLI):
380
380
  owner = args.owner if args.owner else None
381
381
  alias = self.server.create_ermrest_alias(args.id, owner, args.alias_target)
382
382
  if not args.quiet:
383
- print("Created new catalog alias %s with the following configuration:\n" % alias.alias_id)
384
- pp(alias.retrieve())
383
+ print(json.dumps(alias.retrieve()))
385
384
  except HTTPError as e:
386
385
  if e.response.status_code == requests.codes.not_found:
387
386
  raise ResourceException('Catalog alias not found', e)
@@ -397,7 +396,7 @@ class DerivaCatalogCLI (BaseCLI):
397
396
  alias = self.server.connect_ermrest_alias(args.id)
398
397
  response = alias.retrieve()
399
398
  if not args.quiet:
400
- pp(response)
399
+ print(json.dumps(response))
401
400
  except HTTPError as e:
402
401
  if e.response.status_code == requests.codes.not_found:
403
402
  raise ResourceException('Catalog alias not found', e)
@@ -409,8 +408,7 @@ class DerivaCatalogCLI (BaseCLI):
409
408
  owner = args.owner if args.owner else None
410
409
  alias = self.server.connect_ermrest_alias(args.id)
411
410
  response = alias.update(owner, args.alias_target, args.id)
412
- print("Updated catalog alias %s with the following configuration:\n" % alias.alias_id)
413
- pp(response)
411
+ print(json.dumps(response))
414
412
  except HTTPError as e:
415
413
  if e.response.status_code == requests.codes.not_found:
416
414
  raise ResourceException('Catalog alias not found', e)
@@ -1138,7 +1138,7 @@ class ErmrestAlias(DerivaBinding):
1138
1138
  )
1139
1139
 
1140
1140
  @classmethod
1141
- def _digest_alias_args(cls, id, owner, alias_target, name, description):
1141
+ def _digest_alias_args(cls, id, owner, alias_target, name=None, description=None):
1142
1142
  rep = ErmrestCatalog._digest_catalog_args(id, owner, name, description)
1143
1143
 
1144
1144
  if isinstance(alias_target, (str, type(None))):
@@ -1,6 +1,7 @@
1
1
 
2
2
  from __future__ import annotations
3
3
 
4
+ import datetime
4
5
  import base64
5
6
  import hashlib
6
7
  import json
@@ -10,7 +11,10 @@ from collections.abc import Iterable
10
11
  from enum import Enum
11
12
 
12
13
  from . import AttrDict, tag, urlquote, stob, mmo
13
-
14
+ from . import \
15
+ crockford_b32encode, crockford_b32decode, \
16
+ int_to_uintX, uintX_to_int, \
17
+ datetime_to_epoch_microseconds, epoch_microseconds_to_datetime
14
18
 
15
19
  class NoChange (object):
16
20
  """Special class used to distinguish no-change default arguments to methods.
@@ -111,6 +115,93 @@ def sql_literal(v):
111
115
  s = '%s' % v
112
116
  return "'%s'" % (s.replace("'", "''"))
113
117
 
118
+ def timestamptz_to_datetime(ts: str) -> datetime.datetime:
119
+ """Convert an ERMrest (i.e. PostgreSQL) timestamptz string to native datetime.
120
+
121
+ :param ts: A string in ISO format as serialized by ERMrest.
122
+ """
123
+ # Workaround for fromisoformat() limitations in older Python 3.x:
124
+ # Make sure ISO sring has exactly 6 digits of fractional second
125
+ # precision. We can remove this rewrite phase once we bump our
126
+ # minimum language version requirement.
127
+ m = re.match(
128
+ # groups: basetimestamp fracseconds tzoffset
129
+ "^([-0-9]+[T ][:0-9]+)([.][0-9]+)?([-+][:0-9]+)$",
130
+ ts,)
131
+ if m:
132
+ parts = list(m.group(1, 2, 3))
133
+ if parts[1]:
134
+ frac = float(parts[1])
135
+ parts[1] = ("%.6f" % frac).lstrip('0')
136
+ else:
137
+ parts[1] = '.000000'
138
+ ts = '%s%s%s' % tuple(parts)
139
+ else:
140
+ raise ValueError(f'bad operand to timestamptz_to_datetime() {ts=}')
141
+
142
+ return datetime.datetime.fromisoformat(ts)
143
+
144
+ def datetime_to_timestamptz(dt: datetime.datetime) -> str:
145
+ """Convert a native datetime to an ERMrest timestamptz string.
146
+
147
+ :param dt: A timezone-aware datetime.datetime instance.
148
+ """
149
+ return dt.isoformat(' ')
150
+
151
+ def epoch_microseconds_to_snaptime(us: int) -> str:
152
+ """Convert microseconds-since-epoch to ERMrest snaptime format.
153
+
154
+ :param us: Signed integer microseconds-since-epoch.
155
+
156
+ This function includes a bit-shift necessary to introduce the
157
+ padding that ERMrest uses to pack a 64-bit value into a 65-bit
158
+ base32 encoding scheme.
159
+ """
160
+ if not isinstance(us, int) or us > 2**63-1 or us < -2**63:
161
+ raise ValueError(us)
162
+
163
+ return crockford_b32encode(int_to_uintX(us, 64) << 1)
164
+
165
+ def snaptime_to_epoch_microseconds(s: str) -> int:
166
+ """Convert ERMrest snaptime format to integer microseconds-since-epoch.
167
+
168
+ :param s: The ERMrest snaptime string.
169
+
170
+ This function includes a bit-shift necessary to strip the
171
+ padding that ERMrest uses to pack a 64-bit value into a 65-bit
172
+ base32 encoding scheme.
173
+ """
174
+ return uintX_to_int(crockford_b32decode(s) >> 1, 64)
175
+
176
+ def datetime_to_snaptime(dt: datetime.datetime) -> str:
177
+ """Convert a datetime to ERMrest snaptime format.
178
+
179
+ :param dt: A timezone-aware datetime.datetime instance.
180
+ """
181
+ return epoch_microseconds_to_snaptime(datetime_to_epoch_microseconds(dt))
182
+
183
+ def snaptime_to_datetime(s: str) -> datetime.datetime:
184
+ """Convert ERMrest snatime format to datetime.
185
+
186
+ :param s: The ERMrest snaptime string.
187
+ """
188
+ return epoch_microseconds_to_datetime(snaptime_to_epoch_microseconds(s))
189
+
190
+ def snaptime_to_timestamptz(s: str) -> str:
191
+ """Convert ERMrest stamptime format to ERMrest timestamptz string.
192
+
193
+ :param s: The ERMrest snaptime string.
194
+
195
+ """
196
+ return datetime_to_timestamptz(snaptime_to_datetime(s))
197
+
198
+ def timestamptz_to_snaptime(ts: str) -> str:
199
+ """Convert ERMrest timestamptz str to ERMrest snaptime format.
200
+
201
+ :param ts: A string in ISO datetime format as serialized by ERMrest.
202
+ """
203
+ return datetime_to_snaptime(timestamptz_to_datetime(ts))
204
+
114
205
  def presence_annotation(tag_uri):
115
206
  """Decorator to establish property getter/setter/deleter for presence annotations.
116
207
 
deriva/core/hatrac_cli.py CHANGED
@@ -112,6 +112,8 @@ class DerivaHatracCLI (BaseCLI):
112
112
  help="Chunk size in bytes")
113
113
  putobj_parser.add_argument("--parents", action="store_true",
114
114
  help="Create intermediate parent namespaces as required")
115
+ putobj_parser.add_argument("--force", action="store_true",
116
+ help="Force upload even if same object content already exists")
115
117
  putobj_parser.set_defaults(func=self.putobj)
116
118
 
117
119
  # delobj parser
@@ -119,6 +121,13 @@ class DerivaHatracCLI (BaseCLI):
119
121
  delobj_parser.add_argument("resource", metavar="<path>", type=str, help="object path")
120
122
  delobj_parser.set_defaults(func=self.delobj)
121
123
 
124
+ # renobj parser
125
+ renobj_parser = subparsers.add_parser('rename', help="rename object")
126
+ renobj_parser.add_argument('source_name', metavar="<source_name>", type=str, help="source object path")
127
+ renobj_parser.add_argument("resource", metavar="<path>", type=str, help="object path")
128
+ renobj_parser.add_argument("--copy-acls", action="store_true", help="copy source ACLs to new resource")
129
+ renobj_parser.set_defaults(func=self.renobj)
130
+
122
131
  @staticmethod
123
132
  def _get_credential(host_name, token=None, oauth2_token=None):
124
133
  if token or oauth2_token:
@@ -131,7 +140,7 @@ class DerivaHatracCLI (BaseCLI):
131
140
  """
132
141
  self.host = args.host if args.host else 'localhost'
133
142
  self.resource = args.resource
134
- self.store = HatracStore('https', args.host, DerivaHatracCLI._get_credential(self.host,
143
+ self.store = HatracStore('https', self.host, DerivaHatracCLI._get_credential(self.host,
135
144
  token=args.token,
136
145
  oauth2_token=args.oauth2_token))
137
146
 
@@ -256,7 +265,8 @@ class DerivaHatracCLI (BaseCLI):
256
265
  content_type=content_type,
257
266
  chunked=True if file_size > args.chunk_size else False,
258
267
  chunk_size=args.chunk_size,
259
- create_parents=args.parents)
268
+ create_parents=args.parents,
269
+ force=args.force)
260
270
  print(loc)
261
271
  except HTTPError as e:
262
272
  if e.response.status_code == requests.codes.not_found:
@@ -278,6 +288,24 @@ class DerivaHatracCLI (BaseCLI):
278
288
  else:
279
289
  raise e
280
290
 
291
+ def renobj(self, args):
292
+ """Implements the renobj sub-command.
293
+ """
294
+ try:
295
+ loc = self.store.rename_obj(
296
+ args.source_name,
297
+ self.resource,
298
+ copy_acls=args.copy_acls)
299
+ print(loc)
300
+ except HTTPError as e:
301
+ if e.response.status_code == requests.codes.conflict:
302
+ raise ResourceException(
303
+ f'Source name "{args.source_name}" must name an existing object', e)
304
+ elif e.response.status_code == requests.codes.method_not_allowed:
305
+ raise UsageException('operation not support by host. To enable this feature, upgrade Hatrac service.')
306
+ else:
307
+ raise e
308
+
281
309
  def main(self):
282
310
  """Main routine of the CLI.
283
311
  """
@@ -160,7 +160,8 @@ class HatracStore(DerivaBinding):
160
160
  parents=True,
161
161
  content_type=None,
162
162
  content_disposition=None,
163
- allow_versioning=True):
163
+ allow_versioning=True,
164
+ force=False):
164
165
  """Idempotent upload of object, returning object location URI.
165
166
 
166
167
  Arguments:
@@ -173,6 +174,7 @@ class HatracStore(DerivaBinding):
173
174
  content_type: the content-type of the object (optional)
174
175
  content_disposition: the preferred content-disposition of the object (optional)
175
176
  allow_versioning: reject with NotModified if content already exists (optional)
177
+ force: upload object even if it already exists and/or matches content (optional)
176
178
  Automatically computes and sends Content-MD5 if no digests provided.
177
179
 
178
180
  If an object-version already exists under the same name
@@ -204,22 +206,23 @@ class HatracStore(DerivaBinding):
204
206
  raise ValueError("The PUT request payload size of %d bytes is larger than the currently allowed maximum "
205
207
  "payload size of %d bytes for single request PUT operations. Use the 'put_loc' function "
206
208
  "to perform chunked uploads of large data objects." % (file_size, max_request_size))
207
- try:
208
- r = self.head(path)
209
- if r.status_code == 200:
210
- if (md5 and r.headers.get('Content-MD5') == md5 or
211
- sha256 and r.headers.get('Content-SHA256') == sha256):
212
- # object already has same content so skip upload
213
- if file_opened:
214
- f.close()
215
- return r.headers.get('Content-Location')
216
- elif not allow_versioning:
217
- raise NotModified("The data cannot be uploaded because content already exists for this object "
218
- "and multiple versions are not allowed.")
219
- except requests.HTTPError as e:
220
- if e.response.status_code != 404:
221
- logging.debug("HEAD request failed: %s" % format_exception(e))
222
- pass
209
+ if not force:
210
+ try:
211
+ r = self.head(path)
212
+ if r.status_code == 200:
213
+ if (md5 and r.headers.get('Content-MD5') == md5 or
214
+ sha256 and r.headers.get('Content-SHA256') == sha256):
215
+ # object already has same content so skip upload
216
+ if file_opened:
217
+ f.close()
218
+ return r.headers.get('Content-Location')
219
+ elif not allow_versioning:
220
+ raise NotModified("The data cannot be uploaded because content already exists for this object "
221
+ "and multiple versions are not allowed.")
222
+ except requests.HTTPError as e:
223
+ if e.response.status_code != 404:
224
+ logging.debug("HEAD request failed: %s" % format_exception(e))
225
+ pass
223
226
 
224
227
  # TODO: verify incoming hashes if supplied?
225
228
  headers['Content-MD5'] = md5
@@ -250,6 +253,54 @@ class HatracStore(DerivaBinding):
250
253
  self.delete(path)
251
254
  logging.debug('Deleted object "%s%s".' % (self._server_uri, path))
252
255
 
256
+ def rename_obj(self,
257
+ source_path,
258
+ path,
259
+ versions=None,
260
+ copy_acls=False,
261
+ headers=DEFAULT_HEADERS):
262
+ """Rename object optionally specifying versions to process and whether to copy acls.
263
+
264
+ :param source_path: source object path
265
+ :param path: new path for object
266
+ :param versions: list of object versions to process, by default it processes all versions
267
+ :param copy_acls: if true, copy all source acls, else default to set basic ownership like a new object version
268
+ :param headers: headers to send to hatrac
269
+ :return: resource location
270
+ """
271
+ self.check_path(source_path)
272
+ self.check_path(path)
273
+
274
+ headers = headers.copy()
275
+ headers['deriva-client-context'] = self.dcctx.merged(headers.get('deriva-client-context', {})).encoded()
276
+ headers['Content-Type'] = 'application/json'
277
+
278
+ obj = {
279
+ "command": "rename_from",
280
+ "source_name": source_path.removeprefix('/hatrac'),
281
+ }
282
+
283
+ if versions:
284
+ if not isinstance(versions, list):
285
+ raise TypeError('"versions" argument must be a list')
286
+ if not all(isinstance(v, str) for v in versions):
287
+ raise TypeError('elements of "versions" argument must be strings')
288
+ obj["versions"] = versions
289
+
290
+ if copy_acls:
291
+ if copy_acls is not True:
292
+ raise TypeError('"copy_acls" argument must be a boolean')
293
+ obj["copy_acls"] = copy_acls
294
+
295
+ logging.debug(f'rename object message body: {str(obj)}')
296
+
297
+ r = self._session.post(self._server_uri + path, json=obj, headers=headers)
298
+ self._response_raise_for_status(r)
299
+ loc = r.text.strip() or r.url
300
+ if loc.startswith(self._server_uri):
301
+ loc = loc[len(self._server_uri):]
302
+ return loc
303
+
253
304
  def put_loc(self,
254
305
  path,
255
306
  file_path,
@@ -263,7 +314,8 @@ class HatracStore(DerivaBinding):
263
314
  create_parents=True,
264
315
  allow_versioning=True,
265
316
  callback=None,
266
- cancel_job_on_error=True):
317
+ cancel_job_on_error=True,
318
+ force=False):
267
319
  """
268
320
  :param path:
269
321
  :param file_path:
@@ -278,6 +330,7 @@ class HatracStore(DerivaBinding):
278
330
  :param allow_versioning:
279
331
  :param callback:
280
332
  :param cancel_job_on_error:
333
+ :param force:
281
334
  :return:
282
335
  """
283
336
  self.check_path(path)
@@ -291,25 +344,27 @@ class HatracStore(DerivaBinding):
291
344
  content_type=content_type,
292
345
  content_disposition=content_disposition,
293
346
  parents=create_parents,
294
- allow_versioning=allow_versioning)
347
+ allow_versioning=allow_versioning,
348
+ force=force)
295
349
 
296
350
  if not (md5 or sha256):
297
351
  md5 = hu.compute_file_hashes(file_path, hashes=['md5'])['md5'][1]
298
352
 
299
- try:
300
- r = self.head(path)
301
- if r.status_code == 200:
302
- if (md5 and r.headers.get('Content-MD5') == md5 or
303
- sha256 and r.headers.get('Content-SHA256') == sha256):
304
- # object already has same content so skip upload
305
- return r.headers.get('Content-Location')
306
- elif not allow_versioning:
307
- raise NotModified("The file [%s] cannot be uploaded because content already exists for this object "
308
- "and multiple versions are not allowed." % file_path)
309
- except requests.HTTPError as e:
310
- if e.response.status_code != 404:
311
- logging.debug("HEAD request failed: %s" % format_exception(e))
312
- pass
353
+ if not force:
354
+ try:
355
+ r = self.head(path)
356
+ if r.status_code == 200:
357
+ if (md5 and r.headers.get('Content-MD5') == md5 or
358
+ sha256 and r.headers.get('Content-SHA256') == sha256):
359
+ # object already has same content so skip upload
360
+ return r.headers.get('Content-Location')
361
+ elif not allow_versioning:
362
+ raise NotModified("The file [%s] cannot be uploaded because content already exists for this object "
363
+ "and multiple versions are not allowed." % file_path)
364
+ except requests.HTTPError as e:
365
+ if e.response.status_code != 404:
366
+ logging.debug("HEAD request failed: %s" % format_exception(e))
367
+ pass
313
368
 
314
369
  job_id = self.create_upload_job(path,
315
370
  file_path,