pyPreservica 0.9.9__py3-none-any.whl → 3.3.4__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.
pyPreservica/entityAPI.py CHANGED
@@ -9,11 +9,14 @@ licence: Apache License 2.0
9
9
 
10
10
  """
11
11
 
12
+ import os.path
12
13
  import uuid
14
+ import xml.etree.ElementTree
13
15
  from datetime import datetime, timedelta, timezone
16
+ from io import BytesIO
14
17
  from time import sleep
15
- import xml.etree.ElementTree
16
- from typing import Optional, Any
18
+ from typing import Any, Generator, Tuple, Iterable, Union, Callable
19
+
17
20
 
18
21
  from pyPreservica.common import *
19
22
 
@@ -22,116 +25,171 @@ logger = logging.getLogger(__name__)
22
25
 
23
26
  class EntityAPI(AuthenticatedAPI):
24
27
  """
25
- A client library for the Preservica Repository web services Entity API
26
- https://us.preservica.com/api/entity/documentation.html
27
-
28
-
29
- Methods
30
- -------
31
- asset(reference):
32
- Fetches the main XIP attributes for an asset by its reference
28
+ A class for the Preservica Repository web services Entity API
33
29
 
34
- folder(reference):
35
- Fetches the main XIP attributes for a folder by its reference
36
-
37
- content_object(reference):
38
- Fetches the main XIP attributes for a content_object by its reference
30
+ https://us.preservica.com/api/entity/documentation.html
39
31
 
40
- entity(entity_type, reference):
41
- Get an entity by its type and reference
32
+ The EntityAPI allows users to interact with the Preservica repository
42
33
 
43
- metadata(uri):
44
- Return the descriptive metadata attached to an entity.
45
34
 
46
- metadata_for_entity(entity, schema):
47
- Return the metadata fragment for the entity by schema
35
+ """
48
36
 
49
- save(entity):
50
- Updates the title and description of an asset or folder
37
+ def __init__(self, username: str = None, password: str = None, tenant: str = None, server: str = None,
38
+ use_shared_secret: bool = False, two_fa_secret_key: str = None,
39
+ protocol: str = "https", request_hook: Callable = None, credentials_path: str = 'credentials.properties'):
51
40
 
52
- create_folder(title, description, security_tag, parent=None):
53
- creates a new structural object in the repository
41
+ super().__init__(username, password, tenant, server, use_shared_secret, two_fa_secret_key,
42
+ protocol, request_hook, credentials_path)
54
43
 
55
- children(reference, maximum=100, next_page=None):
56
- returns a list of children from the folder
44
+ xml.etree.ElementTree.register_namespace("oai_dc", "http://www.openarchives.org/OAI/2.0/oai_dc/")
45
+ xml.etree.ElementTree.register_namespace("ead", "urn:isbn:1-931666-22-9")
57
46
 
58
- identifier(identifier_type, identifier_value):
59
- returns an asset or folder based on external identifiers
47
+ def user_security_tags(self, with_permissions: bool = False) -> dict:
48
+ """
49
+ Return security tags available for the current user
60
50
 
61
- identifiers_for_entity(entity):
62
- returns a set of identifiers on the entity
51
+ :param with_permissions: Return the permissions for each security tag
52
+ :type with_permissions: bool
63
53
 
64
- add_identifier(entity, identifier_type, identifier_value):
65
- adds a new external identifier to an entity
54
+ :return: dict of security tags
55
+ :rtype: dict
56
+ """
66
57
 
67
- delete_identifier(entity, identifier_type=None, identifier_value=None):
68
- deletes identifiers which belong to an entity
58
+ return self.security_tags_base(with_permissions=with_permissions)
69
59
 
70
- add_metadata(entity, namespace, data):
71
- Add new descriptive metadata to an entity
60
+ def bitstream_chunks(self, bitstream: Bitstream, chunk_size: int = CHUNK_SIZE) -> Generator:
61
+ """
62
+ Generator function to return bitstream chunks, allows the clients to
63
+ process chunks as they are downloaded.
64
+
65
+ :param bitstream: A bitstream object
66
+ :type url: Bitstream
67
+ :param chunk_size: Optional size of the chunks to be downloaded
68
+ :type chunk_size: int
69
+ :return: Iterator
70
+ :rtype: Generator
71
+ """
72
+ if not isinstance(bitstream, Bitstream):
73
+ logger.error("bitstream_content argument is not a Bitstream object")
74
+ raise RuntimeError("bitstream_bytes argument is not a Bitstream object")
75
+ with self.session.get(bitstream.content_url, headers={HEADER_TOKEN: self.token}, stream=True) as request:
76
+ if request.status_code == requests.codes.unauthorized:
77
+ self.token = self.__token__()
78
+ yield from self.bitstream_chunks(bitstream)
79
+ elif request.status_code == requests.codes.ok:
80
+ for chunk in request.iter_content(chunk_size=chunk_size):
81
+ yield chunk
82
+ else:
83
+ exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_chunks",
84
+ request.content.decode('utf-8'))
85
+ logger.error(exception)
86
+ raise exception
72
87
 
73
- update_metadata(entity, namespace, data):
74
- Update the descriptive metadata attached to an entity
88
+ def bitstream_bytes(self, bitstream: Bitstream, chunk_size: int = CHUNK_SIZE) -> Union[BytesIO, None]:
89
+ """
90
+ Download a file represented as a Bitstream to a byteIO array
75
91
 
76
- delete_metadata(entity, schema):
77
- Delete all the metadata fragments on an entity with the schema
78
- Tests - Yes
92
+ Returns the byteIO
93
+ Returns None if the file does not contain the correct number of bytes (default 2k)
79
94
 
80
- download(entity, filename):
81
- Download the first content object of the access representation to the file given by filename
95
+ :param chunk_size: The buffer copy chunk size in bytes default
96
+ :param bitstream: A Bitstream object
97
+ :type bitstream: Bitstream
82
98
 
83
- thumbnail(entity, filename, size=Thumbnail.LARGE):
84
- Download the thumbnail image for an entity
99
+ :return: The file in bytes
100
+ :rtype: byteIO
101
+ """
102
+ if not isinstance(bitstream, Bitstream):
103
+ logger.error("bitstream_content argument is not a Bitstream object")
104
+ raise RuntimeError("bitstream_bytes argument is not a Bitstream object")
105
+ with self.session.get(bitstream.content_url, headers={HEADER_TOKEN: self.token}, stream=True) as response:
106
+ if response.status_code == requests.codes.unauthorized:
107
+ self.token = self.__token__()
108
+ return self.bitstream_bytes(bitstream)
109
+ elif response.status_code == requests.codes.ok:
110
+ file_bytes = BytesIO()
111
+ for chunk in response.iter_content(chunk_size=chunk_size):
112
+ file_bytes.write(chunk)
113
+ file_bytes.seek(0)
114
+ if file_bytes.getbuffer().nbytes == bitstream.length:
115
+ logger.debug(f"Downloaded {bitstream.length} bytes from {bitstream.filename}")
116
+ return file_bytes
117
+ else:
118
+ logger.error("Downloaded file size did not match the Preservica held value")
119
+ return None
120
+ else:
121
+ exception = HTTPException(bitstream.filename, response.status_code, response.url, "bitstream_bytes",
122
+ response.content.decode('utf-8'))
123
+ logger.error(exception)
124
+ raise exception
85
125
 
86
- move(entity, dest_folder):
87
- Move an entity into the folder given by dest_folder
126
+ def bitstream_location(self, bitstream: Bitstream) -> list:
127
+ """"
128
+ Retrieves information about a bitstreams storage locations
88
129
 
89
- bitstream_content(bitstream, filename):
90
- Download a bitstream and save to filename
130
+ :param Bitstream bitstream: The bitstream object
131
+ :return: A list of strings containing all the storage locations of this bitstream
132
+ :rtype: list
91
133
 
92
- security_tag_async(entity, new_tag):
93
- Non-blocking call to change security tag
134
+ """
135
+ if not isinstance(bitstream, Bitstream):
136
+ logger.error("bitstream argument is not a Bitstream object")
137
+ raise RuntimeError("bitstream argument is not a Bitstream object")
94
138
 
95
- security_tag_sync(self, entity, new_tag):
96
- Blocking call to change security tag
139
+ storage_locations = []
97
140
 
98
- representations(asset):
99
- Return a set of representations for the asset
141
+ url: str = f'{self.protocol}://{self.server}/api/entity/content-objects/{bitstream.co_ref}/generations/{bitstream.gen_index}/bitstreams/{bitstream.bs_index}/storage-locations'
100
142
 
101
- content_objects(representation):
102
- Returns an ordered list of content objects in the representation
143
+ with self.session.get(url, headers={HEADER_TOKEN: self.token}, stream=True) as request:
144
+ if request.status_code == requests.codes.ok:
145
+ xml_response = str(request.content.decode('utf-8'))
146
+ entity_response = xml.etree.ElementTree.fromstring(xml_response)
147
+ logger.debug(xml_response)
148
+ locations = entity_response.find(f'.//{{{self.entity_ns}}}StorageLocation')
149
+ for adapter in locations:
150
+ storage_locations.append(adapter.attrib['name'])
151
+ return storage_locations
103
152
 
104
- generations(content_object)
105
- Returns a list of generations for the content object
153
+ if request.status_code == requests.codes.unauthorized:
154
+ self.token = self.__token__()
155
+ return self.bitstream_location(bitstream)
156
+ else:
157
+ exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_location",
158
+ request.content.decode('utf-8'))
159
+ logger.error(exception)
160
+ raise exception
106
161
 
107
- """
108
162
 
109
- def __init__(self, username=None, password=None, tenant=None, server=None, use_shared_secret=False):
110
- super().__init__(username, password, tenant, server, use_shared_secret)
111
- xml.etree.ElementTree.register_namespace("oai_dc", "http://www.openarchives.org/OAI/2.0/oai_dc/")
112
- xml.etree.ElementTree.register_namespace("ead", "urn:isbn:1-931666-22-9")
113
163
 
114
- def bitstream_content(self, bitstream: Bitstream, filename: str):
164
+ def bitstream_content(self, bitstream: Bitstream, filename: str, chunk_size: int = CHUNK_SIZE) -> Union[int, None]:
115
165
  """
116
166
  Download a file represented as a Bitstream to a local filename
117
167
 
118
168
  Returns the number of bytes written to the file
119
169
  Returns None if the file does not contain the correct number of bytes
120
170
 
171
+ :param chunk_size: The buffer copy chunk size in bytes default
121
172
  :param bitstream: A Bitstream object
173
+ :type bitstream: Bitstream
174
+
122
175
  :param filename: The filename to write the bytes to
176
+ :type filename: str
177
+
178
+ :return: The size of the file in bytes
179
+ :rtype: int
180
+
123
181
  """
124
182
 
125
183
  if not isinstance(bitstream, Bitstream):
126
184
  logger.error("bitstream_content argument is not a Bitstream object")
127
185
  raise RuntimeError("bitstream_content argument is not a Bitstream object")
128
- with self.session.get(bitstream.content_url, headers={HEADER_TOKEN: self.token}, stream=True) as req:
129
- if req.status_code == requests.codes.unauthorized:
186
+ with self.session.get(bitstream.content_url, headers={HEADER_TOKEN: self.token}, stream=True) as request:
187
+ if request.status_code == requests.codes.unauthorized:
130
188
  self.token = self.__token__()
131
189
  return self.bitstream_content(bitstream, filename)
132
- elif req.status_code == requests.codes.ok:
190
+ elif request.status_code == requests.codes.ok:
133
191
  with open(filename, 'wb') as file:
134
- for chunk in req.iter_content(chunk_size=CHUNK_SIZE):
192
+ for chunk in request.iter_content(chunk_size=chunk_size):
135
193
  file.write(chunk)
136
194
  file.flush()
137
195
  if os.path.getsize(filename) == bitstream.length:
@@ -142,15 +200,25 @@ class EntityAPI(AuthenticatedAPI):
142
200
  os.remove(filename)
143
201
  return None
144
202
  else:
145
- logger.error(f'bitstream_content failed {req.status_code}')
146
- raise RuntimeError(req.status_code, "bitstream_content failed")
203
+ exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_content",
204
+ request.content.decode('utf-8'))
205
+ logger.error(exception)
206
+ raise exception
147
207
 
148
- def download_opex(self, pid: str):
208
+ def download_opex(self, pid: str) -> str:
149
209
  """
150
- Download DIP request
210
+ Download a completed OPEX export using the workflow process ID
211
+
212
+
213
+ :param pid: A process id which identifiers the export workflow
214
+ :type pid: str
215
+
216
+ :return: The downloaded zip file name
217
+ :rtype: str
218
+
151
219
  """
152
220
  headers = {HEADER_TOKEN: self.__token__(), 'Content-Type': 'application/xml;charset=UTF-8'}
153
- download = self.session.get(f'https://{self.server}/api/entity/actions/exports/{pid}/content',
221
+ download = self.session.get(f'{self.protocol}://{self.server}/api/entity/actions/exports/{pid}/content',
154
222
  stream=True, headers=headers)
155
223
  if download.status_code == requests.codes.ok:
156
224
  with open(f'{pid}.zip', 'wb') as file:
@@ -163,14 +231,16 @@ class EntityAPI(AuthenticatedAPI):
163
231
  self.token = self.__token__()
164
232
  return self.download_opex(pid)
165
233
  else:
166
- logger.error(download)
167
- raise RuntimeError(download.status_code, "download_package failed")
234
+ exception = HTTPException(pid, download.status_code, download.url, "download_opex",
235
+ download.content.decode('utf-8'))
236
+ logger.error(exception)
237
+ raise exception
168
238
 
169
- def __export_opex_start__(self, entity: Entity, **kwargs):
239
+ def __export_opex_start__(self, entity: Entity, **kwargs) -> str:
170
240
  """
171
241
  Initiates export of the entity and downloads the opex package
172
242
 
173
- By default includes content, metadata with the latest active generations
243
+ By default, includes content, metadata with the latest active generations
174
244
  and the parent hierarchy.
175
245
 
176
246
  Arguments are kwargs map
@@ -193,25 +263,25 @@ class EntityAPI(AuthenticatedAPI):
193
263
 
194
264
  include_content = "Content"
195
265
  if 'IncludeContent' in kwargs:
196
- value = kwargs.get("IncludeContent").trim()
266
+ value = str(kwargs.get("IncludeContent")).strip()
197
267
  if value.casefold() in map(str.casefold, include_content_options):
198
268
  include_content = value
199
269
 
200
270
  include_metadata = "Metadata"
201
271
  if 'IncludeMetadata' in kwargs:
202
- value = kwargs.get("IncludeMetadata").trim()
272
+ value = str(kwargs.get("IncludeMetadata")).strip()
203
273
  if value.casefold() in map(str.casefold, include_metadata_options):
204
274
  include_metadata = value
205
275
 
206
276
  include_generation = "All"
207
277
  if 'IncludedGenerations' in kwargs:
208
- value = kwargs.get("IncludedGenerations").trim()
278
+ value = str(kwargs.get("IncludedGenerations")).strip()
209
279
  if value.casefold() in map(str.casefold, include_generation_options):
210
280
  include_generation = value
211
281
 
212
282
  include_parent = "true"
213
283
  if 'IncludeParentHierarchy' in kwargs:
214
- value = kwargs.get("IncludeParentHierarchy").trim()
284
+ value = str(kwargs.get("IncludeParentHierarchy")).strip()
215
285
  if value.casefold() in map(str.casefold, include_parent_options):
216
286
  include_parent = value
217
287
 
@@ -224,29 +294,53 @@ class EntityAPI(AuthenticatedAPI):
224
294
 
225
295
  logger.debug(xml_request)
226
296
 
227
- request = self.session.post(f'https://{self.server}/api/entity/{entity.path}/{entity.reference}/exports',
228
- headers=headers, data=xml_request)
297
+ request = self.session.post(
298
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/exports',
299
+ headers=headers, data=xml_request)
229
300
 
230
301
  if request.status_code == requests.codes.accepted:
231
302
  return str(request.content.decode('utf-8'))
232
303
  elif request.status_code == requests.codes.unauthorized:
233
304
  self.token = self.__token__()
234
- return self.__export_opex_start__(entity)
305
+ return self.__export_opex_start__(entity, IncludeContent=include_content,
306
+ IncludeMetadata=include_metadata, IncludedGenerations=include_generation,
307
+ IncludeParentHierarchy=include_parent)
235
308
  else:
236
- logger.error(str(request.content.decode('utf-8')))
237
- raise RuntimeError(request.status_code, "export_opex failed")
309
+ exception = HTTPException(entity.reference, request.status_code, request.url, "__export_opex_start__",
310
+ request.content.decode('utf-8'))
311
+ logger.error(exception)
312
+ raise exception
238
313
 
239
- def export_opex_async(self, entity: Entity, **kwargs):
314
+ def export_opex_async(self, entity: Entity, **kwargs) -> str:
315
+ """
316
+ Initiates export of the entity returns an id to track progress
317
+ """
240
318
  return self.__export_opex_start__(entity, **kwargs)
241
319
 
242
- def export_opex_sync(self, entity: Entity, **kwargs):
320
+ def export_opex_sync(self, entity: Entity, **kwargs) -> str:
321
+ """
322
+ Initiates export of the entity and downloads the opex package
323
+ Blocks until the package is downloaded
324
+
325
+ By default, includes content, metadata with the latest active generations
326
+ and the parent hierarchy.
327
+
328
+ Arguments are kwargs map
329
+
330
+ IncludeContent
331
+ IncludeMetadata
332
+ IncludedGenerations
333
+ IncludeParentHierarchy
334
+
335
+ """
243
336
  return self.export_opex(entity, **kwargs)
244
337
 
245
- def export_opex(self, entity: Entity, **kwargs):
338
+ def export_opex(self, entity: Entity, **kwargs) -> str:
246
339
  """
247
340
  Initiates export of the entity and downloads the opex package
341
+ Blocks until the package is downloaded
248
342
 
249
- By default includes content, metadata with the latest active generations
343
+ By default, includes content, metadata with the latest active generations
250
344
  and the parent hierarchy.
251
345
 
252
346
  Arguments are kwargs map
@@ -256,6 +350,15 @@ class EntityAPI(AuthenticatedAPI):
256
350
  IncludedGenerations
257
351
  IncludeParentHierarchy
258
352
 
353
+
354
+ :param Entity entity: The entity to export Asset or Folder
355
+ :param str IncludeContent: "Content", "NoContent"
356
+ :param str IncludeMetadata: "Metadata", "NoMetadata", "MetadataWithEvents"
357
+ :param str IncludedGenerations: "LatestActive", "AllActive", "All"
358
+ :param str IncludeParentHierarchy: "true", "false"
359
+ :return: The path to the opex ZIP file
360
+ :rtype: str
361
+
259
362
  """
260
363
  status = "ACTIVE"
261
364
  pid = self.__export_opex_start__(entity, **kwargs)
@@ -268,75 +371,107 @@ class EntityAPI(AuthenticatedAPI):
268
371
  logger.error(status)
269
372
  raise RuntimeError(f"export progress failed {status}")
270
373
 
271
- def download(self, entity: Entity, filename: str):
374
+ def download(self, entity: Entity, filename: str) -> str:
272
375
  """
273
- Download a file from an asset
274
-
275
- Returns the filename of the new file
376
+ Download the first generation of the access representation of an asset
276
377
 
277
- :param entity: The entity containing the file
278
- :param filename: The filename to write the bytes to
378
+ :param Entity entity: The entity
379
+ :param str filename: The file the image is written to
380
+ :return: The filename
381
+ :rtype: str
279
382
  """
280
383
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
281
384
  params = {'id': f'sdb:{entity.entity_type.value}|{entity.reference}'}
282
- with self.session.get(f'https://{self.server}/api/content/download', params=params, headers=headers,
283
- stream=True) as req:
284
- if req.status_code == requests.codes.ok:
385
+ with self.session.get(f'{self.protocol}://{self.server}/api/content/download', params=params, headers=headers,
386
+ stream=True) as request:
387
+ if request.status_code == requests.codes.ok:
285
388
  with open(filename, 'wb') as file:
286
- for chunk in req.iter_content(chunk_size=CHUNK_SIZE):
389
+ for chunk in request.iter_content(chunk_size=CHUNK_SIZE):
287
390
  file.write(chunk)
288
391
  file.flush()
289
392
  return filename
290
- elif req.status_code == requests.codes.unauthorized:
393
+ elif request.status_code == requests.codes.unauthorized:
291
394
  self.token = self.__token__()
292
395
  return self.download(entity, filename)
293
396
  else:
294
- logger.error(req)
295
- raise RuntimeError(req.status_code, "download failed")
397
+ exception = HTTPException(entity.reference, request.status_code, request.url, "download",
398
+ request.content.decode('utf-8'))
399
+ logger.error(exception)
400
+ raise exception
296
401
 
297
- def thumbnail(self, entity: Entity, filename: str, size=Thumbnail.LARGE):
402
+ def has_thumbnail(self, entity: Entity) -> bool:
298
403
  """
299
- Download the thumbnail of an asset or folder
404
+ Does the entity have a thumbnail image attached
405
+ Returns false if the entity has no thumbnail
300
406
 
301
- Returns the filename of the new thumbnail file
407
+ :param entity: The entity
408
+ """
409
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
410
+ params = {'id': f'sdb:{entity.entity_type.value}|{entity.reference}', 'size': f'{Thumbnail.SMALL.value}'}
411
+ with self.session.get(f'{self.protocol}://{self.server}/api/content/thumbnail', params=params,
412
+ headers=headers) as request:
413
+ if request.status_code == requests.codes.ok:
414
+ return True
415
+ if request.status_code == requests.codes.not_found:
416
+ return False
417
+ elif request.status_code == requests.codes.unauthorized:
418
+ self.token = self.__token__()
419
+ return self.has_thumbnail(entity)
420
+ else:
421
+ exception = HTTPException(entity.reference, request.status_code, request.url, "has_thumbnail",
422
+ request.content.decode('utf-8'))
423
+ logger.error(exception)
424
+ raise exception
302
425
 
303
- :param entity: The entity containing the file
304
- :param filename: The filename to write the bytes to
305
- :param size: The size of the thumbnail
426
+ def thumbnail(self, entity: Entity, filename: str, size=Thumbnail.LARGE):
427
+ """
428
+ Get the thumbnail image for an asset or folder
429
+
430
+ :param Entity entity: The entity
431
+ :param str filename: The file the image is written to
432
+ :param Thumbnail size: The size of the thumbnail image
433
+ :return: The filename
434
+ :rtype: str
306
435
  """
307
436
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
308
437
  params = {'id': f'sdb:{entity.entity_type.value}|{entity.reference}', 'size': f'{size.value}'}
309
- with self.session.get(f'https://{self.server}/api/content/thumbnail', params=params, headers=headers) as req:
310
- if req.status_code == requests.codes.ok:
438
+ with self.session.get(f'{self.protocol}://{self.server}/api/content/thumbnail', params=params,
439
+ headers=headers, stream=True) as request:
440
+ if request.status_code == requests.codes.ok:
311
441
  with open(filename, 'wb') as file:
312
- for chunk in req.iter_content(chunk_size=CHUNK_SIZE):
442
+ for chunk in request.iter_content(chunk_size=CHUNK_SIZE):
313
443
  file.write(chunk)
314
444
  file.flush()
315
445
  return filename
316
- elif req.status_code == requests.codes.unauthorized:
446
+ elif request.status_code == requests.codes.not_found:
447
+ return None
448
+ elif request.status_code == requests.codes.unauthorized:
317
449
  self.token = self.__token__()
318
450
  return self.thumbnail(entity, filename, size=size)
319
451
  else:
320
- logger.error(req)
321
- raise RuntimeError(req.status_code, "thumbnail failed")
452
+ exception = HTTPException(entity.reference, request.status_code, request.url, "thumbnail",
453
+ request.content.decode('utf-8'))
454
+ logger.error(exception)
455
+ raise exception
322
456
 
323
457
  def delete_identifiers(self, entity: Entity, identifier_type: str = None, identifier_value: str = None):
324
458
  """
325
- Delete external identifiers from an entity
459
+ Delete identifiers on an Entity object
326
460
 
327
- Returns the entity
328
-
329
- :param entity: The entity to delete identifiers from
330
- :param identifier_type: The type of the identifier to delete.
331
- :param identifier_value: The value of the identifier to delete.
332
- """
461
+ :param Entity entity: The entity the identifiers are deleted from
462
+ :param str identifier_type: The identifier type
463
+ :param str identifier_value: The identifier value
464
+ :return: entity
465
+ :rtype: Entity
466
+ """
333
467
 
334
468
  if (self.major_version < 7) and (self.minor_version < 1):
335
469
  raise RuntimeError("delete_identifiers API call is not available when connected to a v6.0 System")
336
470
 
337
471
  headers = {HEADER_TOKEN: self.token}
338
- request = self.session.get(f'https://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
339
- headers=headers)
472
+ request = self.session.get(
473
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
474
+ headers=headers)
340
475
  if request.status_code == requests.codes.ok:
341
476
  xml_response = str(request.content.decode('utf-8'))
342
477
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
@@ -354,7 +489,7 @@ class EntityAPI(AuthenticatedAPI):
354
489
  _aipid = identifier.text
355
490
  if _ref == entity.reference and _type == identifier_type and _value == identifier_value:
356
491
  del_req = self.session.delete(
357
- f'https://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers/{_aipid}',
492
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers/{_aipid}',
358
493
  headers=headers)
359
494
  if del_req.status_code == requests.codes.unauthorized:
360
495
  self.token = self.__token__()
@@ -371,17 +506,66 @@ class EntityAPI(AuthenticatedAPI):
371
506
  logger.error(request)
372
507
  raise RuntimeError(request.status_code, "delete_identifier failed")
373
508
 
374
- def identifiers_for_entity(self, entity: Entity):
509
+ def entity_identifiers(self, entity: Entity, external_identifier_type = None) -> set[ExternIdentifier]:
375
510
  """
376
- Get all external identifiers on an entity
511
+ Get all external identifiers on an entity
512
+
513
+ Returns the set of external identifiers on the entity
514
+
515
+ :param entity: The Entity (Asset or Folder)
516
+ :param external_identifier_type: Optional identifier type to filter the results
517
+ :type entity: Entity
518
+ """
519
+ headers = {HEADER_TOKEN: self.token}
520
+ request = self.session.get(
521
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
522
+ headers=headers)
523
+ if request.status_code == requests.codes.ok:
524
+ xml_response = str(request.content.decode('utf-8'))
525
+ logger.debug(xml_response)
526
+ entity_response = xml.etree.ElementTree.fromstring(xml_response)
527
+ identifier_list = entity_response.findall(f'.//{{{self.xip_ns}}}Identifier')
528
+ result = set()
529
+ for identifier in identifier_list:
530
+ identifier_value = identifier_type = identifier_id = ""
531
+ for child in identifier:
532
+ if child.tag.endswith("Type"):
533
+ identifier_type = child.text
534
+ if child.tag.endswith("Value"):
535
+ identifier_value = child.text
536
+ if child.tag.endswith("ApiId"):
537
+ identifier_id = child.text
538
+ if external_identifier_type is None:
539
+ external_id: ExternIdentifier = ExternIdentifier(identifier_type, identifier_value)
540
+ external_id.identifier_id = identifier_id
541
+ result.add(external_id)
542
+ else:
543
+ if identifier_type == external_identifier_type:
544
+ external_id: ExternIdentifier = ExternIdentifier(identifier_type, identifier_value)
545
+ external_id.identifier_id = identifier_id
546
+ result.add(external_id)
547
+ return result
548
+ elif request.status_code == requests.codes.unauthorized:
549
+ self.token = self.__token__()
550
+ return self.entity_identifiers(entity)
551
+ else:
552
+ exception = HTTPException(entity.reference, request.status_code, request.url, "identifiers_for_entity",
553
+ request.content.decode('utf-8'))
554
+ logger.error(exception)
555
+ raise exception
377
556
 
378
- Returns the set of external identifiers on the entity
557
+ def identifiers_for_entity(self, entity: Entity) -> set[Tuple]:
558
+ """
559
+ Return a set of identifiers which belong to the entity
379
560
 
380
- :param entity: The entity
561
+ :param Entity entity: The entity
562
+ :return: Set of identifiers as tuples
563
+ :rtype: set(Tuple)
381
564
  """
382
565
  headers = {HEADER_TOKEN: self.token}
383
- request = self.session.get(f'https://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
384
- headers=headers)
566
+ request = self.session.get(
567
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
568
+ headers=headers)
385
569
  if request.status_code == requests.codes.ok:
386
570
  xml_response = str(request.content.decode('utf-8'))
387
571
  logger.debug(xml_response)
@@ -401,21 +585,23 @@ class EntityAPI(AuthenticatedAPI):
401
585
  self.token = self.__token__()
402
586
  return self.identifiers_for_entity(entity)
403
587
  else:
404
- logger.error(request)
405
- raise RuntimeError(request.status_code, "identifiers_for_entity failed")
588
+ exception = HTTPException(entity.reference, request.status_code, request.url, "identifiers_for_entity",
589
+ request.content.decode('utf-8'))
590
+ logger.error(exception)
591
+ raise exception
406
592
 
407
- def identifier(self, identifier_type: str, identifier_value: str):
593
+ def identifier(self, identifier_type: str, identifier_value: str) -> set[EntityT]:
408
594
  """
409
- Get all entities which have the external identifier
410
-
411
- Returns the set of entities which have the external identifier
595
+ Return a set of entities with external identifiers which match the type and value
412
596
 
413
- :param identifier_type: The identifier type
414
- :param identifier_value: The identifier value
597
+ :param str identifier_type: The identifier type
598
+ :param str identifier_value: The identifier value
599
+ :return: Set of entity objects which have a reference and title attribute
600
+ :rtype: set(Entity)
415
601
  """
416
602
  headers = {HEADER_TOKEN: self.token}
417
603
  payload = {'type': identifier_type, 'value': identifier_value}
418
- request = self.session.get(f'https://{self.server}/api/entity/entities/by-identifier', params=payload,
604
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/entities/by-identifier', params=payload,
419
605
  headers=headers)
420
606
  if request.status_code == requests.codes.ok:
421
607
  xml_response = str(request.content.decode('utf-8'))
@@ -425,33 +611,34 @@ class EntityAPI(AuthenticatedAPI):
425
611
  result = set()
426
612
  for entity in entity_list:
427
613
  if entity.attrib['type'] == EntityType.FOLDER.value:
428
- f = Folder(entity.attrib['ref'], entity.attrib['title'], None, None, None, None)
429
- result.add(f)
614
+ folder = Folder(entity.attrib['ref'], entity.attrib['title'], None, None, None, None)
615
+ result.add(folder)
430
616
  elif entity.attrib['type'] == EntityType.ASSET.value:
431
- a = Asset(entity.attrib['ref'], entity.attrib['title'], None, None, None, None)
432
- result.add(a)
617
+ asset = Asset(entity.attrib['ref'], entity.attrib['title'], None, None, None, None)
618
+ result.add(asset)
433
619
  elif entity.attrib['type'] == EntityType.CONTENT_OBJECT.value:
434
- c = ContentObject(entity.attrib['ref'], entity.attrib['title'], None, None, None, None)
435
- result.add(c)
620
+ co = ContentObject(entity.attrib['ref'], entity.attrib['title'], None, None, None, None)
621
+ result.add(co)
436
622
  return result
437
623
  elif request.status_code == requests.codes.unauthorized:
438
624
  self.token = self.__token__()
439
625
  return self.identifier(identifier_type, identifier_value)
440
626
  else:
441
- logger.error(request)
442
- logger.error(f"identifier failed {request.status_code}")
443
- raise RuntimeError(request.status_code, "identifier failed")
627
+ exception = HTTPException(payload, request.status_code, request.url, "identifier",
628
+ request.content.decode('utf-8'))
629
+ logger.error(exception)
630
+ raise exception
444
631
 
445
632
  def add_identifier(self, entity: Entity, identifier_type: str, identifier_value: str):
446
633
  """
447
- Add a new identifier to an entity
448
-
449
- Returns the internal identifier DB key
634
+ Add a new external identifier to an Entity object
450
635
 
451
- :param entity: The Entity
452
- :param identifier_type: The identifier type
453
- :param identifier_value: The identifier value
454
- """
636
+ :param Entity entity: The entity the identifier is added to
637
+ :param str identifier_type: The identifier type
638
+ :param str identifier_value: The identifier value
639
+ :return: An internal id for this external identifier
640
+ :rtype: str
641
+ """
455
642
 
456
643
  if self.major_version < 7 and self.minor_version < 1:
457
644
  raise RuntimeError("add_identifier API call is not available when connected to a v6.0 System")
@@ -465,7 +652,8 @@ class EntityAPI(AuthenticatedAPI):
465
652
  end_point = f"/{entity.path}/{entity.reference}/identifiers"
466
653
  xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
467
654
  logger.debug(xml_request)
468
- request = self.session.post(f'https://{self.server}/api/entity{end_point}', data=xml_request, headers=headers)
655
+ request = self.session.post(f'{self.protocol}://{self.server}/api/entity{end_point}', data=xml_request,
656
+ headers=headers)
469
657
  if request.status_code == requests.codes.ok:
470
658
  xml_string = str(request.content.decode("utf-8"))
471
659
  identifier_response = xml.etree.ElementTree.fromstring(xml_string)
@@ -478,17 +666,276 @@ class EntityAPI(AuthenticatedAPI):
478
666
  self.token = self.__token__()
479
667
  return self.add_identifier(entity, identifier_type, identifier_value)
480
668
  else:
481
- raise RuntimeError(request.status_code, "add_identifier failed with error code")
669
+ exception = HTTPException(entity.reference, request.status_code, request.url, "add_identifier",
670
+ request.content.decode('utf-8'))
671
+ logger.error(exception)
672
+ raise exception
482
673
 
483
- def delete_metadata(self, entity: Entity, schema: str) -> Entity:
674
+ def update_identifiers(self, entity: Entity, identifier_type: str = None, identifier_value: str = None):
484
675
  """
485
- Deletes all the metadata fragments on an entity which match the schema URI
676
+ Update external identifiers based on Entity and Type
486
677
 
487
- Returns The updated Entity
678
+ Returns the internal identifier DB Key
679
+
680
+ :param entity: The entity to delete identifiers from
681
+ :param identifier_type: The type of the identifier to delete.
682
+ :param identifier_value: The value of the identifier to delete.
683
+ """
488
684
 
489
- :param entity: The Entity to delete metadata from
490
- :param schema: The schema URI to match against
685
+ if (self.major_version < 7) and (self.minor_version < 1):
686
+ raise RuntimeError("update_identifiers API call is not available when connected to a v6.0 System")
687
+
688
+ headers = {HEADER_TOKEN: self.token}
689
+ response = self.session.get(
690
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
691
+ headers=headers)
692
+
693
+ if response.status_code == requests.codes.ok:
694
+ xml_response = str(response.content.decode('utf-8'))
695
+ entity_response = xml.etree.ElementTree.fromstring(xml_response)
696
+ identifier_list = entity_response.findall(f'.//{{{self.xip_ns}}}Identifier')
697
+ for identifier_element in identifier_list:
698
+ _ref = _type = _value = _aipid = None
699
+ for identifier in identifier_element:
700
+ if identifier.tag.endswith("Entity"):
701
+ _ref = identifier.text
702
+ if identifier.tag.endswith("Type") and identifier_type is not None:
703
+ _type = identifier.text
704
+ if identifier.tag.endswith("Value") and identifier_value is not None:
705
+ _value = identifier.text
706
+ if identifier.tag.endswith("ApiId"):
707
+ _aipid = identifier.text
708
+ if _ref == entity.reference and _type == identifier_type:
709
+
710
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
711
+
712
+ xml_object = xml.etree.ElementTree.Element('Identifier', {"xmlns": self.xip_ns})
713
+ xml.etree.ElementTree.SubElement(xml_object, "Type").text = identifier_type
714
+ xml.etree.ElementTree.SubElement(xml_object, "Value").text = identifier_value
715
+ xml.etree.ElementTree.SubElement(xml_object, "Entity").text = entity.reference
716
+ xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
717
+
718
+ put_response = self.session.put(
719
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers/{_aipid}',
720
+ headers=headers, data=xml_request)
721
+ if put_response.status_code == requests.codes.ok:
722
+ xml_string = str(put_response.content.decode("utf-8"))
723
+ identifier_response = xml.etree.ElementTree.fromstring(xml_string)
724
+ aip_id = identifier_response.find(f'.//{{{self.xip_ns}}}ApiId')
725
+ if hasattr(aip_id, 'text'):
726
+ return aip_id.text
727
+ else:
728
+ return None
729
+ if put_response.status_code == requests.codes.unauthorized:
730
+ self.token = self.__token__()
731
+ return self.update_identifiers(entity, identifier_type, identifier_value)
732
+ if put_response.status_code == requests.codes.no_content:
733
+ pass
734
+ else:
735
+ return None
736
+ return entity
737
+ elif response.status_code == requests.codes.unauthorized:
738
+ self.token = self.__token__()
739
+ return self.update_identifiers(entity, identifier_type, identifier_value)
740
+ else:
741
+ logger.error(response)
742
+ raise RuntimeError(response.status_code, "update_identifiers failed")
743
+
744
+ def delete_relationships(self, entity: Entity, relationship_type: str = None):
745
+ """
746
+ Delete a relationship between two entities by its internal id
747
+
748
+ This function only deletes the relationship FROM the specified entity to another entity
749
+ It does not delete relationships TO this entity
750
+
751
+ If relationship_type is not specified all relationships FROM this entity are deleted.
752
+
753
+ :param entity:
754
+ :type entity: Entity
755
+
756
+ :param relationship_type: The relationship type to delete
757
+ :type relationship_type: str
758
+ """
759
+
760
+ if (self.major_version < 7) and (self.minor_version < 4) and (self.patch_version < 1):
761
+ raise RuntimeError("add_relation API call is only available with a Preservica v6.3.1 system or higher")
762
+
763
+ for relationship in self.relationships(entity=entity):
764
+ if relationship.direction == RelationshipDirection.FROM:
765
+ assert relationship.this_ref == entity.reference
766
+ if relationship_type is None:
767
+ self.__delete_relationship(relationship)
768
+ if relationship_type == relationship.relationship_type:
769
+ self.__delete_relationship(relationship)
770
+
771
+ def __delete_relationship(self, relationship: Relationship):
772
+ """
773
+ Delete a relationship between two entities by its internal id
774
+
775
+ :param relationship:
776
+ :return:
491
777
  """
778
+ headers = {HEADER_TOKEN: self.token}
779
+ entity = self.entity(relationship.entity_type, relationship.this_ref)
780
+ end_point = f"{entity.path}/{entity.reference}/links/{relationship.api_id}"
781
+ request = self.session.delete(f'{self.protocol}://{self.server}/api/entity/{end_point}', headers=headers)
782
+ if request.status_code == requests.codes.no_content:
783
+ return None
784
+ elif request.status_code == requests.codes.unauthorized:
785
+ self.token = self.__token__()
786
+ return self.__delete_relationship(relationship)
787
+ else:
788
+ exception = HTTPException(entity.reference, request.status_code, request.url, "delete_relationships",
789
+ request.content.decode('utf-8'))
790
+ logger.error(exception)
791
+ raise exception
792
+
793
+ def relationships(self, entity: Entity, page_size: int = 25) -> Generator[Relationship, None, None]:
794
+ """
795
+ List the relationship links between entities
796
+
797
+
798
+ :param page_size: The number of items returned in a single server call
799
+ :type: page_size: int
800
+
801
+ :param entity: The Source Entity
802
+ :type: entity: An Entity type such as Asset, Folder etc
803
+
804
+ :return: Generator
805
+ :rtype: Relationship
806
+ """
807
+
808
+ paged_set = self.__relationships__(entity, maximum=page_size, next_page=None)
809
+ for e in paged_set.results:
810
+ yield e
811
+ while paged_set.has_more:
812
+ paged_set = self.__relationships__(entity, maximum=page_size, next_page=paged_set.next_page)
813
+ for e in paged_set.results:
814
+ yield e
815
+
816
+ def __relationships__(self, entity: Entity, maximum: int = 50, next_page: str = None) -> PagedSet:
817
+ """
818
+ List the relationship links between entities
819
+
820
+ :param next_page: URL to next page of results
821
+ :type: next_page: str
822
+
823
+ :param maximum: The number of items returned in a single server call
824
+ :type: maximum: int
825
+
826
+ :param entity: The Source Entity
827
+ :type: from_entity: Entity
828
+
829
+ :return: relationship links
830
+ :rtype: list
831
+ """
832
+
833
+ headers = {HEADER_TOKEN: self.token}
834
+ end_point = f"{entity.path}/{entity.reference}/links"
835
+
836
+ if next_page is None:
837
+ params = {'start': '0', 'max': str(maximum)}
838
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{end_point}', headers=headers,
839
+ params=params)
840
+ else:
841
+ request = self.session.get(next_page, headers=headers)
842
+
843
+ if request.status_code == requests.codes.ok:
844
+ xml_response = str(request.content.decode('utf-8'))
845
+ logger.debug(xml_response)
846
+ entity_response = xml.etree.ElementTree.fromstring(xml_response)
847
+ links = entity_response.findall(f'.//{{{self.entity_ns}}}Link')
848
+ next_url = entity_response.find(f'.//{{{self.entity_ns}}}Paging/{{{self.entity_ns}}}Next')
849
+ total_hits = entity_response.find(f'.//{{{self.entity_ns}}}Paging/{{{self.entity_ns}}}TotalResults')
850
+ results = []
851
+ for link in links:
852
+ link_type = link.attrib['linkType']
853
+ link_direction = link.attrib['linkDirection']
854
+ title = link.attrib['title']
855
+ other_ref = link.attrib['ref']
856
+ this_ref = entity.reference
857
+ entity_type = link.attrib['type']
858
+ api_id = link.attrib['apiId']
859
+ results.append(Relationship(api_id, link_type, RelationshipDirection(link_direction), other_ref, title,
860
+ EntityType(entity_type), this_ref, api_id))
861
+ has_more = True
862
+ url = None
863
+ if next_url is None:
864
+ has_more = False
865
+ else:
866
+ url = next_url.text
867
+
868
+ return PagedSet(results, has_more, int(total_hits.text), url)
869
+ elif request.status_code == requests.codes.unauthorized:
870
+ return self.__relationships__(entity=entity, maximum=maximum, next_page=next_page)
871
+ else:
872
+ exception = HTTPException(entity.reference, request.status_code, request.url, "relationships",
873
+ request.content.decode('utf-8'))
874
+ logger.error(exception)
875
+ raise exception
876
+
877
+ def add_relation(self, from_entity: Entity, relationship_type: str, to_entity: Entity):
878
+ """
879
+ Add a new relationship link between two Assets or Folders
880
+
881
+ :param from_entity: The Source entity to link from
882
+ :type from_entity: Entity
883
+
884
+ :param to_entity: The Target entity
885
+ :type to_entity: Entity
886
+
887
+ :param relationship_type: The Relationship type
888
+ :type relationship_type: str
889
+
890
+ :return: relationship_type
891
+ :rtype: str
892
+ """
893
+
894
+ if (self.major_version < 7) and (self.minor_version < 4) and (self.patch_version < 1):
895
+ raise RuntimeError("add_relation API call is only available with a Preservica v6.3.1 system or higher")
896
+
897
+ assert from_entity.entity_type is not EntityType.CONTENT_OBJECT
898
+ assert to_entity.entity_type is not EntityType.CONTENT_OBJECT
899
+
900
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
901
+
902
+ xml_object = xml.etree.ElementTree.Element('Link ', {"xmlns": self.xip_ns})
903
+ xml.etree.ElementTree.SubElement(xml_object, "Type").text = relationship_type
904
+ xml.etree.ElementTree.SubElement(xml_object, "FromEntity").text = from_entity.reference
905
+ xml.etree.ElementTree.SubElement(xml_object, "ToEntity").text = to_entity.reference
906
+
907
+ end_point = f"/{from_entity.path}/{from_entity.reference}/links"
908
+ xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
909
+ logger.debug(xml_request)
910
+ request = self.session.post(f'{self.protocol}://{self.server}/api/entity{end_point}', data=xml_request,
911
+ headers=headers)
912
+ if request.status_code == requests.codes.ok:
913
+ xml_string = str(request.content.decode("utf-8"))
914
+ logger.debug(xml_string)
915
+ link_response = xml.etree.ElementTree.fromstring(xml_string)
916
+ relation = link_response.find(f'.//{{{self.xip_ns}}}Link')
917
+ relation_type = relation.find(f'.//{{{self.xip_ns}}}Type')
918
+ return relation_type.text
919
+ elif request.status_code == requests.codes.unauthorized:
920
+ self.token = self.__token__()
921
+ return self.add_relation(from_entity, relationship_type, to_entity)
922
+ else:
923
+ exception = HTTPException(from_entity.reference, request.status_code, request.url, "add_relation",
924
+ request.content.decode('utf-8'))
925
+ logger.error(exception)
926
+ raise exception
927
+
928
+ def delete_metadata(self, entity: EntityT, schema: str) -> EntityT:
929
+ """
930
+ Delete an existing descriptive XML document on an entity by its schema
931
+ This call will delete all fragments with the same schema
932
+
933
+ :param Entity entity: The entity to add the metadata to
934
+ :param str schema: The metadata schema URI
935
+ :return: The updated Entity
936
+ :rtype: Entity
937
+ """
938
+
492
939
  headers = {HEADER_TOKEN: self.token}
493
940
  for url in entity.metadata:
494
941
  if schema == entity.metadata[url]:
@@ -499,18 +946,52 @@ class EntityAPI(AuthenticatedAPI):
499
946
  self.token = self.__token__()
500
947
  return self.delete_metadata(entity, schema)
501
948
  else:
502
- raise RuntimeError(request.status_code, "delete_metadata failed with error code")
949
+ exception = HTTPException(entity.reference, request.status_code, request.url, "delete_metadata",
950
+ request.content.decode('utf-8'))
951
+ logger.error(exception)
952
+ raise exception
953
+
503
954
  return self.entity(entity.entity_type, entity.reference)
504
955
 
505
- def update_metadata(self, entity: Entity, schema: str, data: Any) -> Entity:
956
+
957
+ def add_group_metadata(self, csv_file: str) -> str:
506
958
  """
507
- Update all existing metadata fragments which match the schema
959
+ Perform bulk additions of metadata with a CSV file.
960
+ This is designed for metadata which populates a New Gen Metadata Group
961
+ Returns The process ID which will track the updates
962
+ Requires DataManagement permission
963
+
964
+ :param str csv_file: The path of the CSV metadata file
965
+ :return: The process ID
966
+ :rtype: str
967
+ """
968
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/csv;charset=UTF-8'}
969
+
970
+ url = f'{self.protocol}://{self.server}/api/entity/actions/metadata-csv-edits'
971
+
972
+ with open(csv_file, 'rb') as fd:
973
+ with self.session.post(url, headers=headers, data=fd) as request:
974
+ if request.status_code == requests.codes.unauthorized:
975
+ self.token = self.__token__()
976
+ return self.add_group_metadata(csv_file)
977
+ elif request.status_code == requests.codes.accepted:
978
+ return str(request.content.decode('utf-8'))
979
+ else:
980
+ exception = HTTPException(None, request.status_code, request.url, "add_group_metadata",
981
+ request.content.decode('utf-8'))
982
+ logger.error(exception)
983
+ raise exception
508
984
 
509
- Returns The updated Entity
510
985
 
511
- :param data: The updated XML as a string or as IO bytes
512
- :param entity: The Entity to update
513
- :param schema: The schema URI to match against
986
+ def update_metadata(self, entity: EntityT, schema: str, data: Any) -> EntityT:
987
+ """
988
+ Update an existing descriptive XML document on an entity
989
+
990
+ :param Entity entity: The entity to add the metadata to
991
+ :param str schema: The metadata schema URI
992
+ :param data data: The XML document as a string or as a file bytes
993
+ :return: The updated Entity
994
+ :rtype: Entity
514
995
  """
515
996
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
516
997
 
@@ -520,11 +1001,11 @@ class EntityAPI(AuthenticatedAPI):
520
1001
  for url in entity.metadata:
521
1002
  if schema == entity.metadata[url]:
522
1003
  mref = url[url.rfind(f"{entity.reference}/metadata/") + len(f"{entity.reference}/metadata/"):]
523
- xml_object = xml.etree.ElementTree.Element('MetadataContainer',
524
- {"schemaUri": schema, "xmlns": self.xip_ns})
525
- xml.etree.ElementTree.SubElement(xml_object, "Ref").text = mref
526
- xml.etree.ElementTree.SubElement(xml_object, "Entity").text = entity.reference
527
- content = xml.etree.ElementTree.SubElement(xml_object, "Content")
1004
+ xml_object = xml.etree.ElementTree.Element('xip:MetadataContainer',
1005
+ {"schemaUri": schema, "xmlns:xip": self.xip_ns})
1006
+ xml.etree.ElementTree.SubElement(xml_object, "xip:Ref").text = mref
1007
+ xml.etree.ElementTree.SubElement(xml_object, "xip:Entity").text = entity.reference
1008
+ content = xml.etree.ElementTree.SubElement(xml_object, "xip:Content")
528
1009
  if isinstance(data, str):
529
1010
  ob = xml.etree.ElementTree.fromstring(data)
530
1011
  content.append(ob)
@@ -533,7 +1014,7 @@ class EntityAPI(AuthenticatedAPI):
533
1014
  content.append(tree.getroot())
534
1015
  else:
535
1016
  raise RuntimeError("Unknown data type")
536
- xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
1017
+ xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8').decode("utf-8")
537
1018
  logger.debug(xml_request)
538
1019
  request = self.session.put(url, data=xml_request, headers=headers)
539
1020
  if request.status_code == requests.codes.ok:
@@ -542,24 +1023,64 @@ class EntityAPI(AuthenticatedAPI):
542
1023
  self.token = self.__token__()
543
1024
  return self.update_metadata(entity, schema, data)
544
1025
  else:
545
- raise RuntimeError(request.status_code, "update_metadata failed with error code")
1026
+ exception = HTTPException(entity.reference, request.status_code, request.url, "update_metadata",
1027
+ request.content.decode('utf-8'))
1028
+ logger.error(exception)
1029
+ raise exception
546
1030
  return self.entity(entity.entity_type, entity.reference)
547
1031
 
548
- def add_metadata(self, entity: Entity, schema: str, data) -> Entity:
1032
+ def add_metadata_as_fragment(self, entity: EntityT, schema: str, xml_fragment: str) -> EntityT:
549
1033
  """
550
- Add a metadata fragment with a given namespace URI
1034
+ Add a metadata fragment with a given namespace URI to an Entity
1035
+ Don't parse the xml fragment which may add extra namespaces etc
551
1036
 
552
1037
  Returns The updated Entity
553
1038
 
554
- :param data: The new XML as a string or as IO bytes
555
- :param entity: The Entity to update
556
- :param schema: The schema URI of the XML document
1039
+ :param str xml_fragment: The new XML as a string
1040
+ :param Entity entity: The entity to update
1041
+ :param str schema: The schema URI of the XML document
1042
+ :rtype: Entity
557
1043
  """
558
1044
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
559
1045
 
560
- xml_object = xml.etree.ElementTree.Element('MetadataContainer', {"schemaUri": schema, "xmlns": self.xip_ns})
561
- xml.etree.ElementTree.SubElement(xml_object, "Entity").text = entity.reference
562
- content = xml.etree.ElementTree.SubElement(xml_object, "Content")
1046
+ xml_doc = f"""<xip:MetadataContainer xmlns="{schema}" schemaUri="{schema}" xmlns:xip="{self.xip_ns}">
1047
+ <xip:Entity>{entity.reference}</xip:Entity>
1048
+ <xip:Content>
1049
+ {xml_fragment}
1050
+ </xip:Content>
1051
+ </xip:MetadataContainer>"""
1052
+
1053
+ end_point = f"/{entity.path}/{entity.reference}/metadata"
1054
+ logger.debug(xml_doc)
1055
+ request = self.session.post(f'{self.protocol}://{self.server}/api/entity{end_point}', data=xml_doc,
1056
+ headers=headers)
1057
+ if request.status_code == requests.codes.ok:
1058
+ return self.entity(entity_type=entity.entity_type, reference=entity.reference)
1059
+ elif request.status_code == requests.codes.unauthorized:
1060
+ self.token = self.__token__()
1061
+ return self.add_metadata(entity, schema, xml_fragment)
1062
+ else:
1063
+ exception = HTTPException(entity.reference, request.status_code, request.url, "add_metadata",
1064
+ request.content.decode('utf-8'))
1065
+ logger.error(exception)
1066
+ raise exception
1067
+
1068
+ def add_metadata(self, entity: EntityT, schema: str, data) -> EntityT:
1069
+ """
1070
+ Add a new descriptive XML document to an existing entity
1071
+
1072
+ :param Entity entity: The entity to add the metadata to
1073
+ :param str schema: The metadata schema URI
1074
+ :param data data: The XML document as a string or as file bytes
1075
+ :return: The updated entity with the new metadata
1076
+ :rtype: Entity
1077
+ """
1078
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
1079
+
1080
+ xml_object = xml.etree.ElementTree.Element('xip:MetadataContainer', {"schemaUri": schema,
1081
+ "xmlns:xip": self.xip_ns})
1082
+ xml.etree.ElementTree.SubElement(xml_object, "xip:Entity").text = entity.reference
1083
+ content = xml.etree.ElementTree.SubElement(xml_object, "xip:Content")
563
1084
  if isinstance(data, str):
564
1085
  ob = xml.etree.ElementTree.fromstring(data)
565
1086
  content.append(ob)
@@ -571,23 +1092,30 @@ class EntityAPI(AuthenticatedAPI):
571
1092
  xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
572
1093
  end_point = f"/{entity.path}/{entity.reference}/metadata"
573
1094
  logger.debug(xml_request)
574
- request = self.session.post(f'https://{self.server}/api/entity{end_point}', data=xml_request, headers=headers)
1095
+ request = self.session.post(f'{self.protocol}://{self.server}/api/entity{end_point}', data=xml_request,
1096
+ headers=headers)
575
1097
  if request.status_code == requests.codes.ok:
576
1098
  return self.entity(entity_type=entity.entity_type, reference=entity.reference)
577
1099
  elif request.status_code == requests.codes.unauthorized:
578
1100
  self.token = self.__token__()
579
1101
  return self.add_metadata(entity, schema, data)
580
1102
  else:
581
- raise RuntimeError(request.status_code, "add_metadata failed with error code")
1103
+ exception = HTTPException(entity.reference, request.status_code, request.url, "add_metadata",
1104
+ request.content.decode('utf-8'))
1105
+ logger.error(exception)
1106
+ raise exception
582
1107
 
583
- def save(self, entity: Entity) -> Entity:
1108
+ def save(self, entity: EntityT) -> EntityT:
584
1109
  """
585
- Save the title and description of an entity
1110
+ Updates the title and description of an entity
1111
+ The security tag and parent are not saved via this method call
586
1112
 
587
- Returns The updated Entity
588
-
589
- :param entity: The Entity to update
1113
+ :param entity: The entity (asset, folder, content_object) to be updated
1114
+ :type entity: Entity
1115
+ :return: The updated entity
1116
+ :rtype: Entity
590
1117
  """
1118
+
591
1119
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
592
1120
 
593
1121
  xml_object = xml.etree.ElementTree.Element(entity.tag, {"xmlns": self.xip_ns})
@@ -595,34 +1123,51 @@ class EntityAPI(AuthenticatedAPI):
595
1123
  xml.etree.ElementTree.SubElement(xml_object, "Title").text = entity.title
596
1124
  xml.etree.ElementTree.SubElement(xml_object, "Description").text = entity.description
597
1125
  xml.etree.ElementTree.SubElement(xml_object, "SecurityTag").text = entity.security_tag
1126
+
1127
+ if entity.custom_type is not None:
1128
+ xml.etree.ElementTree.SubElement(xml_object, "CustomType").text = entity.custom_type
1129
+
598
1130
  if entity.parent is not None:
599
1131
  xml.etree.ElementTree.SubElement(xml_object, "Parent").text = entity.parent
600
1132
 
601
1133
  xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
602
1134
  logger.debug(xml_request)
603
- request = self.session.put(f'https://{self.server}/api/entity/{entity.path}/{entity.reference}',
1135
+ request = self.session.put(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}',
604
1136
  data=xml_request, headers=headers)
605
1137
  if request.status_code == requests.codes.ok:
606
1138
  xml_response = str(request.content.decode('utf-8'))
607
1139
  response = self.entity_from_string(xml_response)
608
1140
  if isinstance(entity, Asset):
609
- return Asset(response['reference'], response['title'], response['description'],
610
- response['security_tag'],
611
- response['parent'], response['metadata'])
612
- elif isinstance(entity, Folder):
613
- return Folder(response['reference'], response['title'], response['description'],
1141
+ asset = Asset(response['reference'], response['title'], response['description'],
614
1142
  response['security_tag'],
615
1143
  response['parent'], response['metadata'])
1144
+ if 'CustomType' in response:
1145
+ asset.custom_type = response['CustomType']
1146
+ return asset
1147
+ elif isinstance(entity, Folder):
1148
+ folder = Folder(response['reference'], response['title'], response['description'],
1149
+ response['security_tag'],
1150
+ response['parent'], response['metadata'])
1151
+ if 'CustomType' in response:
1152
+ folder.custom_type = response['CustomType']
1153
+ return folder
616
1154
  elif isinstance(entity, ContentObject):
617
- return ContentObject(response['reference'], response['title'],
618
- response['description'],
619
- response['security_tag'],
620
- response['parent'], response['metadata'])
1155
+ content_object = ContentObject(response['reference'], response['title'],
1156
+ response['description'],
1157
+ response['security_tag'],
1158
+ response['parent'], response['metadata'])
1159
+ if 'CustomType' in response:
1160
+ content_object.custom_type = response['CustomType']
1161
+ return content_object
1162
+ return None
621
1163
  elif request.status_code == requests.codes.unauthorized:
622
1164
  self.token = self.__token__()
623
1165
  return self.save(entity)
624
1166
  else:
625
- raise RuntimeError(request.status_code, "save failed for entity: " + entity.reference)
1167
+ exception = HTTPException(entity.reference, request.status_code, request.url, "save",
1168
+ request.content.decode('utf-8'))
1169
+ logger.error(exception)
1170
+ raise exception
626
1171
 
627
1172
  def move_async(self, entity: Entity, dest_folder: Folder) -> str:
628
1173
  """
@@ -634,8 +1179,10 @@ class EntityAPI(AuthenticatedAPI):
634
1179
 
635
1180
  Returns The updated Entity
636
1181
 
637
- :param entity: The Entity to update
638
- :param dest_folder: The Folder which will become the new parent of this entity
1182
+ :param Entity entity: The entity to move either asset or folder
1183
+ :param Entity dest_folder: The new destination folder. This can be None to move a folder to the root of the repository
1184
+ :return: Progress ID token
1185
+ :rtype: str
639
1186
  """
640
1187
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
641
1188
  if isinstance(entity, Asset) and dest_folder is None:
@@ -644,19 +1191,34 @@ class EntityAPI(AuthenticatedAPI):
644
1191
  data = dest_folder.reference
645
1192
  else:
646
1193
  data = "@root@"
647
- request = self.session.put(f'https://{self.server}/api/entity/{entity.path}/{entity.reference}/parent-ref',
648
- data=data, headers=headers)
1194
+ request = self.session.put(
1195
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/parent-ref',
1196
+ data=data, headers=headers)
649
1197
  if request.status_code == requests.codes.accepted:
650
1198
  return request.content.decode()
651
1199
  elif request.status_code == requests.codes.unauthorized:
652
1200
  self.token = self.__token__()
653
1201
  return self.move_async(entity, dest_folder)
654
1202
  else:
655
- raise RuntimeError(request.status_code, "move failed for entity: " + entity.reference)
1203
+ exception = HTTPException(entity.reference, request.status_code, request.url, "move_async",
1204
+ request.content.decode('utf-8'))
1205
+ logger.error(exception)
1206
+ raise exception
1207
+
1208
+ def get_progress(self, pid: str) -> AsyncProgress:
1209
+ return AsyncProgress[self.get_async_progress(pid)]
656
1210
 
657
1211
  def get_async_progress(self, pid: str) -> str:
1212
+ """
1213
+ Return the status of a running process
1214
+
1215
+
1216
+ :param pid: The progress ID
1217
+ :return: Workflow status
1218
+ :rtype: str
1219
+ """
658
1220
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
659
- request = self.session.get(f"https://{self.server}/api/entity/progress/{pid}", headers=headers)
1221
+ request = self.session.get(f"{self.protocol}://{self.server}/api/entity/progress/{pid}", headers=headers)
660
1222
  if request.status_code == requests.codes.ok:
661
1223
  entity_response = xml.etree.ElementTree.fromstring(request.content.decode("utf-8"))
662
1224
  status = entity_response.find(".//{http://status.preservica.com}Status")
@@ -668,18 +1230,20 @@ class EntityAPI(AuthenticatedAPI):
668
1230
  self.token = self.__token__()
669
1231
  return self.get_async_progress(pid)
670
1232
  else:
671
- raise RuntimeError(request.status_code, "get_async_progress" + pid)
1233
+ exception = HTTPException(pid, request.status_code, request.url, "get_async_progress",
1234
+ request.content.decode('utf-8'))
1235
+ logger.error(exception)
1236
+ raise exception
672
1237
 
673
- def move_sync(self, entity: Entity, dest_folder: Folder) -> Entity:
1238
+ def move_sync(self, entity: EntityT, dest_folder: Folder) -> EntityT:
674
1239
  """
675
- Move an Entity (Asset or Folder) to a new Folder
676
- If dest_folder is None then the entity must be a Folder and will be moved to the root of the repository
1240
+ Move an entity (asset or folder) to a new folder
1241
+ This call blocks until the move is complete
677
1242
 
678
- Returns The updated Entity.
679
- Blocks until the move is complete.
680
-
681
- :param entity: The Entity to update
682
- :param dest_folder: The Folder which will become the new parent of this entity
1243
+ :param Entity entity: The entity to move either asset or folder
1244
+ :param Entity dest_folder: The new destination folder. This can be None to move a folder to the root of the repository
1245
+ :return: The updated entity
1246
+ :rtype: Entity
683
1247
  """
684
1248
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
685
1249
  if isinstance(entity, Asset) and dest_folder is None:
@@ -688,8 +1252,9 @@ class EntityAPI(AuthenticatedAPI):
688
1252
  data = dest_folder.reference
689
1253
  else:
690
1254
  data = "@root@"
691
- request = self.session.put(f'https://{self.server}/api/entity/{entity.path}/{entity.reference}/parent-ref',
692
- data=data, headers=headers)
1255
+ request = self.session.put(
1256
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/parent-ref',
1257
+ data=data, headers=headers)
693
1258
  if request.status_code == requests.codes.accepted:
694
1259
  sleep_sec = 1
695
1260
  while True:
@@ -704,17 +1269,20 @@ class EntityAPI(AuthenticatedAPI):
704
1269
  self.token = self.__token__()
705
1270
  return self.move_sync(entity, dest_folder)
706
1271
  else:
707
- raise RuntimeError(request.status_code, "move failed for entity: " + entity.reference)
1272
+ exception = HTTPException(entity, request.status_code, request.url, "move_sync",
1273
+ request.content.decode('utf-8'))
1274
+ logger.error(exception)
1275
+ raise exception
708
1276
 
709
- def move(self, entity: Entity, dest_folder: Folder) -> Entity:
1277
+ def move(self, entity: EntityT, dest_folder: Folder) -> EntityT:
710
1278
  """
711
- Move an Entity (Asset or Folder) to a new Folder
712
- If dest_folder is None then the entity must be a Folder and will be moved to the root of the repository
1279
+ Move an entity (asset or folder) to a new folder
1280
+ This call is an alias for the move_sync (blocking) method.
713
1281
 
714
- Returns The updated Entity
715
-
716
- :param entity: The Entity to update
717
- :param dest_folder: The Folder which will become the new parent of this entity
1282
+ :param Entity entity: The entity to move either asset or folder
1283
+ :param Entity dest_folder: The new destination folder. This can be None to move a folder to the root of the repository
1284
+ :return: The updated entity
1285
+ :rtype: Entity
718
1286
  """
719
1287
  return self.move_sync(entity, dest_folder)
720
1288
 
@@ -742,7 +1310,7 @@ class EntityAPI(AuthenticatedAPI):
742
1310
 
743
1311
  xml_request = xml.etree.ElementTree.tostring(structural_object, encoding='utf-8')
744
1312
  logger.debug(xml_request)
745
- request = self.session.post(f'https://{self.server}/api/entity/structural-objects', data=xml_request,
1313
+ request = self.session.post(f'{self.protocol}://{self.server}/api/entity/structural-objects', data=xml_request,
746
1314
  headers=headers)
747
1315
  if request.status_code == requests.codes.ok:
748
1316
  xml_response = str(request.content.decode('utf-8'))
@@ -755,35 +1323,81 @@ class EntityAPI(AuthenticatedAPI):
755
1323
  self.token = self.__token__()
756
1324
  return self.create_folder(title, description, security_tag, parent=parent)
757
1325
  else:
758
- raise RuntimeError(request.status_code, "create_folder failed")
1326
+ exception = HTTPException(title, request.status_code, request.url, "create_folder",
1327
+ request.content.decode('utf-8'))
1328
+ logger.error(exception)
1329
+ raise exception
1330
+
1331
+ def all_metadata(self, entity: Entity) -> Generator[Tuple[str, str], None, None]:
1332
+ """
1333
+ Retrieve all metadata fragments on an entity
1334
+
1335
+ Returns XML documents in a tuple
1336
+
1337
+ :param Entity entity: The entity with the metadata
1338
+ :return: A list of Tuples, the first value is the schmea and the second is the metadata
1339
+ :rtype: Generator[Tuple[str, str]]
1340
+ """
1341
+
1342
+ for uri, schema in entity.metadata.items():
1343
+ yield tuple((str(schema), self.metadata(uri)))
759
1344
 
760
- def metadata_for_entity(self, entity: Entity, schema: str) -> Optional[str]:
1345
+ def metadata_for_entity(self, entity: Entity, schema: str) -> Union[str, None]:
761
1346
  """
762
- Retrieve the first metadata fragment on an entity with a matching schema URI
1347
+ Fetch the first metadata document which matches the schema URI from an entity
1348
+
1349
+ :param Entity entity: The entity containing the metadata
1350
+ :param str schema: The metadata schema URI
1351
+ :return: The first XML document on the entity matching the schema URI
1352
+ :rtype: str
1353
+ """
1354
+
1355
+ # if the entity is a lightweight enum version request the full object
1356
+ if entity.metadata is None:
1357
+ entity = self.entity(entity.entity_type, entity.reference)
1358
+
1359
+ for uri, schema_name in entity.metadata.items():
1360
+ if schema == schema_name:
1361
+ return self.metadata(uri)
1362
+ return None
1363
+
1364
+ def metadata_tag_for_entity(self, entity: Entity, schema: str, tag: str, isXpath: bool = False) -> Union[str, None]:
1365
+ """
1366
+ Retrieve the first value of the tag from a metadata template given by schema
763
1367
 
764
1368
  Returns XML document as a string
765
1369
 
1370
+ :param isXpath: True if the tag name is a fully qualified xpath expression
766
1371
  :param entity: The entity with the metadata
767
1372
  :param schema: The schema URI
1373
+ :param tag: The tag name
768
1374
  """
769
- for u, s in entity.metadata.items():
770
- if schema == s:
771
- return self.metadata(u)
1375
+
1376
+ xml_doc = self.metadata_for_entity(entity, schema)
1377
+ if xml_doc:
1378
+ xml_object = xml.etree.ElementTree.fromstring(xml_doc)
1379
+ if not isXpath:
1380
+ return xml_object.find(f'.//{{*}}{tag}').text
1381
+ else:
1382
+ return xml_object.find(tag).text
772
1383
  return None
773
1384
 
774
- def security_tag_sync(self, entity: Entity, new_tag: str):
1385
+ def security_tag_sync(self, entity: EntityT, new_tag: str) -> EntityT:
775
1386
  """
776
- Change the security tag for a folder or asset
777
-
778
- Returns the updated entity after the security tag has been updated.
779
-
780
- :param entity: The entity to change
781
- :param new_tag: The new security tag
1387
+ Change the security tag of an asset or folder
1388
+ This is a blocking call which returns after all entities have been updated.
1389
+
1390
+ :param entity: The entity (asset, folder) to be updated
1391
+ :type entity: Entity
1392
+ :param new_tag: The new security tag to be set on the entity
1393
+ :type new_tag: str
1394
+ :return: The updated entity
1395
+ :rtype: Entity
782
1396
  """
783
1397
  self.token = self.__token__()
784
1398
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
785
1399
  end_point = f"/{entity.path}/{entity.reference}/security-descriptor"
786
- request = self.session.put(f'https://{self.server}/api/entity{end_point}?includeDescendants=false',
1400
+ request = self.session.put(f'{self.protocol}://{self.server}/api/entity{end_point}?includeDescendants=false',
787
1401
  data=new_tag, headers=headers)
788
1402
  if request.status_code == requests.codes.accepted:
789
1403
  sleep_sec = 1
@@ -798,158 +1412,366 @@ class EntityAPI(AuthenticatedAPI):
798
1412
  self.token = self.__token__()
799
1413
  return self.security_tag_sync(entity, new_tag)
800
1414
  else:
801
- raise RuntimeError(request.status_code, f"security_tag_sync change failed on {entity.reference}")
1415
+ exception = HTTPException(entity.reference, request.status_code, request.url, "security_tag_sync",
1416
+ request.content.decode('utf-8'))
1417
+ logger.error(exception)
1418
+ raise exception
802
1419
 
803
1420
  def security_tag_async(self, entity: Entity, new_tag: str):
804
1421
  """
805
- Change the security tag for a folder or asset
806
-
807
- Returns a process ID asynchronous (without blocking)
808
-
809
- :param entity: The entity to change
810
- :param new_tag: The new security tag
1422
+ Change the security tag of an asset or folder
1423
+ This is a non blocking call which returns immediately.
1424
+
1425
+ :param entity: The entity (asset, folder) to be updated
1426
+ :type entity: Entity
1427
+ :param new_tag: The new security tag to be set on the entity
1428
+ :type new_tag: str
1429
+ :return: A progress id which can be used to monitor the workflow
1430
+ :rtype: str
811
1431
  """
812
1432
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
813
1433
  end_point = f"/{entity.path}/{entity.reference}/security-descriptor"
814
- request = self.session.put(f'https://{self.server}/api/entity{end_point}?includeDescendants=false',
1434
+ request = self.session.put(f'{self.protocol}://{self.server}/api/entity{end_point}?includeDescendants=false',
815
1435
  data=new_tag, headers=headers)
816
1436
  if request.status_code == requests.codes.accepted:
817
- return request.content.decode("utf-8")
1437
+ return request.content.decode("utf-8")
1438
+ elif request.status_code == requests.codes.unauthorized:
1439
+ self.token = self.__token__()
1440
+ return self.security_tag_async(entity, new_tag)
1441
+ else:
1442
+ exception = HTTPException(entity.reference, request.status_code, request.url, "security_tag_async",
1443
+ request.content.decode('utf-8'))
1444
+ logger.error(exception)
1445
+ raise exception
1446
+
1447
+ def metadata(self, uri: str) -> str:
1448
+ """
1449
+ Fetch the metadata document by its identifier, this is the key from the entity metadata map
1450
+
1451
+ :param str uri: The metadata identifier
1452
+ :return: An XML document as a string
1453
+ :rtype: str
1454
+ """
1455
+ request = self.session.get(uri, headers={HEADER_TOKEN: self.token})
1456
+ if request.status_code == requests.codes.ok:
1457
+ xml_response = str(request.content.decode('utf-8'))
1458
+ logger.debug(xml_response)
1459
+ entity_response = xml.etree.ElementTree.fromstring(xml_response)
1460
+ content = entity_response.find(f'.//{{{self.xip_ns}}}Content')
1461
+ return xml.etree.ElementTree.tostring(content[0], encoding='utf-8', method='xml').decode('utf-8')
1462
+ elif request.status_code == requests.codes.unauthorized:
1463
+ self.token = self.__token__()
1464
+ return self.metadata(uri)
1465
+ else:
1466
+ exception = HTTPException(uri, request.status_code, request.url, "metadata",
1467
+ request.content.decode('utf-8'))
1468
+ logger.error(exception)
1469
+ raise exception
1470
+
1471
+ def entity(self, entity_type: EntityType, reference: str) -> EntityT:
1472
+ """
1473
+ Returns a generic entity based on its reference identifier
1474
+
1475
+ :param entity_type: The type of entity
1476
+ :type entity_type: EntityType
1477
+ :param reference: The unique identifier for the entity
1478
+ :type reference: str
1479
+ :return: The entity either Asset, Folder or ContentObject
1480
+ :rtype: Entity
1481
+ :raises RuntimeError: if the identifier is incorrect
1482
+ """
1483
+ if entity_type is EntityType.CONTENT_OBJECT:
1484
+ return self.content_object(reference)
1485
+ if entity_type is EntityType.FOLDER:
1486
+ return self.folder(reference)
1487
+ if entity_type is EntityType.ASSET:
1488
+ return self.asset(reference)
1489
+ return None
1490
+
1491
+ def add_physical_asset(self, title: str, description: str, parent: Folder, security_tag: str = "open") -> Asset:
1492
+ """
1493
+ Create a new asset which represents a physical object
1494
+
1495
+ Returns Asset
1496
+
1497
+ :param str title: The title of the new Asset
1498
+ :param str description: The description of the new Asset
1499
+ :param Folder parent: The parent folder
1500
+ :param str security_tag: The security tag, defaults to open
1501
+ :return: The new physical object
1502
+ :rtype: Asset
1503
+ """
1504
+
1505
+ if (self.major_version < 7) and (self.minor_version < 4):
1506
+ raise RuntimeError(
1507
+ "add_physical_asset API call is only available with a Preservica v6.4.0 system or higher")
1508
+
1509
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
1510
+
1511
+ xip_object = xml.etree.ElementTree.Element('XIP ', {"xmlns": self.xip_ns})
1512
+ io_object = xml.etree.ElementTree.SubElement(xip_object, "InformationObject")
1513
+ xml.etree.ElementTree.SubElement(io_object, "Title").text = str(title)
1514
+ xml.etree.ElementTree.SubElement(io_object, "Description").text = str(description)
1515
+ xml.etree.ElementTree.SubElement(io_object, "SecurityTag").text = str(security_tag)
1516
+ xml.etree.ElementTree.SubElement(io_object, "Parent").text = parent.reference
1517
+ rep_object = xml.etree.ElementTree.SubElement(xip_object, "Representation")
1518
+ xml.etree.ElementTree.SubElement(rep_object, "Type").text = "Physical"
1519
+
1520
+ xml_request = xml.etree.ElementTree.tostring(xip_object, encoding='utf-8')
1521
+
1522
+ request = self.session.post(f'{self.protocol}://{self.server}/api/entity/{IO_PATH}', data=xml_request,
1523
+ headers=headers)
1524
+ if request.status_code == requests.codes.ok:
1525
+ xml_string = str(request.content.decode("utf-8"))
1526
+ entity = self.entity_from_string(xml_string)
1527
+ return Asset(entity['reference'], entity['title'], entity['description'],
1528
+ entity['security_tag'], entity['parent'],
1529
+ entity['metadata'])
1530
+ elif request.status_code == requests.codes.unauthorized:
1531
+ self.token = self.__token__()
1532
+ return self.add_physical_asset(title, description, parent, security_tag)
1533
+ else:
1534
+ exception = HTTPException(title, request.status_code, request.url, "add_physical_asset",
1535
+ request.content.decode('utf-8'))
1536
+ logger.error(exception)
1537
+ raise exception
1538
+
1539
+ def merge_assets(self, assets: list[Asset], title: str, description: str) -> str:
1540
+ """
1541
+ Create a new Asset with the content from each Asset in supplied list
1542
+ This call will create a new multipart Asset which contains all the content from list of Assets.
1543
+
1544
+ The return value is the progress status of the merge operation.
1545
+ """
1546
+
1547
+ headers = {
1548
+ HEADER_TOKEN: self.token,
1549
+ "Content-Type": "application/xml;charset=UTF-8",
1550
+ "accept": "text/plain;charset=UTF-8",
1551
+ }
1552
+
1553
+ merge_object = xml.etree.ElementTree.Element("MergeAction", {"xmlns": self.entity_ns, "xmlns:xip": self.xip_ns})
1554
+ xml.etree.ElementTree.SubElement(merge_object, "Title").text = str(title)
1555
+ xml.etree.ElementTree.SubElement(merge_object, "Description").text = str(description)
1556
+ for a in assets:
1557
+ xml.etree.ElementTree.SubElement(merge_object, "Entity", {
1558
+ "excludeIdentifiers": "true",
1559
+ "excludeLinks": "true",
1560
+ "excludeMetadata": "true",
1561
+ "ref": a.reference,
1562
+ "type": EntityType.ASSET.value}
1563
+ )
1564
+ # order_object = xml.etree.ElementTree.SubElement(merge_object, "Order")
1565
+ # for a in assets:
1566
+ # xml.etree.ElementTree.SubElement(order_object, "Entity", {
1567
+ # "ref": a.reference,
1568
+ # "type": EntityType.CONTENT_OBJECT.value}
1569
+ # )
1570
+ xml_request = xml.etree.ElementTree.tostring(merge_object, encoding="utf-8")
1571
+ print(xml_request)
1572
+ request = self.session.post(
1573
+ f"{self.protocol}://{self.server}/api/entity/actions/merges", data=xml_request, headers=headers)
1574
+ if request.status_code == requests.codes.accepted:
1575
+ return request.content.decode('utf-8')
1576
+ elif request.status_code == requests.codes.unauthorized:
1577
+ self.token = self.__token__()
1578
+ return self.merge_assets(assets, title, description)
1579
+ else:
1580
+ exception = HTTPException(
1581
+ "",
1582
+ request.status_code,
1583
+ request.url,
1584
+ "merge_assets",
1585
+ request.content.decode("utf-8"),
1586
+ )
1587
+ logger.error(exception)
1588
+ raise exception
1589
+
1590
+ def merge_folder(self, folder: Folder) -> str:
1591
+ """
1592
+ Create a new Asset with the content from each Asset in the Folder
1593
+
1594
+ This call will create a new multipart Asset which contains all the content from the Folder.
1595
+
1596
+ The new Asset which is created will have the same title, description and parent as the Folder.
1597
+
1598
+ The return value is the progress status of the merge operation.
1599
+ """
1600
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8', 'accept': 'text/plain;charset=UTF-8'}
1601
+ payload = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1602
+ <MergeAction xmlns="{self.entity_ns}" xmlns:xip="{self.xip_ns}">
1603
+ <Title>{folder.title}</Title>
1604
+ <Description>{folder.description}</Description>
1605
+ <Entity excludeIdentifiers="true" excludeLinks="true" excludeMetadata="true" ref="{folder.reference}" type="SO"/>
1606
+ </MergeAction>"""
1607
+ request = self.session.post(
1608
+ f"{self.protocol}://{self.server}/api/entity/actions/merges", data=payload, headers=headers)
1609
+ if request.status_code == requests.codes.accepted:
1610
+ return request.content.decode('utf-8')
818
1611
  elif request.status_code == requests.codes.unauthorized:
819
1612
  self.token = self.__token__()
820
- return self.security_tag_async(entity, new_tag)
1613
+ return self.merge_folder(folder)
821
1614
  else:
822
- raise RuntimeError(request.status_code, f"security_tag_async change failed on {entity.reference}")
823
-
824
- def metadata(self, uri: str) -> str:
1615
+ exception = HTTPException(
1616
+ folder.reference,
1617
+ request.status_code,
1618
+ request.url,
1619
+ "merge_folder",
1620
+ request.content.decode("utf-8"),
1621
+ )
1622
+ logger.error(exception)
1623
+ raise exception
1624
+
1625
+
1626
+ def xml_asset(self, reference: str) -> str:
825
1627
  """
826
- Retrieve the metadata fragment which is referenced by the URI
1628
+ Retrieve an Asset by its reference
827
1629
 
828
- Returns XML document as a string
1630
+ Returns an XML document of the full Asset
829
1631
 
830
- :param uri: The endpoint of the metadata fragment
831
- """
832
- request = self.session.get(uri, headers={HEADER_TOKEN: self.token})
1632
+ :param reference: The unique identifier of the entity
1633
+ """
1634
+ headers = {HEADER_TOKEN: self.token}
1635
+ params = {"expand": "structure"}
1636
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{IO_PATH}/{reference}', params=params, headers=headers)
833
1637
  if request.status_code == requests.codes.ok:
834
1638
  xml_response = str(request.content.decode('utf-8'))
835
- logger.debug(xml_response)
836
- entity_response = xml.etree.ElementTree.fromstring(xml_response)
837
- content = entity_response.find(f'.//{{{self.xip_ns}}}Content')
838
- return xml.etree.ElementTree.tostring(content[0], encoding='utf-8', method='xml').decode('utf-8')
1639
+ return xml_response
839
1640
  elif request.status_code == requests.codes.unauthorized:
840
1641
  self.token = self.__token__()
841
- return self.metadata(uri)
1642
+ return self.xml_asset(reference)
1643
+ elif request.status_code == requests.codes.not_found:
1644
+ exception = ReferenceNotFoundException(reference, request.status_code, request.url, "xml_asset")
1645
+ logger.error(exception)
1646
+ raise exception
842
1647
  else:
843
- logger.error(request)
844
- raise RuntimeError(request.status_code, f"metadata failed for {uri}")
845
-
846
- def entity(self, entity_type: EntityType, reference: str) -> Entity:
847
- """
848
- Retrieve an entity by its type and reference
1648
+ exception = HTTPException(reference, request.status_code, request.url, "xml_asset",
1649
+ request.content.decode('utf-8'))
1650
+ logger.error(exception)
1651
+ raise exception
849
1652
 
850
- Returns Entity (Asset, Folder, ContentObject)
851
-
852
- :param entity_type: The type of entity to fetch
853
- :param reference: The unique identifier of the entity
854
- """
855
- if entity_type is EntityType.CONTENT_OBJECT:
856
- return self.content_object(reference)
857
- if entity_type is EntityType.FOLDER:
858
- return self.folder(reference)
859
- if entity_type is EntityType.ASSET:
860
- return self.asset(reference)
861
1653
 
862
1654
  def asset(self, reference: str) -> Asset:
1655
+
863
1656
  """
864
- Retrieve an Asset by its reference
1657
+ Returns an asset object back by its internal reference identifier
865
1658
 
866
- Returns Asset
1659
+ :param reference: The unique identifier for the asset usually its uuid
1660
+ :type reference: str
1661
+ :return: The Asset object
1662
+ :rtype: Asset
1663
+ :raises RuntimeError: if the identifier is incorrect
867
1664
 
868
- :param reference: The unique identifier of the entity
869
1665
  """
870
1666
  headers = {HEADER_TOKEN: self.token}
871
- request = self.session.get(f'https://{self.server}/api/entity/{IO_PATH}/{reference}', headers=headers)
1667
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{IO_PATH}/{reference}', headers=headers)
872
1668
  if request.status_code == requests.codes.ok:
873
1669
  xml_response = str(request.content.decode('utf-8'))
874
1670
  entity = self.entity_from_string(xml_response)
875
- return Asset(entity['reference'], entity['title'], entity['description'],
876
- entity['security_tag'], entity['parent'],
877
- entity['metadata'])
1671
+ asset = Asset(entity['reference'], entity['title'], entity['description'],
1672
+ entity['security_tag'], entity['parent'],
1673
+ entity['metadata'])
1674
+ if 'CustomType' in entity:
1675
+ asset.custom_type = entity['CustomType']
1676
+ return asset
878
1677
  elif request.status_code == requests.codes.unauthorized:
879
1678
  self.token = self.__token__()
880
1679
  return self.asset(reference)
881
1680
  elif request.status_code == requests.codes.not_found:
882
- msg = "The requested reference is not found in the repository"
883
- logger.error(msg)
884
- raise RuntimeError(reference, msg)
1681
+ exception = ReferenceNotFoundException(reference, request.status_code, request.url, "asset")
1682
+ logger.error(exception)
1683
+ raise exception
885
1684
  else:
886
- logger.error(request.status_code)
887
- logger.error(str(request.content.decode('utf-8')))
888
- raise RuntimeError(request.status_code, f"asset failed for {reference}")
1685
+ exception = HTTPException(reference, request.status_code, request.url, "asset",
1686
+ request.content.decode('utf-8'))
1687
+ logger.error(exception)
1688
+ raise exception
889
1689
 
890
1690
  def folder(self, reference: str) -> Folder:
891
1691
  """
892
- Retrieve an Folder by its reference
1692
+ Returns a folder object back by its internal reference identifier
893
1693
 
894
- Returns Folder
895
-
896
- :param reference: The unique identifier of the entity
1694
+ :param reference: The unique identifier for the folder usually its uuid
1695
+ :type reference: str
1696
+ :return: The Folder object
1697
+ :rtype: Folder
1698
+ :raises RuntimeError: if the identifier is incorrect
897
1699
  """
898
1700
  headers = {HEADER_TOKEN: self.token}
899
- request = self.session.get(f'https://{self.server}/api/entity/{SO_PATH}/{reference}', headers=headers)
1701
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{SO_PATH}/{reference}', headers=headers)
900
1702
  if request.status_code == requests.codes.ok:
901
1703
  xml_response = str(request.content.decode('utf-8'))
902
1704
  entity = self.entity_from_string(xml_response)
903
- return Folder(entity['reference'], entity['title'], entity['description'],
904
- entity['security_tag'], entity['parent'],
905
- entity['metadata'])
1705
+ folder = Folder(entity['reference'], entity['title'], entity['description'],
1706
+ entity['security_tag'], entity['parent'],
1707
+ entity['metadata'])
1708
+ if 'CustomType' in entity:
1709
+ folder.custom_type = entity['CustomType']
1710
+ return folder
906
1711
  elif request.status_code == requests.codes.unauthorized:
907
1712
  self.token = self.__token__()
908
1713
  return self.folder(reference)
909
1714
  elif request.status_code == requests.codes.not_found:
910
- raise RuntimeError(reference, "The requested reference is not found in the repository")
1715
+ exception = ReferenceNotFoundException(reference, request.status_code, request.url, "folder")
1716
+ logger.error(exception)
1717
+ raise exception
911
1718
  else:
912
- raise RuntimeError(request.status_code, f"folder failed for {reference}")
1719
+ exception = HTTPException(reference, request.status_code, request.url, "folder",
1720
+ request.content.decode('utf-8'))
1721
+ logger.error(exception)
1722
+ raise exception
913
1723
 
914
1724
  def content_object(self, reference: str) -> ContentObject:
915
1725
  """
916
- Retrieve an ContentObject by its reference
917
-
918
- Returns ContentObject
1726
+ Returns a content object back by its internal reference identifier
919
1727
 
920
- :param reference: The unique identifier of the entity
1728
+ :param reference: The unique identifier for the content object usually its uuid
1729
+ :type reference: str
1730
+ :return: The content object
1731
+ :rtype: ContentObject
1732
+ :raises RuntimeError: if the identifier is incorrect
921
1733
  """
922
1734
  headers = {HEADER_TOKEN: self.token}
923
- request = self.session.get(f'https://{self.server}/api/entity/{CO_PATH}/{reference}', headers=headers)
1735
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{CO_PATH}/{reference}', headers=headers)
924
1736
  if request.status_code == requests.codes.ok:
925
1737
  xml_response = str(request.content.decode('utf-8'))
926
1738
  entity = self.entity_from_string(xml_response)
927
- return ContentObject(entity['reference'], entity['title'], entity['description'],
928
- entity['security_tag'], entity['parent'],
929
- entity['metadata'])
1739
+ content_object = ContentObject(entity['reference'], entity['title'], entity['description'],
1740
+ entity['security_tag'], entity['parent'],
1741
+ entity['metadata'])
1742
+ if 'CustomType' in entity:
1743
+ content_object.custom_type = entity['CustomType']
1744
+ return content_object
930
1745
  elif request.status_code == requests.codes.unauthorized:
931
1746
  self.token = self.__token__()
932
1747
  return self.content_object(reference)
933
1748
  elif request.status_code == requests.codes.not_found:
934
- raise RuntimeError(reference, "The requested reference is not found in the repository")
1749
+ exception = ReferenceNotFoundException(reference, request.status_code, request.url, "content_object")
1750
+ logger.error(exception)
1751
+ raise exception
935
1752
  else:
936
- raise RuntimeError(request.status_code, f"content_object failed for {reference}")
1753
+ exception = HTTPException(reference, request.status_code, request.url, "content_object",
1754
+ request.content.decode('utf-8'))
1755
+ logger.error(exception)
1756
+ raise exception
937
1757
 
938
- def content_objects(self, representation: Representation) -> Optional[list]:
1758
+ def content_objects(self, representation: Representation) -> list[ContentObject]:
939
1759
  """
940
- Retrieve a list of content objects within a representation
1760
+ Return a list of content objects for a representation
941
1761
 
942
- Returns List(ContentObject)
1762
+ :param representation: The representation
1763
+ :type representation: Representation
1764
+ :return: List of content objects
1765
+ :rtype: list(ContentObject)
943
1766
 
944
- :param representation:
945
1767
  """
946
1768
  headers = {HEADER_TOKEN: self.token}
947
1769
  if not isinstance(representation, Representation):
948
- logger.warn("representation is not of type Representation")
949
- return None
1770
+ logger.warning("representation is not of type Representation")
1771
+ return []
950
1772
  request = self.session.get(f'{representation.url}', headers=headers)
951
1773
  if request.status_code == requests.codes.ok:
952
- results = list()
1774
+ results = []
953
1775
  xml_response = str(request.content.decode('utf-8'))
954
1776
  logger.debug(xml_response)
955
1777
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
@@ -965,17 +1787,22 @@ class EntityAPI(AuthenticatedAPI):
965
1787
  self.token = self.__token__()
966
1788
  return self.content_objects(representation)
967
1789
  else:
968
- logger.error(request)
969
- raise RuntimeError(request.status_code, "content_objects failed")
1790
+ exception = HTTPException(representation.name, request.status_code, request.url, "content_objects",
1791
+ request.content.decode('utf-8'))
1792
+ logger.error(exception)
1793
+ raise exception
970
1794
 
971
- def generation(self, url: str):
1795
+ def generation(self, url: str, content_ref: str = None) -> Generation:
972
1796
  """
973
- Retrieve a list of generation objects
1797
+ Retrieve a list of generation objects
974
1798
 
975
- Returns List(Generation)
1799
+ :param url:
1800
+ :param content_ref:
1801
+
1802
+ :return: Generation
1803
+ :rtype: Generation
1804
+ """
976
1805
 
977
- :param url:
978
- """
979
1806
  headers = {HEADER_TOKEN: self.token}
980
1807
  request = self.session.get(url, headers=headers)
981
1808
  if request.status_code == requests.codes.ok:
@@ -985,19 +1812,61 @@ class EntityAPI(AuthenticatedAPI):
985
1812
  ge = entity_response.find(f'.//{{{self.xip_ns}}}Generation')
986
1813
  format_group = entity_response.find(f'.//{{{self.xip_ns}}}FormatGroup')
987
1814
  effective_date = entity_response.find(f'.//{{{self.xip_ns}}}EffectiveDate')
1815
+
1816
+ formats = entity_response.findall(f'.//{{{self.xip_ns}}}Formats/{{{self.xip_ns}}}Format')
1817
+ formats_list = []
1818
+ for tech_format in formats:
1819
+ format_dict = {'Valid': tech_format.attrib['valid']}
1820
+ puid = tech_format.find(f'.//{{{self.xip_ns}}}PUID')
1821
+ format_dict['PUID'] = puid.text if hasattr(puid, 'text') else None
1822
+ priority = tech_format.find(f'.//{{{self.xip_ns}}}Priority')
1823
+ format_dict['Priority'] = priority.text if hasattr(priority, 'text') else None
1824
+ method = tech_format.find(f'.//{{{self.xip_ns}}}IdentificationMethod')
1825
+ format_dict['IdentificationMethod'] = method.text if hasattr(method, 'text') else None
1826
+ name = tech_format.find(f'.//{{{self.xip_ns}}}FormatName')
1827
+ format_dict['FormatName'] = name.text if hasattr(name, 'text') else None
1828
+ version = tech_format.find(f'.//{{{self.xip_ns}}}FormatVersion')
1829
+ format_dict['FormatVersion'] = version.text if hasattr(version, 'text') else None
1830
+ formats_list.append(format_dict)
1831
+
1832
+ index = int(url.rsplit("/", 1)[-1])
1833
+
1834
+ properties = entity_response.findall(f'.//{{{self.xip_ns}}}Properties/{{{self.xip_ns}}}Property')
1835
+ property_set = []
1836
+ for tech_props in properties:
1837
+ tech_props_dict = {}
1838
+ puid = tech_props.find(f'.//{{{self.xip_ns}}}PUID')
1839
+ tech_props_dict['PUID'] = puid.text if hasattr(puid, 'text') else None
1840
+ name = tech_props.find(f'.//{{{self.xip_ns}}}PropertyName')
1841
+ tech_props_dict['PropertyName'] = name.text if hasattr(name, 'text') else None
1842
+ value = tech_props.find(f'.//{{{self.xip_ns}}}Value')
1843
+ tech_props_dict['Value'] = value.text if hasattr(value, 'text') else None
1844
+ property_set.append(tech_props_dict)
1845
+
988
1846
  bitstreams = entity_response.findall(f'./{{{self.entity_ns}}}Bitstreams/{{{self.entity_ns}}}Bitstream')
989
- bitstream_list = list()
1847
+ bitstream_list = []
990
1848
  for bit in bitstreams:
991
- bitstream_list.append(self.bitstream(bit.text))
992
- return Generation(bool(ge.attrib['original']), bool(ge.attrib['active']),
993
- format_group.text if hasattr(format_group, 'text') else None,
994
- effective_date.text if hasattr(effective_date, 'text') else None,
995
- bitstream_list)
1849
+ bs: Bitstream = self.bitstream(bit.text)
1850
+ bs.gen_index = index
1851
+ if content_ref is not None:
1852
+ bs.co_ref = content_ref
1853
+ bitstream_list.append(bs)
1854
+ generation = Generation(strtobool(ge.attrib['original']), strtobool(ge.attrib['active']),
1855
+ format_group.text if hasattr(format_group, 'text') else None,
1856
+ effective_date.text if hasattr(effective_date, 'text') else None,
1857
+ bitstream_list)
1858
+ generation.formats = formats_list
1859
+ generation.properties = property_set
1860
+ generation.gen_index = index
1861
+ return generation
996
1862
  elif request.status_code == requests.codes.unauthorized:
997
1863
  self.token = self.__token__()
998
1864
  return self.generation(url)
999
1865
  else:
1000
- raise RuntimeError(request.status_code, "generation failed")
1866
+ exception = HTTPException(url, request.status_code, request.url, "generation",
1867
+ request.content.decode('utf-8'))
1868
+ logger.error(exception)
1869
+ raise exception
1001
1870
 
1002
1871
  def _integrity_checks(self, bitstream: Bitstream, maximum: int = 10, next_page: str = None):
1003
1872
  headers = {HEADER_TOKEN: self.token}
@@ -1016,7 +1885,7 @@ class EntityAPI(AuthenticatedAPI):
1016
1885
 
1017
1886
  next_url = entity_response.find(f'.//{{{self.entity_ns}}}Paging/{{{self.entity_ns}}}Next')
1018
1887
  total_hits = entity_response.find(f'.//{{{self.entity_ns}}}Paging/{{{self.entity_ns}}}TotalResults')
1019
- results = list()
1888
+ results = []
1020
1889
  for history in histories:
1021
1890
  xip_type = history.find(f'./{{{self.xip_ns}}}Type')
1022
1891
  xip_success = history.find(f'./{{{self.xip_ns}}}Success')
@@ -1039,15 +1908,18 @@ class EntityAPI(AuthenticatedAPI):
1039
1908
  else:
1040
1909
  url = next_url.text
1041
1910
 
1042
- return PagedSet(results, has_more, total_hits.text, url)
1911
+ return PagedSet(results, has_more, int(total_hits.text), url)
1043
1912
 
1044
1913
  elif request.status_code == requests.codes.unauthorized:
1045
1914
  self.token = self.__token__()
1046
1915
  return self._integrity_checks(bitstream, maximum, next_page)
1047
1916
  else:
1048
- raise RuntimeError(request.status_code, "integrity_checks failed")
1917
+ exception = HTTPException(bitstream.filename, request.status_code, request.url, "_integrity_checks",
1918
+ request.content.decode('utf-8'))
1919
+ logger.error(exception)
1920
+ raise exception
1049
1921
 
1050
- def integrity_checks(self, bitstream: Bitstream):
1922
+ def integrity_checks(self, bitstream: Bitstream) -> Generator:
1051
1923
  """
1052
1924
  Return integrity checks for a bitstream
1053
1925
 
@@ -1061,13 +1933,14 @@ class EntityAPI(AuthenticatedAPI):
1061
1933
  for entity in paged_set.results:
1062
1934
  yield entity
1063
1935
 
1064
- def bitstream(self, url: str):
1936
+ def bitstream(self, url: str) -> Bitstream:
1065
1937
  """
1066
- Retrieve a bitstream by its url
1938
+ Fetch a bitstream object from the server using its URL
1067
1939
 
1068
- Returns Bitstream
1069
-
1070
- :param url:
1940
+ :param url: The URL to the bitstream
1941
+ :type url: str
1942
+ :return: a bitstream object
1943
+ :rtype: Bitstream
1071
1944
  """
1072
1945
  headers = {HEADER_TOKEN: self.token}
1073
1946
  request = self.session.get(url, headers=headers)
@@ -1079,26 +1952,40 @@ class EntityAPI(AuthenticatedAPI):
1079
1952
  filesize = entity_response.find(f'.//{{{self.xip_ns}}}FileSize')
1080
1953
  fixity_values = entity_response.findall(f'.//{{{self.xip_ns}}}Fixity')
1081
1954
  content = entity_response.find(f'.//{{{self.entity_ns}}}Content')
1082
- fixity = dict()
1955
+
1956
+ index = int(url.rsplit("/", 1)[-1])
1957
+
1958
+ fixity = {}
1083
1959
  for f in fixity_values:
1084
1960
  fixity[f[0].text] = f[1].text
1085
1961
  bitstream = Bitstream(filename.text if hasattr(filename, 'text') else None,
1086
- filesize.text if hasattr(filesize, 'text') else None, fixity,
1962
+ int(filesize.text) if hasattr(filesize, 'text') else None, fixity,
1087
1963
  content.text if hasattr(content, 'text') else None)
1964
+
1965
+ bitstream.bs_index = index
1088
1966
  return bitstream
1089
1967
  elif request.status_code == requests.codes.unauthorized:
1090
1968
  self.token = self.__token__()
1091
1969
  return self.bitstream(url)
1092
1970
  else:
1093
- logger.error(request)
1094
- raise RuntimeError(request.status_code, "bitstream failed")
1971
+ exception = HTTPException(url, request.status_code, request.url, "bitstream",
1972
+ request.content.decode('utf-8'))
1973
+ logger.error(exception)
1974
+ raise exception
1095
1975
 
1096
1976
  def replace_generation_sync(self, content_object: ContentObject, file_name, fixity_algorithm=None,
1097
1977
  fixity_value=None) -> str:
1098
1978
  """
1099
- Replace the last active generation of a content object with a new digital file.
1979
+ Replace the last active generation of a content object with a new digital file.
1100
1980
 
1101
- Starts the workflow and blocks until the workflow completes.
1981
+ Starts the workflow and blocks until the workflow completes.
1982
+
1983
+ :param ContentObject content_object: The content object to replace
1984
+ :param str file_name: The path to the new content object
1985
+ :param str fixity_algorithm: Optional fixity algorithm
1986
+ :param str fixity_value: Optional fixity value
1987
+ :return: Completed workflow status
1988
+ :rtype: str
1102
1989
 
1103
1990
  """
1104
1991
 
@@ -1117,7 +2004,14 @@ class EntityAPI(AuthenticatedAPI):
1117
2004
  """
1118
2005
  Replace the last active generation of a content object with a new digital file.
1119
2006
 
1120
- Starts the workflow and returns
2007
+ Starts the workflow and returns a process ID
2008
+
2009
+ :param ContentObject content_object: The content object to replace
2010
+ :param str file_name: The path to the new content object
2011
+ :param str fixity_algorithm: Optional fixity algorithm
2012
+ :param str fixity_value: Optional fixity value
2013
+ :return: Process ID
2014
+ :rtype: str
1121
2015
 
1122
2016
  """
1123
2017
  if (self.major_version < 7) and (self.minor_version < 2) and (self.patch_version < 1):
@@ -1131,7 +2025,14 @@ class EntityAPI(AuthenticatedAPI):
1131
2025
  bitstream = generation.bitstreams.pop()
1132
2026
  for algo, value in bitstream.fixity.items():
1133
2027
  fixity_algorithm = algo
1134
- fixity_value = value
2028
+ if "MD5" in fixity_algorithm.upper():
2029
+ fixity_value = FileHash(hashlib.md5)(file_name)
2030
+ if "SHA1" in fixity_algorithm.upper() or "SHA-1" in fixity_algorithm.upper():
2031
+ fixity_value = FileHash(hashlib.sha1)(file_name)
2032
+ if "SHA256" in fixity_algorithm.upper() or "SHA-256" in fixity_algorithm.upper():
2033
+ fixity_value = FileHash(hashlib.sha256)(file_name)
2034
+ if "SHA512" in fixity_algorithm.upper() or "SHA-512" in fixity_algorithm.upper():
2035
+ fixity_value = FileHash(hashlib.sha512)(file_name)
1135
2036
 
1136
2037
  if fixity_algorithm and fixity_value:
1137
2038
  if "MD5" in fixity_algorithm.upper():
@@ -1148,7 +2049,7 @@ class EntityAPI(AuthenticatedAPI):
1148
2049
 
1149
2050
  with open(file_name, 'rb') as f:
1150
2051
  request = self.session.post(
1151
- f'https://{self.server}/api/entity/{CO_PATH}/{content_object.reference}/generations',
2052
+ f'{self.protocol}://{self.server}/api/entity/{CO_PATH}/{content_object.reference}/generations',
1152
2053
  params=params, data=f, headers=headers)
1153
2054
 
1154
2055
  if request.status_code == requests.codes.ok:
@@ -1157,71 +2058,107 @@ class EntityAPI(AuthenticatedAPI):
1157
2058
  return self.replace_generation_async(content_object=content_object, file_name=file_name,
1158
2059
  fixity_algorithm=fixity_algorithm, fixity_value=fixity_value)
1159
2060
  else:
1160
- raise RuntimeError(request.status_code, f"replace_generation failed: {request.content}")
2061
+ exception = HTTPException(content_object.reference, request.status_code, request.url,
2062
+ "replace_generation_async", request.content.decode('utf-8'))
2063
+ logger.error(exception)
2064
+ raise exception
1161
2065
 
1162
- def generations(self, content_object: ContentObject) -> list:
2066
+ def generations(self, content_object: ContentObject) -> list[Generation]:
1163
2067
  """
1164
- Retrieve list of generations on a content object
2068
+ Return a list of Generation objects for a content object
1165
2069
 
1166
- Returns list
1167
-
1168
- :param content_object:
2070
+ :param content_object: The content object
2071
+ :type content_object: ContentObject
2072
+ :return: list of generations
2073
+ :rtype: list(Generation)
1169
2074
  """
1170
2075
  headers = {HEADER_TOKEN: self.token}
1171
2076
  request = self.session.get(
1172
- f'https://{self.server}/api/entity/{CO_PATH}/{content_object.reference}/generations', headers=headers)
2077
+ f'{self.protocol}://{self.server}/api/entity/{CO_PATH}/{content_object.reference}/generations',
2078
+ headers=headers)
1173
2079
  if request.status_code == requests.codes.ok:
1174
2080
  xml_response = str(request.content.decode('utf-8'))
1175
2081
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
1176
2082
  generations = entity_response.findall(f'.//{{{self.entity_ns}}}Generation')
1177
- result = list()
2083
+ result = []
1178
2084
  for g in generations:
1179
2085
  if hasattr(g, 'text'):
1180
- generation = self.generation(g.text)
2086
+ generation = self.generation(g.text, content_object.reference)
2087
+ generation.asset = content_object.asset
1181
2088
  generation.content_object = content_object
2089
+ generation.representation_type = content_object.representation_type
1182
2090
  result.append(generation)
1183
2091
  return result
1184
2092
  elif request.status_code == requests.codes.unauthorized:
1185
2093
  self.token = self.__token__()
1186
2094
  return self.generations(content_object)
1187
2095
  else:
1188
- raise RuntimeError(request.status_code, "generations failed")
2096
+ exception = HTTPException(content_object.reference, request.status_code, request.url,
2097
+ "generations", request.content.decode('utf-8'))
2098
+ logger.error(exception)
2099
+ raise exception
2100
+
2101
+ def bitstreams_for_asset(self, asset: Union[Asset, Entity]) -> Iterable[Bitstream]:
2102
+ """
2103
+ Return all the active bitstreams within an asset.
2104
+ This includes all the representations and content objects
2105
+
2106
+ :param asset: The asset
2107
+ :return: Iterable
2108
+ """
1189
2109
 
1190
- def representations(self, asset: Asset) -> Optional[set]:
2110
+ for representation in self.representations(asset):
2111
+ for content_object in self.content_objects(representation):
2112
+ for generation in self.generations(content_object):
2113
+ if generation.active:
2114
+ for bitstream in generation.bitstreams:
2115
+ bitstream.representation = representation
2116
+ bitstream.content_object = content_object
2117
+ bitstream.generation = generation
2118
+ yield bitstream
2119
+
2120
+ def representations(self, asset: Asset) -> set[Representation]:
1191
2121
  """
1192
- Retrieve set of representations on an Asset
2122
+ Return a set of representations for the asset
1193
2123
 
1194
- Returns list
2124
+ Representations are used to define how the information object are composed in terms of technology and structure.
1195
2125
 
1196
- :param asset: The asset
2126
+ :param asset: The asset containing the required representations
2127
+ :type asset: Asset
2128
+ :return: Set of Representation objects
2129
+ :rtype: set(Representation)
1197
2130
  """
1198
2131
  headers = {HEADER_TOKEN: self.token}
1199
2132
  if not isinstance(asset, Asset):
1200
- return None
1201
- request = self.session.get(f'https://{self.server}/api/entity/{asset.path}/{asset.reference}/representations',
1202
- headers=headers)
2133
+ return set()
2134
+ request = self.session.get(
2135
+ f'{self.protocol}://{self.server}/api/entity/{asset.path}/{asset.reference}/representations',
2136
+ headers=headers)
1203
2137
  if request.status_code == requests.codes.ok:
1204
2138
  xml_response = str(request.content.decode('utf-8'))
1205
2139
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
1206
2140
  representations = entity_response.findall(f'.//{{{self.entity_ns}}}Representation')
1207
2141
  result = set()
1208
2142
  for r in representations:
1209
- representation = Representation(asset, r.get('type'), r.get("name", ""), r.text)
2143
+ representation = Representation(asset, r.get('type'), r.get("name", None), r.text)
1210
2144
  result.add(representation)
1211
2145
  return result
1212
2146
  elif request.status_code == requests.codes.unauthorized:
1213
2147
  self.token = self.__token__()
1214
2148
  return self.representations(asset)
1215
2149
  else:
1216
- raise RuntimeError(request.status_code, "representations failed")
2150
+ exception = HTTPException(asset.reference, request.status_code, request.url,
2151
+ "representations", request.content.decode('utf-8'))
2152
+ logger.error(exception)
2153
+ raise exception
1217
2154
 
1218
2155
  def remove_thumbnail(self, entity: Entity):
1219
2156
  """
1220
- remove a thumbnail icon to a folder or asset
2157
+ Remove the thumbnail for the entity to the uploaded image
1221
2158
 
1222
-
1223
- :param entity: The Entity
1224
- """
2159
+ :param entity: The entity with the thumbnail
2160
+ :type entity: Entity
2161
+ """
1225
2162
  if self.major_version < 7 and self.minor_version < 2:
1226
2163
  raise RuntimeError("Thumbnail API is only available when connected to a v6.2 System")
1227
2164
 
@@ -1230,24 +2167,68 @@ class EntityAPI(AuthenticatedAPI):
1230
2167
 
1231
2168
  headers = {HEADER_TOKEN: self.token}
1232
2169
 
1233
- request = self.session.delete(f'https://{self.server}/api/entity/{entity.path}/{entity.reference}/preview',
1234
- headers=headers)
2170
+ request = self.session.delete(
2171
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/preview',
2172
+ headers=headers)
1235
2173
  if request.status_code == requests.codes.no_content:
1236
2174
  return str(request.content.decode('utf-8'))
1237
2175
  elif request.status_code == requests.codes.unauthorized:
1238
2176
  self.token = self.__token__()
1239
2177
  return self.remove_thumbnail(entity)
1240
2178
  else:
1241
- raise RuntimeError(request.status_code, f"remove_thumbnail failed: {request.content}")
2179
+ exception = HTTPException(entity.reference, request.status_code, request.url,
2180
+ "remove_thumbnail", request.content.decode('utf-8'))
2181
+ logger.error(exception)
2182
+ raise exception
2183
+
2184
+
2185
+ def add_access_representation(self, entity: Entity, access_file: str, name: str = "Access"):
2186
+ """
2187
+ Add a new Access representation to an existing asset.
2188
+
2189
+ :param Entity entity: The existing asset which will receive the new representation
2190
+ :param str access_file: The new digital file
2191
+ :param str name: The name of the new access representation defaults to "Access"
2192
+ :return:
2193
+ """
2194
+
2195
+ if self.major_version < 7 and self.minor_version < 12:
2196
+ raise RuntimeError("Add Representation API is only available when connected to a v6.12 System")
2197
+
2198
+ if isinstance(entity, Folder) or isinstance(entity, ContentObject):
2199
+ raise RuntimeError("Add Representation cannot be added to Folders and Content Objects")
2200
+
2201
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
2202
+
2203
+ filename = os.path.basename(access_file)
2204
+
2205
+ params = {'type': 'Access', 'name': name, 'filename': filename}
2206
+
2207
+ with open(access_file, 'rb') as fd:
2208
+ request = self.session.post(
2209
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/representations',
2210
+ data=fd, headers=headers, params=params)
2211
+ if request.status_code == requests.codes.accepted:
2212
+ return str(request.content.decode('utf-8'))
2213
+ elif request.status_code == requests.codes.unauthorized:
2214
+ self.token = self.__token__()
2215
+ return self.add_access_representation(entity, access_file, name)
2216
+ else:
2217
+ exception = HTTPException(entity.reference, request.status_code, request.url,
2218
+ "add_access_representation", request.content.decode('utf-8'))
2219
+ logger.error(exception)
2220
+ raise exception
1242
2221
 
1243
2222
  def add_thumbnail(self, entity: Entity, image_file: str):
1244
2223
  """
1245
- add a thumbnail icon to a folder or asset
2224
+ Set the thumbnail for the entity to the uploaded image
1246
2225
 
2226
+ Supported image formats are png, jpeg, tiff, gif and bmp. The image must be 10MB or less in size.
2227
+
2228
+ :param Entity entity: The entity
2229
+ :param str image_file: The path to the image
2230
+ """
1247
2231
 
1248
- :param entity: The Entity
1249
- :param image_file: Path to image file
1250
- """
1251
2232
  if self.major_version < 7 and self.minor_version < 2:
1252
2233
  raise RuntimeError("Thumbnail API is only available when connected to a v6.2 System")
1253
2234
 
@@ -1256,9 +2237,10 @@ class EntityAPI(AuthenticatedAPI):
1256
2237
 
1257
2238
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
1258
2239
 
1259
- with open(image_file, 'rb') as f:
1260
- request = self.session.put(f'https://{self.server}/api/entity/{entity.path}/{entity.reference}/preview',
1261
- data=f, headers=headers)
2240
+ with open(image_file, 'rb') as fd:
2241
+ request = self.session.put(
2242
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/preview',
2243
+ data=fd, headers=headers)
1262
2244
 
1263
2245
  if request.status_code == requests.codes.no_content:
1264
2246
  return str(request.content.decode('utf-8'))
@@ -1266,75 +2248,417 @@ class EntityAPI(AuthenticatedAPI):
1266
2248
  self.token = self.__token__()
1267
2249
  return self.add_thumbnail(entity, image_file)
1268
2250
  else:
1269
- raise RuntimeError(request.status_code, f"add_thumbnail failed: {request.content}")
2251
+ exception = HTTPException(entity.reference, request.status_code, request.url,
2252
+ "add_thumbnail", request.content.decode('utf-8'))
2253
+ logger.error(exception)
2254
+ raise exception
2255
+
2256
+ def _event_actions(self, entity: Entity, maximum: int):
2257
+ """
2258
+ event actions performed against this entity
2259
+ """
2260
+ if self.major_version < 7 and self.minor_version < 1:
2261
+ logger.error("Entity events is only available when connected to a v6.1 System")
2262
+ raise RuntimeError("Entity events is only available when connected to a v6.1 System")
2263
+
2264
+ headers = {HEADER_TOKEN: self.token}
2265
+ params = {'start': str(0), 'max': str(maximum)}
2266
+
2267
+ request = self.session.get(
2268
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/event-actions',
2269
+ params=params, headers=headers)
2270
+
2271
+ if request.status_code == requests.codes.ok:
2272
+ return None
2273
+ elif request.status_code == requests.codes.unauthorized:
2274
+ self.token = self.__token__()
2275
+ return self._event_actions(entity, maximum=maximum)
2276
+ else:
2277
+ exception = HTTPException(entity.reference, request.status_code, request.url,
2278
+ "_event_actions", request.content.decode('utf-8'))
2279
+ logger.error(exception)
2280
+ raise exception
1270
2281
 
1271
- def all_descendants(self, folder_reference: str = None):
2282
+ def all_descendants(self, folder: Union[Folder, Entity] = None) -> Generator[Entity, None, None]:
1272
2283
  """
1273
- Retrieve list of entities below a folder in the repository
2284
+ Return all child entities recursively of a folder or repository down to the assets using a lazy iterator.
2285
+ The paging is done internally using a default page
2286
+ size of 100 elements. Callers can iterate over the result to get all children with a single call.
1274
2287
 
1275
- Returns list
2288
+ :param str folder: The parent folder reference, None for the children of root folders
2289
+ :return: A set of entity objects (Folders and Assets)
2290
+ :rtype: set(Entity)
1276
2291
 
1277
- :param folder_reference: The folder to find children of
1278
2292
  """
1279
- self.token = self.__token__()
1280
- for e in self.descendants(folder_reference=folder_reference):
1281
- yield e
1282
- if e.entity_type == EntityType.FOLDER:
1283
- yield from self.all_descendants(folder_reference=e.reference)
2293
+ for entity in self.descendants(folder=folder):
2294
+ yield entity
2295
+ if entity.entity_type == EntityType.FOLDER:
2296
+ yield from self.all_descendants(folder=entity)
2297
+
2298
+ def descendants(self, folder: Union[str, Folder] = None) -> Generator[Entity, None, None]:
2299
+
2300
+ """
2301
+ Return the immediate child entities of a folder using a lazy iterator. The paging is done internally using a default page
2302
+ size of 100 elements. Callers can iterate over the result to get all children with a single call.
2303
+
2304
+ :param str folder: The parent folder reference, None for the children of root folders
2305
+ :return: A set of entity objects (Folders and Assets)
2306
+ :rtype: set(Entity)
1284
2307
 
1285
- def descendants(self, folder_reference: str = None):
1286
- maximum = 50
1287
- paged_set = self.children(folder_reference, maximum=maximum, next_page=None)
2308
+ """
2309
+
2310
+ maximum = 100
2311
+ paged_set = self.children(folder, maximum=maximum, next_page=None)
1288
2312
  for entity in paged_set.results:
1289
2313
  yield entity
1290
2314
  while paged_set.has_more:
1291
- paged_set = self.children(folder_reference, maximum=maximum, next_page=paged_set.next_page)
2315
+ paged_set = self.children(folder, maximum=maximum, next_page=paged_set.next_page)
1292
2316
  for entity in paged_set.results:
1293
2317
  yield entity
1294
2318
 
1295
- def children(self, folder_reference: str = None, maximum: int = 50, next_page: str = None) -> PagedSet:
2319
+
2320
+
2321
+ def children(self, folder: Union[str, Folder] = None, maximum: int = 100, next_page: str = None) -> PagedSet:
2322
+
2323
+ """
2324
+ Return the child entities of a folder one page at a time. The caller is responsible for
2325
+ requesting the next page of results.
2326
+
2327
+ This function is deprecated, use descendants instead as the paging is automatic
2328
+
2329
+ :param str folder: The parent folder reference, None for the children of root folders
2330
+ :param int maximum: The maximum size of the result set in each page
2331
+ :param str next_page: A URL for the next page of results
2332
+ :return: A set of entity objects
2333
+ :rtype: set(Entity)
2334
+ """
2335
+
1296
2336
  headers = {HEADER_TOKEN: self.token}
1297
2337
  data = {'start': str(0), 'max': str(maximum)}
2338
+
2339
+ if isinstance(folder, Folder):
2340
+ folder_reference = folder.reference
2341
+ else:
2342
+ folder_reference = folder
1298
2343
  if next_page is None:
1299
2344
  if folder_reference is None:
1300
- request = self.session.get(f'https://{self.server}/api/entity/root/children', data=data,
2345
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/root/children', params=data,
1301
2346
  headers=headers)
1302
2347
  else:
2348
+ if hasattr(folder, "reference"):
2349
+ folder_reference = folder.reference
1303
2350
  request = self.session.get(
1304
- f'https://{self.server}/api/entity/structural-objects/{folder_reference}/children',
1305
- data=data, headers=headers)
2351
+ f'{self.protocol}://{self.server}/api/entity/structural-objects/{folder_reference}/children',
2352
+ params=data, headers=headers)
1306
2353
  else:
1307
2354
  request = self.session.get(next_page, headers=headers)
2355
+ logger.debug(request.url)
1308
2356
  if request.status_code == requests.codes.ok:
1309
2357
  xml_response = str(request.content.decode('utf-8'))
2358
+ logger.debug(xml_response)
1310
2359
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
1311
2360
  children = entity_response.findall(f'.//{{{self.entity_ns}}}Child')
1312
2361
  result = set()
1313
2362
  next_url = entity_response.find(f'.//{{{self.entity_ns}}}Next')
1314
2363
  total_hits = entity_response.find(f'.//{{{self.entity_ns}}}TotalResults')
1315
- for c in children:
1316
- if c.attrib['type'] == EntityType.FOLDER.value:
1317
- f = Folder(c.attrib['ref'], c.attrib['title'], None, None, folder_reference, None)
1318
- result.add(f)
2364
+ for child in children:
2365
+ if child.attrib['type'] == EntityType.FOLDER.value:
2366
+ folder = Folder(child.attrib['ref'], child.attrib['title'], None, None, folder_reference, None)
2367
+ result.add(folder)
1319
2368
  else:
1320
- a = Asset(c.attrib['ref'], c.attrib['title'], None, None, folder_reference, None)
1321
- result.add(a)
2369
+ asset = Asset(child.attrib['ref'], child.attrib['title'], None, None, folder_reference, None)
2370
+ result.add(asset)
1322
2371
  has_more = True
1323
2372
  url = None
1324
2373
  if next_url is None:
1325
2374
  has_more = False
1326
2375
  else:
1327
2376
  url = next_url.text
1328
- return PagedSet(result, has_more, total_hits.text, url)
2377
+ return PagedSet(result, has_more, int(total_hits.text), url)
1329
2378
  elif request.status_code == requests.codes.unauthorized:
1330
2379
  self.token = self.__token__()
1331
2380
  return self.children(folder_reference, maximum=maximum, next_page=next_page)
1332
2381
  else:
1333
- raise RuntimeError(request.status_code, "children failed")
2382
+ exception = HTTPException(folder_reference, request.status_code, request.url,
2383
+ "children", request.content.decode('utf-8'))
2384
+ logger.error(exception)
2385
+ raise exception
2386
+
2387
+ def all_ingest_events(self, previous_days: int = 1) -> Generator:
2388
+ """
2389
+ Returns a list of ingest only events for the user's tenancy
2390
+
2391
+ This method uses a generator function to make repeated calls to the server for every page of results.
2392
+
2393
+ :param int previous_days: The number of days to look back for events
2394
+ :return: A generator of events
2395
+ :rtype: Generator
2396
+ """
2397
+
2398
+ self.token = self.__token__()
2399
+ previous = datetime.utcnow() - timedelta(days=previous_days)
2400
+ from_date = previous.replace(tzinfo=timezone.utc).isoformat()
2401
+ to_date = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
2402
+ paged_set = self._all_events_page(maximum=25, next_page=None, type="Ingest", from_date=from_date,
2403
+ to_date=to_date)
2404
+ for entity in paged_set.results:
2405
+ yield entity
2406
+ while paged_set.has_more:
2407
+ paged_set = self._all_events_page(maximum=25, next_page=paged_set.next_page, type="Ingest",
2408
+ from_date=from_date, to_date=to_date)
2409
+ for entity in paged_set.results:
2410
+ yield entity
2411
+
2412
+ def all_events(self) -> Generator:
2413
+ """
2414
+ Returns a list of events for the user's tenancy
2415
+
2416
+ This method uses a generator function to make repeated calls to the server for every page of results.
2417
+
2418
+ :return: A generator of events
2419
+ :rtype: Generator
2420
+ """
2421
+ self.token = self.__token__()
2422
+ paged_set = self._all_events_page()
2423
+ for entity in paged_set.results:
2424
+ yield entity
2425
+ while paged_set.has_more:
2426
+ paged_set = self._all_events_page(next_page=paged_set.next_page)
2427
+ for entity in paged_set.results:
2428
+ yield entity
2429
+
2430
+ def _entity_from_event_page(self, event_id: str, maximum: int = 25, next_page: str = None):
2431
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
2432
+ if next_page is None:
2433
+ url = f'{self.protocol}://{self.server}/api/entity/events/{event_id}/event-actions'
2434
+ response = requests.get(url, params={'start': 0, 'max': maximum}, headers=headers)
2435
+ else:
2436
+ response = requests.get(next_page, headers=headers)
2437
+ if response.status_code == requests.codes.unauthorized:
2438
+ self.token = self.__token__()
2439
+ return self._entity_from_event_page(event_id, maximum, next_page)
2440
+ if response.status_code == 200:
2441
+ xml_response = str(response.content.decode('utf-8'))
2442
+ entity_response = xml.etree.ElementTree.fromstring(xml_response)
2443
+ actions = entity_response.findall(f'.//{{{self.xip_ns}}}EventAction')
2444
+ result_list = []
2445
+ for action in actions:
2446
+ item: dict = {}
2447
+ event = action.find(f'.//{{{self.xip_ns}}}Event')
2448
+ event_type = event.attrib["type"]
2449
+ item['EventType'] = event_type
2450
+ entity_date = action.find(f'.//{{{self.xip_ns}}}Date')
2451
+ item['Date'] = entity_date.text
2452
+ entity_ref = action.find(f'.//{{{self.xip_ns}}}Entity')
2453
+ item['Entity'] = entity_ref.text
2454
+ result_list.append(item)
2455
+ next_url = entity_response.find(f'.//{{{self.entity_ns}}}Next')
2456
+ total_hits = entity_response.find(f'.//{{{self.entity_ns}}}TotalResults')
2457
+ has_more = True
2458
+ url = None
2459
+ if next_url is None:
2460
+ has_more = False
2461
+ else:
2462
+ url = next_url.text
2463
+ return PagedSet(result_list, has_more, int(total_hits.text), url)
2464
+ return None
2465
+
2466
+
2467
+
2468
+
2469
+ def entity_from_event(self, event_id: str) -> Generator:
2470
+ """
2471
+ Returns an entity from the user's tenancy
2472
+ :rtype: Generator
2473
+
2474
+ """
2475
+ self.token = self.__token__()
2476
+ paged_set = self._entity_from_event_page(event_id, 25, None)
2477
+ for entity in paged_set.results:
2478
+ yield entity
2479
+ while paged_set.has_more:
2480
+ paged_set = self._entity_from_event_page(event_id, 25, next_page=paged_set.next_page)
2481
+ for entity in paged_set.results:
2482
+ yield entity
2483
+
2484
+ def _all_events_page(self, maximum: int = 25, next_page: str = None, **kwargs) -> PagedSet:
2485
+ """
2486
+ event actions performed against this repository
2487
+ """
2488
+ headers = {HEADER_TOKEN: self.token}
2489
+
2490
+ params = {'start': str(0), 'max': str(maximum)}
2491
+ if "type" in kwargs:
2492
+ params["types"] = kwargs.get("type")
2493
+ if "from_date" in kwargs:
2494
+ params["from"] = kwargs.get("from_date")
2495
+ if "to_date" in kwargs:
2496
+ params["to"] = kwargs.get("to_date")
2497
+ if "username" in kwargs:
2498
+ params["username"] = kwargs.get("username")
2499
+
2500
+ if next_page is None:
2501
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/events', params=params,
2502
+ headers=headers)
2503
+ else:
2504
+ request = self.session.get(next_page, headers=headers)
2505
+
2506
+ if request.status_code == requests.codes.ok:
2507
+ xml_response = str(request.content.decode('utf-8'))
2508
+ logger.debug(xml_response)
2509
+ entity_response = xml.etree.ElementTree.fromstring(xml_response)
2510
+ events = entity_response.findall(f'.//{{{self.xip_ns}}}Event')
2511
+ result_list = []
2512
+ for event in events:
2513
+ result = {'eventType': event.attrib['type']}
2514
+ date_node = event.find(f'.//{{{self.xip_ns}}}Date')
2515
+ result['Date'] = date_node.text if hasattr(date_node, 'text') else None
2516
+ user_node = event.find(f'.//{{{self.xip_ns}}}User')
2517
+ result['User'] = user_node.text if hasattr(user_node, 'text') else None
2518
+ ref_node = event.find(f'.//{{{self.xip_ns}}}Ref')
2519
+ result['Ref'] = ref_node.text if hasattr(ref_node, 'text') else None
2520
+
2521
+ workflow_name = event.find(f'.//{{{self.xip_ns}}}WorkflowName')
2522
+ if workflow_name is not None:
2523
+ result['WorkflowName'] = workflow_name.text
2524
+
2525
+ workflow_instance_id = event.find(f'.//{{{self.xip_ns}}}WorkflowInstanceId')
2526
+ if workflow_instance_id is not None:
2527
+ result['WorkflowInstanceId'] = workflow_instance_id.text
2528
+
2529
+ serialised_command = event.find(f'.//{{{self.xip_ns}}}SerialisedCommand')
2530
+ if serialised_command is not None:
2531
+ result['SerialisedCommand'] = serialised_command.text
2532
+
2533
+ result_list.append(result)
2534
+ next_url = entity_response.find(f'.//{{{self.entity_ns}}}Next')
2535
+ total_hits = entity_response.find(f'.//{{{self.entity_ns}}}TotalResults')
2536
+ has_more = True
2537
+ url = None
2538
+ if next_url is None:
2539
+ has_more = False
2540
+ else:
2541
+ url = next_url.text
2542
+ return PagedSet(result_list, has_more, int(total_hits.text), url)
2543
+
2544
+ elif request.status_code == requests.codes.unauthorized:
2545
+ self.token = self.__token__()
2546
+ return self._all_events_page(maximum, next_page, **kwargs)
2547
+ else:
2548
+ exception = HTTPException("", request.status_code, request.url,
2549
+ "_all_events_page", request.content.decode('utf-8'))
2550
+ logger.error(exception)
2551
+ raise exception
2552
+
2553
+ def _entity_events_page(self, entity: Entity, maximum: int = 25, next_page: str = None) -> PagedSet:
2554
+ """
2555
+ event actions performed against this entity
2556
+ """
2557
+ if self.major_version < 7 and self.minor_version < 1:
2558
+ logger.error("Entity events is only available when connected to a v6.1 System")
2559
+ raise RuntimeError("Entity events is only available when connected to a v6.1 System")
2560
+
2561
+ headers = {HEADER_TOKEN: self.token}
2562
+ params = {'start': str(0), 'max': str(maximum)}
2563
+ if next_page is None:
2564
+ request = self.session.get(
2565
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/event-actions',
2566
+ params=params, headers=headers)
2567
+ else:
2568
+ request = self.session.get(next_page, headers=headers)
2569
+
2570
+ if request.status_code == requests.codes.ok:
2571
+ xml_response = str(request.content.decode('utf-8'))
2572
+ logger.debug(xml_response)
2573
+ entity_response = xml.etree.ElementTree.fromstring(xml_response)
2574
+ event_actions = entity_response.findall(f'.//{{{self.xip_ns}}}EventAction')
2575
+ result_list = []
2576
+ for event_action in event_actions:
2577
+ result = {'commandType': ''}
2578
+ if 'commandType' in event_action.attrib:
2579
+ result['commandType'] = event_action.attrib['commandType']
2580
+
2581
+ event = event_action.find(f'.//{{{self.xip_ns}}}Event')
2582
+ if 'type' in event_action.attrib:
2583
+ result['eventType'] = event.attrib['type']
2584
+
2585
+ date_node = event.find(f'.//{{{self.xip_ns}}}Date')
2586
+ if date_node is not None:
2587
+ result['Date'] = date_node.text
2588
+
2589
+ user_node = event.find(f'.//{{{self.xip_ns}}}User')
2590
+ if user_node is not None:
2591
+ result['User'] = user_node.text
2592
+
2593
+ ref_node = event.find(f'.//{{{self.xip_ns}}}Ref')
2594
+ if ref_node is not None:
2595
+ result['Ref'] = ref_node.text
2596
+
2597
+ workflow_name = event.find(f'.//{{{self.xip_ns}}}WorkflowName')
2598
+ if workflow_name is not None:
2599
+ result['WorkflowName'] = workflow_name.text
2600
+
2601
+ workflow_instance_id = event.find(f'.//{{{self.xip_ns}}}WorkflowInstanceId')
2602
+ if workflow_instance_id is not None:
2603
+ result['WorkflowInstanceId'] = workflow_instance_id.text
2604
+
2605
+ serialised_command = event_action.find(f'.//{{{self.xip_ns}}}SerialisedCommand')
2606
+ if serialised_command is not None:
2607
+ result['SerialisedCommand'] = serialised_command.text
2608
+
2609
+ result_list.append(result)
2610
+ next_url = entity_response.find(f'.//{{{self.entity_ns}}}Next')
2611
+ total_hits = entity_response.find(f'.//{{{self.entity_ns}}}TotalResults')
2612
+ has_more = True
2613
+ url = None
2614
+ if next_url is None:
2615
+ has_more = False
2616
+ else:
2617
+ url = next_url.text
2618
+ return PagedSet(result_list, has_more, int(total_hits.text), url)
2619
+ elif request.status_code == requests.codes.unauthorized:
2620
+ self.token = self.__token__()
2621
+ return self._entity_events_page(entity)
2622
+ else:
2623
+ exception = HTTPException(entity.reference, request.status_code, request.url,
2624
+ "_all_events_page", request.content.decode('utf-8'))
2625
+ logger.error(exception)
2626
+ raise exception
2627
+
2628
+ def entity_events(self, entity: Entity) -> Generator:
2629
+ """
2630
+ Returns a list of event actions performed against this entity
2631
+
2632
+ This method uses a generator function to make repeated calls to the server for every page of results.
2633
+
2634
+ :param Entity entity: The entity
2635
+ :return: A list of events
2636
+ :rtype: list
2637
+
2638
+ """
2639
+ self.token = self.__token__()
2640
+ paged_set = self._entity_events_page(entity)
2641
+ for entity in paged_set.results:
2642
+ yield entity
2643
+ while paged_set.has_more:
2644
+ paged_set = self._entity_events_page(entity, next_page=paged_set.next_page)
2645
+ for entity in paged_set.results:
2646
+ yield entity
2647
+
2648
+ def updated_entities(self, previous_days: int = 1) -> Generator:
2649
+ """
2650
+ Fetch a list of entities which have changed (been updated) over the previous n days.
2651
+
2652
+ This method uses a generator function to make repeated calls to the server for every page of results.
2653
+
2654
+ :param int previous_days: The number of days to check for changes.
2655
+ :return: A list of entities
2656
+ :rtype: list
2657
+
2658
+ """
1334
2659
 
1335
- def updated_entities(self, previous_days: int = 1):
1336
2660
  self.token = self.__token__()
1337
- maximum = 50
2661
+ maximum = 25
1338
2662
  paged_set = self._updated_entities_page(previous_days=previous_days, maximum=maximum, next_page=None)
1339
2663
  for entity in paged_set.results:
1340
2664
  yield entity
@@ -1350,7 +2674,7 @@ class EntityAPI(AuthenticatedAPI):
1350
2674
  today = x.replace(tzinfo=timezone.utc).isoformat()
1351
2675
  if next_page is None:
1352
2676
  params = {'date': today, 'start': '0', 'max': str(maximum)}
1353
- request = self.session.get(f'https://{self.server}/api/entity/entities/updated-since',
2677
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/entities/updated-since',
1354
2678
  headers=headers, params=params)
1355
2679
  else:
1356
2680
  request = self.session.get(next_page, headers=headers)
@@ -1359,18 +2683,18 @@ class EntityAPI(AuthenticatedAPI):
1359
2683
  logger.debug(xml_response)
1360
2684
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
1361
2685
  entities = entity_response.findall(f'.//{{{self.entity_ns}}}Entity')
1362
- result = list()
1363
- for e in entities:
1364
- if 'type' in e.attrib:
1365
- if e.attrib['type'] == EntityType.FOLDER.value:
1366
- f = Folder(e.attrib['ref'], e.attrib['title'], None, None, None, None)
1367
- result.append(f)
1368
- elif e.attrib['type'] == EntityType.ASSET.value:
1369
- a = Asset(e.attrib['ref'], e.attrib['title'], None, None, None, None)
1370
- result.append(a)
1371
- elif e.attrib['type'] == EntityType.CONTENT_OBJECT.value:
1372
- c = ContentObject(e.attrib['ref'], e.attrib['title'], None, None, None, None)
1373
- result.append(c)
2686
+ result = []
2687
+ for entity in entities:
2688
+ if 'type' in entity.attrib:
2689
+ if entity.attrib['type'] == EntityType.FOLDER.value:
2690
+ folder = Folder(entity.attrib['ref'], entity.attrib['title'], None, None, None, None)
2691
+ result.append(folder)
2692
+ elif entity.attrib['type'] == EntityType.ASSET.value:
2693
+ asset = Asset(entity.attrib['ref'], entity.attrib['title'], None, None, None, None)
2694
+ result.append(asset)
2695
+ elif entity.attrib['type'] == EntityType.CONTENT_OBJECT.value:
2696
+ co = ContentObject(entity.attrib['ref'], entity.attrib['title'], None, None, None, None)
2697
+ result.append(co)
1374
2698
  next_url = entity_response.find(f'.//{{{self.entity_ns}}}Next')
1375
2699
  total_hits = entity_response.find(f'.//{{{self.entity_ns}}}TotalResults')
1376
2700
  has_more = True
@@ -1379,28 +2703,48 @@ class EntityAPI(AuthenticatedAPI):
1379
2703
  has_more = False
1380
2704
  else:
1381
2705
  url = next_url.text
1382
- return PagedSet(result, has_more, total_hits.text, url)
2706
+ return PagedSet(result, has_more, int(total_hits.text), url)
1383
2707
  elif request.status_code == requests.codes.unauthorized:
1384
2708
  self.token = self.__token__()
1385
2709
  return self._updated_entities_page(previous_days=previous_days, maximum=maximum,
1386
2710
  next_page=next_page)
1387
2711
  else:
1388
- logger.error(request)
1389
- raise RuntimeError(request.status_code, "updated_entities failed")
2712
+ exception = HTTPException(previous_days, request.status_code, request.url,
2713
+ "_updated_entities_page", request.content.decode('utf-8'))
2714
+ logger.error(exception)
2715
+ raise exception
2716
+
2717
+ def delete_asset(self, asset: Asset, operator_comment: str, supervisor_comment: str, credentials_path: str = "credentials.properties"):
2718
+ """
2719
+ Initiate and approve the deletion of an asset.
1390
2720
 
1391
- def delete_asset(self, asset: Asset, operator_comment: str, supervisor_comment: str):
2721
+ :param Asset asset: The asset to delete
2722
+ :param str operator_comment: The comments from the operator which are added to the logs
2723
+ :param str supervisor_comment: The comments from the supervisor which are added to the logs
2724
+ :return: The asset reference
2725
+ :rtype: str
2726
+ """
1392
2727
  if isinstance(asset, Asset):
1393
- return self.__delete_entity__(asset, operator_comment, supervisor_comment)
2728
+ return self._delete_entity(asset, operator_comment, supervisor_comment, credentials_path)
1394
2729
  else:
1395
2730
  raise RuntimeError("delete_asset only deletes assets")
1396
2731
 
1397
- def delete_folder(self, folder: Folder, operator_comment: str, supervisor_comment: str):
2732
+ def delete_folder(self, folder: Folder, operator_comment: str, supervisor_comment: str, credentials_path: str = "credentials.properties"):
2733
+ """
2734
+ Initiate and approve the deletion of a folder.
2735
+
2736
+ :param Folder folder: The folder to delete
2737
+ :param str operator_comment: The comments from the operator which are added to the logs
2738
+ :param str supervisor_comment: The comments from the supervisor which are added to the logs
2739
+ :return: The folder reference
2740
+ :rtype: str
2741
+ """
1398
2742
  if isinstance(folder, Folder):
1399
- return self.__delete_entity__(folder, operator_comment, supervisor_comment)
2743
+ return self._delete_entity(folder, operator_comment, supervisor_comment, credentials_path)
1400
2744
  else:
1401
2745
  raise RuntimeError("delete_folder only deletes folders")
1402
2746
 
1403
- def __delete_entity__(self, entity: Entity, operator_comment: str, supervisor_comment: str):
2747
+ def _delete_entity(self, entity: Entity, operator_comment: str, supervisor_comment: str, credentials_path: str = "credentials.properties"):
1404
2748
  """
1405
2749
  Delete an asset from the repository
1406
2750
 
@@ -1411,7 +2755,7 @@ class EntityAPI(AuthenticatedAPI):
1411
2755
 
1412
2756
  # check manager password is available:
1413
2757
  config = configparser.ConfigParser()
1414
- config.read('credentials.properties')
2758
+ config.read(credentials_path, encoding='utf-8')
1415
2759
  try:
1416
2760
  manager_username = config['credentials']['manager.username']
1417
2761
  manager_password = config['credentials']['manager.password']
@@ -1419,6 +2763,8 @@ class EntityAPI(AuthenticatedAPI):
1419
2763
  except KeyError:
1420
2764
  raise RuntimeError("No manager password set in credentials.properties")
1421
2765
 
2766
+ self.token = self.__token__()
2767
+
1422
2768
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
1423
2769
  xml_object = xml.etree.ElementTree.Element('DeletionAction',
1424
2770
  {"xmlns:xip": self.xip_ns, "xmlns": self.entity_ns})
@@ -1427,17 +2773,19 @@ class EntityAPI(AuthenticatedAPI):
1427
2773
  comment_el.text = operator_comment
1428
2774
  xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
1429
2775
  logger.debug(xml_request)
1430
- request = self.session.delete(f'https://{self.server}/api/entity/{entity.path}/{entity.reference}',
2776
+ request = self.session.delete(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}',
1431
2777
  data=xml_request, headers=headers)
1432
2778
  logger.debug(request.content.decode("utf-8"))
1433
2779
  if request.status_code == requests.codes.accepted:
1434
2780
  progress = request.content.decode("utf-8")
1435
- req = self.session.get(f"https://{self.server}/api/entity/progress/{progress}", headers=headers)
2781
+ req = self.session.get(f"{self.protocol}://{self.server}/api/entity/progress/{progress}", headers=headers)
1436
2782
  while True:
1437
2783
  if req.status_code == requests.codes.ok:
1438
2784
  entity_response = xml.etree.ElementTree.fromstring(req.content.decode("utf-8"))
1439
2785
  status = entity_response.find(".//{http://status.preservica.com}Status")
1440
2786
  if hasattr(status, 'text'):
2787
+ if status.text == "COMPLETED":
2788
+ return entity.reference
1441
2789
  if status.text == "PENDING":
1442
2790
  headers = {HEADER_TOKEN: self.manager_token(manager_username, manager_password),
1443
2791
  'Content-Type': 'application/xml;charset=UTF-8'}
@@ -1448,19 +2796,30 @@ class EntityAPI(AuthenticatedAPI):
1448
2796
  xml.etree.ElementTree.SubElement(approval_el, "Comment").text = supervisor_comment
1449
2797
  xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
1450
2798
  logger.debug(xml_request)
1451
- approve = self.session.put(f"https://{self.server}/api/entity/actions/deletions/{progress}",
1452
- data=xml_request, headers=headers)
2799
+ approve = self.session.put(
2800
+ f"{self.protocol}://{self.server}/api/entity/actions/deletions/{progress}",
2801
+ data=xml_request, headers=headers)
1453
2802
  if approve.status_code == requests.codes.accepted:
1454
2803
  return entity.reference
1455
2804
  else:
2805
+ logger.error(approve.content.decode('utf-8'))
1456
2806
  raise RuntimeError(approve.status_code, "delete_asset failed during approval")
1457
2807
  sleep(2.0)
2808
+ req = self.session.get(f"{self.protocol}://{self.server}/api/entity/progress/{progress}",
2809
+ headers=headers)
1458
2810
  elif request.status_code == requests.codes.unauthorized:
1459
2811
  self.token = self.__token__()
1460
- return self.__delete_entity__(entity, operator_comment, supervisor_comment)
2812
+ return self._delete_entity(entity, operator_comment, supervisor_comment, credentials_path)
1461
2813
  if request.status_code == requests.codes.unprocessable:
2814
+ logger.error(request.content.decode('utf-8'))
1462
2815
  raise RuntimeError(request.status_code, "no active workflow context for full deletion exists in the system")
1463
2816
  if request.status_code == requests.codes.forbidden:
2817
+ logger.error(request.content.decode('utf-8'))
1464
2818
  raise RuntimeError(request.status_code, "User doesn't have deletion rights on the "
1465
2819
  "entity or the required operator role to evaluate a deletion")
1466
- raise RuntimeError(request.status_code, "delete_asset failed")
2820
+
2821
+ exception = HTTPException(entity.reference, request.status_code, request.url,
2822
+ "_delete_entity", request.content.decode('utf-8'))
2823
+ logger.error(exception)
2824
+ raise exception
2825
+