pyPreservica 2.0.3__py3-none-any.whl → 3.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pyPreservica might be problematic. Click here for more details.

pyPreservica/entityAPI.py CHANGED
@@ -8,11 +8,15 @@ author: James Carr
8
8
  licence: Apache License 2.0
9
9
 
10
10
  """
11
+
12
+ import os.path
11
13
  import uuid
12
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
- from typing import Any, Generator, Tuple, Iterable, Union
18
+ from typing import Any, Generator, Tuple, Iterable, Union, Callable
19
+
16
20
 
17
21
  from pyPreservica.common import *
18
22
 
@@ -31,8 +35,12 @@ class EntityAPI(AuthenticatedAPI):
31
35
  """
32
36
 
33
37
  def __init__(self, username: str = None, password: str = None, tenant: str = None, server: str = None,
34
- use_shared_secret: bool = False, two_fa_secret_key: str = None, protocol: str = "https"):
35
- super().__init__(username, password, tenant, server, use_shared_secret, two_fa_secret_key, protocol)
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'):
40
+
41
+ super().__init__(username, password, tenant, server, use_shared_secret, two_fa_secret_key,
42
+ protocol, request_hook, credentials_path)
43
+
36
44
  xml.etree.ElementTree.register_namespace("oai_dc", "http://www.openarchives.org/OAI/2.0/oai_dc/")
37
45
  xml.etree.ElementTree.register_namespace("ead", "urn:isbn:1-931666-22-9")
38
46
 
@@ -40,19 +48,127 @@ class EntityAPI(AuthenticatedAPI):
40
48
  """
41
49
  Return security tags available for the current user
42
50
 
51
+ :param with_permissions: Return the permissions for each security tag
52
+ :type with_permissions: bool
53
+
43
54
  :return: dict of security tags
44
55
  :rtype: dict
45
56
  """
46
57
 
47
58
  return self.security_tags_base(with_permissions=with_permissions)
48
59
 
49
- def bitstream_content(self, bitstream: Bitstream, filename: str) -> int:
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
87
+
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
91
+
92
+ Returns the byteIO
93
+ Returns None if the file does not contain the correct number of bytes (default 2k)
94
+
95
+ :param chunk_size: The buffer copy chunk size in bytes default
96
+ :param bitstream: A Bitstream object
97
+ :type bitstream: Bitstream
98
+
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
125
+
126
+ def bitstream_location(self, bitstream: Bitstream) -> list:
127
+ """"
128
+ Retrieves information about a bitstreams storage locations
129
+
130
+ :param Bitstream bitstream: The bitstream object
131
+ :return: A list of strings containing all the storage locations of this bitstream
132
+ :rtype: list
133
+
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")
138
+
139
+ storage_locations = []
140
+
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'
142
+
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
152
+
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
161
+
162
+
163
+
164
+ def bitstream_content(self, bitstream: Bitstream, filename: str, chunk_size: int = CHUNK_SIZE) -> Union[int, None]:
50
165
  """
51
166
  Download a file represented as a Bitstream to a local filename
52
167
 
53
168
  Returns the number of bytes written to the file
54
169
  Returns None if the file does not contain the correct number of bytes
55
170
 
171
+ :param chunk_size: The buffer copy chunk size in bytes default
56
172
  :param bitstream: A Bitstream object
57
173
  :type bitstream: Bitstream
58
174
 
@@ -73,7 +189,7 @@ class EntityAPI(AuthenticatedAPI):
73
189
  return self.bitstream_content(bitstream, filename)
74
190
  elif request.status_code == requests.codes.ok:
75
191
  with open(filename, 'wb') as file:
76
- for chunk in request.iter_content(chunk_size=CHUNK_SIZE):
192
+ for chunk in request.iter_content(chunk_size=chunk_size):
77
193
  file.write(chunk)
78
194
  file.flush()
79
195
  if os.path.getsize(filename) == bitstream.length:
@@ -178,8 +294,9 @@ class EntityAPI(AuthenticatedAPI):
178
294
 
179
295
  logger.debug(xml_request)
180
296
 
181
- request = self.session.post(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/exports',
182
- 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)
183
300
 
184
301
  if request.status_code == requests.codes.accepted:
185
302
  return str(request.content.decode('utf-8'))
@@ -205,7 +322,7 @@ class EntityAPI(AuthenticatedAPI):
205
322
  Initiates export of the entity and downloads the opex package
206
323
  Blocks until the package is downloaded
207
324
 
208
- By default includes content, metadata with the latest active generations
325
+ By default, includes content, metadata with the latest active generations
209
326
  and the parent hierarchy.
210
327
 
211
328
  Arguments are kwargs map
@@ -233,6 +350,15 @@ class EntityAPI(AuthenticatedAPI):
233
350
  IncludedGenerations
234
351
  IncludeParentHierarchy
235
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
+
236
362
  """
237
363
  status = "ACTIVE"
238
364
  pid = self.__export_opex_start__(entity, **kwargs)
@@ -247,12 +373,12 @@ class EntityAPI(AuthenticatedAPI):
247
373
 
248
374
  def download(self, entity: Entity, filename: str) -> str:
249
375
  """
250
- Download a file from an asset
376
+ Download the first generation of the access representation of an asset
251
377
 
252
- Returns the filename of the new file
253
-
254
- :param entity: The entity containing the file
255
- :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
256
382
  """
257
383
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
258
384
  params = {'id': f'sdb:{entity.entity_type.value}|{entity.reference}'}
@@ -299,13 +425,13 @@ class EntityAPI(AuthenticatedAPI):
299
425
 
300
426
  def thumbnail(self, entity: Entity, filename: str, size=Thumbnail.LARGE):
301
427
  """
302
- Download the thumbnail of an asset or folder
303
-
304
- Returns the filename of the new thumbnail file or None if the entity has no thumbnail
428
+ Get the thumbnail image for an asset or folder
305
429
 
306
- :param entity: The entity containing the file
307
- :param filename: The filename to write the bytes to
308
- :param size: The size of the thumbnail
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
309
435
  """
310
436
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
311
437
  params = {'id': f'sdb:{entity.entity_type.value}|{entity.reference}', 'size': f'{size.value}'}
@@ -330,21 +456,22 @@ class EntityAPI(AuthenticatedAPI):
330
456
 
331
457
  def delete_identifiers(self, entity: Entity, identifier_type: str = None, identifier_value: str = None):
332
458
  """
333
- Delete external identifiers from an entity
334
-
335
- Returns the entity
459
+ Delete identifiers on an Entity object
336
460
 
337
- :param entity: The entity to delete identifiers from
338
- :param identifier_type: The type of the identifier to delete.
339
- :param identifier_value: The value of the identifier to delete.
340
- """
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
+ """
341
467
 
342
468
  if (self.major_version < 7) and (self.minor_version < 1):
343
469
  raise RuntimeError("delete_identifiers API call is not available when connected to a v6.0 System")
344
470
 
345
471
  headers = {HEADER_TOKEN: self.token}
346
- request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
347
- headers=headers)
472
+ request = self.session.get(
473
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
474
+ headers=headers)
348
475
  if request.status_code == requests.codes.ok:
349
476
  xml_response = str(request.content.decode('utf-8'))
350
477
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
@@ -379,18 +506,66 @@ class EntityAPI(AuthenticatedAPI):
379
506
  logger.error(request)
380
507
  raise RuntimeError(request.status_code, "delete_identifier failed")
381
508
 
382
- def identifiers_for_entity(self, entity: Entity) -> set:
509
+ def entity_identifiers(self, entity: Entity, external_identifier_type = None) -> set[ExternIdentifier]:
383
510
  """
384
- Get all external identifiers on an entity
511
+ Get all external identifiers on an entity
385
512
 
386
- Returns the set of external identifiers on the entity
513
+ Returns the set of external identifiers on the entity
387
514
 
388
- :param entity: The entity
389
- :type entity: Entity
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
556
+
557
+ def identifiers_for_entity(self, entity: Entity) -> set[Tuple]:
558
+ """
559
+ Return a set of identifiers which belong to the entity
560
+
561
+ :param Entity entity: The entity
562
+ :return: Set of identifiers as tuples
563
+ :rtype: set(Tuple)
390
564
  """
391
565
  headers = {HEADER_TOKEN: self.token}
392
- request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
393
- headers=headers)
566
+ request = self.session.get(
567
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/identifiers',
568
+ headers=headers)
394
569
  if request.status_code == requests.codes.ok:
395
570
  xml_response = str(request.content.decode('utf-8'))
396
571
  logger.debug(xml_response)
@@ -415,14 +590,14 @@ class EntityAPI(AuthenticatedAPI):
415
590
  logger.error(exception)
416
591
  raise exception
417
592
 
418
- def identifier(self, identifier_type: str, identifier_value: str) -> set:
593
+ def identifier(self, identifier_type: str, identifier_value: str) -> set[EntityT]:
419
594
  """
420
- Get all entities which have the external identifier
421
-
422
- 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
423
596
 
424
- :param identifier_type: The identifier type
425
- :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)
426
601
  """
427
602
  headers = {HEADER_TOKEN: self.token}
428
603
  payload = {'type': identifier_type, 'value': identifier_value}
@@ -456,14 +631,14 @@ class EntityAPI(AuthenticatedAPI):
456
631
 
457
632
  def add_identifier(self, entity: Entity, identifier_type: str, identifier_value: str):
458
633
  """
459
- Add a new identifier to an entity
460
-
461
- Returns the internal identifier DB key
634
+ Add a new external identifier to an Entity object
462
635
 
463
- :param entity: The Entity
464
- :param identifier_type: The identifier type
465
- :param identifier_value: The identifier value
466
- """
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
+ """
467
642
 
468
643
  if self.major_version < 7 and self.minor_version < 1:
469
644
  raise RuntimeError("add_identifier API call is not available when connected to a v6.0 System")
@@ -477,7 +652,8 @@ class EntityAPI(AuthenticatedAPI):
477
652
  end_point = f"/{entity.path}/{entity.reference}/identifiers"
478
653
  xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
479
654
  logger.debug(xml_request)
480
- request = self.session.post(f'{self.protocol}://{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)
481
657
  if request.status_code == requests.codes.ok:
482
658
  xml_string = str(request.content.decode("utf-8"))
483
659
  identifier_response = xml.etree.ElementTree.fromstring(xml_string)
@@ -495,6 +671,76 @@ class EntityAPI(AuthenticatedAPI):
495
671
  logger.error(exception)
496
672
  raise exception
497
673
 
674
+ def update_identifiers(self, entity: Entity, identifier_type: str = None, identifier_value: str = None):
675
+ """
676
+ Update external identifiers based on Entity and Type
677
+
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
+ """
684
+
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
+
498
744
  def delete_relationships(self, entity: Entity, relationship_type: str = None):
499
745
  """
500
746
  Delete a relationship between two entities by its internal id
@@ -534,7 +780,7 @@ class EntityAPI(AuthenticatedAPI):
534
780
  end_point = f"{entity.path}/{entity.reference}/links/{relationship.api_id}"
535
781
  request = self.session.delete(f'{self.protocol}://{self.server}/api/entity/{end_point}', headers=headers)
536
782
  if request.status_code == requests.codes.no_content:
537
- print(relationship)
783
+ return None
538
784
  elif request.status_code == requests.codes.unauthorized:
539
785
  self.token = self.__token__()
540
786
  return self.__delete_relationship(relationship)
@@ -544,7 +790,7 @@ class EntityAPI(AuthenticatedAPI):
544
790
  logger.error(exception)
545
791
  raise exception
546
792
 
547
- def relationships(self, entity: Entity, page_size: int = 25) -> Generator:
793
+ def relationships(self, entity: Entity, page_size: int = 25) -> Generator[Relationship, None, None]:
548
794
  """
549
795
  List the relationship links between entities
550
796
 
@@ -553,7 +799,7 @@ class EntityAPI(AuthenticatedAPI):
553
799
  :type: page_size: int
554
800
 
555
801
  :param entity: The Source Entity
556
- :type: entity: Entity
802
+ :type: entity: An Entity type such as Asset, Folder etc
557
803
 
558
804
  :return: Generator
559
805
  :rtype: Relationship
@@ -589,7 +835,8 @@ class EntityAPI(AuthenticatedAPI):
589
835
 
590
836
  if next_page is None:
591
837
  params = {'start': '0', 'max': str(maximum)}
592
- request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{end_point}', headers=headers, params=params)
838
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{end_point}', headers=headers,
839
+ params=params)
593
840
  else:
594
841
  request = self.session.get(next_page, headers=headers)
595
842
 
@@ -620,7 +867,7 @@ class EntityAPI(AuthenticatedAPI):
620
867
 
621
868
  return PagedSet(results, has_more, int(total_hits.text), url)
622
869
  elif request.status_code == requests.codes.unauthorized:
623
- self.__relationships__(entity=entity, maximum=maximum, next_page=next_page)
870
+ return self.__relationships__(entity=entity, maximum=maximum, next_page=next_page)
624
871
  else:
625
872
  exception = HTTPException(entity.reference, request.status_code, request.url, "relationships",
626
873
  request.content.decode('utf-8'))
@@ -631,10 +878,10 @@ class EntityAPI(AuthenticatedAPI):
631
878
  """
632
879
  Add a new relationship link between two Assets or Folders
633
880
 
634
- :param from_entity: The Source Entity
881
+ :param from_entity: The Source entity to link from
635
882
  :type from_entity: Entity
636
883
 
637
- :param to_entity: The Target Entity
884
+ :param to_entity: The Target entity
638
885
  :type to_entity: Entity
639
886
 
640
887
  :param relationship_type: The Relationship type
@@ -660,7 +907,8 @@ class EntityAPI(AuthenticatedAPI):
660
907
  end_point = f"/{from_entity.path}/{from_entity.reference}/links"
661
908
  xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
662
909
  logger.debug(xml_request)
663
- request = self.session.post(f'{self.protocol}://{self.server}/api/entity{end_point}', data=xml_request, headers=headers)
910
+ request = self.session.post(f'{self.protocol}://{self.server}/api/entity{end_point}', data=xml_request,
911
+ headers=headers)
664
912
  if request.status_code == requests.codes.ok:
665
913
  xml_string = str(request.content.decode("utf-8"))
666
914
  logger.debug(xml_string)
@@ -677,15 +925,17 @@ class EntityAPI(AuthenticatedAPI):
677
925
  logger.error(exception)
678
926
  raise exception
679
927
 
680
- def delete_metadata(self, entity: Entity, schema: str) -> Entity:
928
+ def delete_metadata(self, entity: EntityT, schema: str) -> EntityT:
681
929
  """
682
- Deletes all the metadata fragments on an entity which match the schema URI
683
-
684
- Returns The updated Entity
930
+ Delete an existing descriptive XML document on an entity by its schema
931
+ This call will delete all fragments with the same schema
685
932
 
686
- :param entity: The Entity to delete metadata from
687
- :param schema: The schema URI to match against
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
688
937
  """
938
+
689
939
  headers = {HEADER_TOKEN: self.token}
690
940
  for url in entity.metadata:
691
941
  if schema == entity.metadata[url]:
@@ -703,15 +953,45 @@ class EntityAPI(AuthenticatedAPI):
703
953
 
704
954
  return self.entity(entity.entity_type, entity.reference)
705
955
 
706
- def update_metadata(self, entity: Entity, schema: str, data: Any) -> Entity:
956
+
957
+ def add_group_metadata(self, csv_file: str) -> str:
707
958
  """
708
- 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
709
963
 
710
- Returns The updated Entity
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'}
711
969
 
712
- :param data: The updated XML as a string or as IO bytes
713
- :param entity: The Entity to update
714
- :param schema: The schema URI to match against
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
984
+
985
+
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
715
995
  """
716
996
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
717
997
 
@@ -721,11 +1001,11 @@ class EntityAPI(AuthenticatedAPI):
721
1001
  for url in entity.metadata:
722
1002
  if schema == entity.metadata[url]:
723
1003
  mref = url[url.rfind(f"{entity.reference}/metadata/") + len(f"{entity.reference}/metadata/"):]
724
- xml_object = xml.etree.ElementTree.Element('MetadataContainer',
725
- {"schemaUri": schema, "xmlns": self.xip_ns})
726
- xml.etree.ElementTree.SubElement(xml_object, "Ref").text = mref
727
- xml.etree.ElementTree.SubElement(xml_object, "Entity").text = entity.reference
728
- 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")
729
1009
  if isinstance(data, str):
730
1010
  ob = xml.etree.ElementTree.fromstring(data)
731
1011
  content.append(ob)
@@ -734,7 +1014,7 @@ class EntityAPI(AuthenticatedAPI):
734
1014
  content.append(tree.getroot())
735
1015
  else:
736
1016
  raise RuntimeError("Unknown data type")
737
- 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")
738
1018
  logger.debug(xml_request)
739
1019
  request = self.session.put(url, data=xml_request, headers=headers)
740
1020
  if request.status_code == requests.codes.ok:
@@ -749,21 +1029,58 @@ class EntityAPI(AuthenticatedAPI):
749
1029
  raise exception
750
1030
  return self.entity(entity.entity_type, entity.reference)
751
1031
 
752
- 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:
753
1033
  """
754
- 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
755
1036
 
756
1037
  Returns The updated Entity
757
1038
 
758
- :param data: The new XML as a string or as IO bytes
759
- :param entity: The Entity to update
760
- :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
761
1043
  """
762
1044
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
763
1045
 
764
- xml_object = xml.etree.ElementTree.Element('MetadataContainer', {"schemaUri": schema, "xmlns": self.xip_ns})
765
- xml.etree.ElementTree.SubElement(xml_object, "Entity").text = entity.reference
766
- 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")
767
1084
  if isinstance(data, str):
768
1085
  ob = xml.etree.ElementTree.fromstring(data)
769
1086
  content.append(ob)
@@ -775,7 +1092,8 @@ class EntityAPI(AuthenticatedAPI):
775
1092
  xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
776
1093
  end_point = f"/{entity.path}/{entity.reference}/metadata"
777
1094
  logger.debug(xml_request)
778
- request = self.session.post(f'{self.protocol}://{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)
779
1097
  if request.status_code == requests.codes.ok:
780
1098
  return self.entity(entity_type=entity.entity_type, reference=entity.reference)
781
1099
  elif request.status_code == requests.codes.unauthorized:
@@ -787,14 +1105,15 @@ class EntityAPI(AuthenticatedAPI):
787
1105
  logger.error(exception)
788
1106
  raise exception
789
1107
 
790
- def save(self, entity: Entity) -> Entity:
1108
+ def save(self, entity: EntityT) -> EntityT:
791
1109
  """
792
- 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
793
1112
 
794
- Returns The updated Entity
795
-
796
- :param custom_type:
797
- :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
798
1117
  """
799
1118
 
800
1119
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
@@ -840,6 +1159,7 @@ class EntityAPI(AuthenticatedAPI):
840
1159
  if 'CustomType' in response:
841
1160
  content_object.custom_type = response['CustomType']
842
1161
  return content_object
1162
+ return None
843
1163
  elif request.status_code == requests.codes.unauthorized:
844
1164
  self.token = self.__token__()
845
1165
  return self.save(entity)
@@ -859,8 +1179,10 @@ class EntityAPI(AuthenticatedAPI):
859
1179
 
860
1180
  Returns The updated Entity
861
1181
 
862
- :param entity: The Entity to update
863
- :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
864
1186
  """
865
1187
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
866
1188
  if isinstance(entity, Asset) and dest_folder is None:
@@ -869,8 +1191,9 @@ class EntityAPI(AuthenticatedAPI):
869
1191
  data = dest_folder.reference
870
1192
  else:
871
1193
  data = "@root@"
872
- request = self.session.put(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/parent-ref',
873
- 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)
874
1197
  if request.status_code == requests.codes.accepted:
875
1198
  return request.content.decode()
876
1199
  elif request.status_code == requests.codes.unauthorized:
@@ -882,7 +1205,18 @@ class EntityAPI(AuthenticatedAPI):
882
1205
  logger.error(exception)
883
1206
  raise exception
884
1207
 
1208
+ def get_progress(self, pid: str) -> AsyncProgress:
1209
+ return AsyncProgress[self.get_async_progress(pid)]
1210
+
885
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
+ """
886
1220
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
887
1221
  request = self.session.get(f"{self.protocol}://{self.server}/api/entity/progress/{pid}", headers=headers)
888
1222
  if request.status_code == requests.codes.ok:
@@ -901,16 +1235,15 @@ class EntityAPI(AuthenticatedAPI):
901
1235
  logger.error(exception)
902
1236
  raise exception
903
1237
 
904
- def move_sync(self, entity: Entity, dest_folder: Folder) -> Entity:
1238
+ def move_sync(self, entity: EntityT, dest_folder: Folder) -> EntityT:
905
1239
  """
906
- Move an Entity (Asset or Folder) to a new Folder
907
- If dest_folder is None then the entity must be a Folder and will be moved to the root of the repository
908
-
909
- Returns The updated Entity.
910
- Blocks until the move is complete.
1240
+ Move an entity (asset or folder) to a new folder
1241
+ This call blocks until the move is complete
911
1242
 
912
- :param entity: The Entity to update
913
- :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
914
1247
  """
915
1248
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
916
1249
  if isinstance(entity, Asset) and dest_folder is None:
@@ -919,8 +1252,9 @@ class EntityAPI(AuthenticatedAPI):
919
1252
  data = dest_folder.reference
920
1253
  else:
921
1254
  data = "@root@"
922
- request = self.session.put(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/parent-ref',
923
- 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)
924
1258
  if request.status_code == requests.codes.accepted:
925
1259
  sleep_sec = 1
926
1260
  while True:
@@ -940,15 +1274,15 @@ class EntityAPI(AuthenticatedAPI):
940
1274
  logger.error(exception)
941
1275
  raise exception
942
1276
 
943
- def move(self, entity: Entity, dest_folder: Folder) -> Entity:
1277
+ def move(self, entity: EntityT, dest_folder: Folder) -> EntityT:
944
1278
  """
945
- Move an Entity (Asset or Folder) to a new Folder
946
- If dest_folder is None then the entity must be a Folder and will be moved to the root of the repository
947
-
948
- Returns The updated Entity
1279
+ Move an entity (asset or folder) to a new folder
1280
+ This call is an alias for the move_sync (blocking) method.
949
1281
 
950
- :param entity: The Entity to update
951
- :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
952
1286
  """
953
1287
  return self.move_sync(entity, dest_folder)
954
1288
 
@@ -994,26 +1328,28 @@ class EntityAPI(AuthenticatedAPI):
994
1328
  logger.error(exception)
995
1329
  raise exception
996
1330
 
997
- def all_metadata(self, entity: Entity) -> Tuple:
1331
+ def all_metadata(self, entity: Entity) -> Generator[Tuple[str, str], None, None]:
998
1332
  """
999
1333
  Retrieve all metadata fragments on an entity
1000
1334
 
1001
1335
  Returns XML documents in a tuple
1002
1336
 
1003
- :param entity: The entity with the metadata
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]]
1004
1340
  """
1005
1341
 
1006
1342
  for uri, schema in entity.metadata.items():
1007
1343
  yield tuple((str(schema), self.metadata(uri)))
1008
1344
 
1009
- def metadata_for_entity(self, entity: Entity, schema: str) -> str:
1345
+ def metadata_for_entity(self, entity: Entity, schema: str) -> Union[str, None]:
1010
1346
  """
1011
- Retrieve the first metadata fragment on an entity with a matching schema URI
1012
-
1013
- Returns XML document as a string
1347
+ Fetch the first metadata document which matches the schema URI from an entity
1014
1348
 
1015
- :param entity: The entity with the metadata
1016
- :param schema: The schema URI
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
1017
1353
  """
1018
1354
 
1019
1355
  # if the entity is a lightweight enum version request the full object
@@ -1025,7 +1361,7 @@ class EntityAPI(AuthenticatedAPI):
1025
1361
  return self.metadata(uri)
1026
1362
  return None
1027
1363
 
1028
- def metadata_tag_for_entity(self, entity: Entity, schema: str, tag: str, isXpath: bool = False) -> str:
1364
+ def metadata_tag_for_entity(self, entity: Entity, schema: str, tag: str, isXpath: bool = False) -> Union[str, None]:
1029
1365
  """
1030
1366
  Retrieve the first value of the tag from a metadata template given by schema
1031
1367
 
@@ -1038,20 +1374,25 @@ class EntityAPI(AuthenticatedAPI):
1038
1374
  """
1039
1375
 
1040
1376
  xml_doc = self.metadata_for_entity(entity, schema)
1041
- xml_object = xml.etree.ElementTree.fromstring(xml_doc)
1042
- if isXpath is False:
1043
- return xml_object.find(f'.//{{*}}{tag}').text
1044
- else:
1045
- return xml_object.find(tag).text
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
1383
+ return None
1046
1384
 
1047
- def security_tag_sync(self, entity: Entity, new_tag: str):
1385
+ def security_tag_sync(self, entity: EntityT, new_tag: str) -> EntityT:
1048
1386
  """
1049
- Change the security tag for a folder or asset
1050
-
1051
- Returns the updated entity after the security tag has been updated.
1387
+ Change the security tag of an asset or folder
1388
+ This is a blocking call which returns after all entities have been updated.
1052
1389
 
1053
- :param entity: The entity to change
1054
- :param new_tag: The new security tag
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
1055
1396
  """
1056
1397
  self.token = self.__token__()
1057
1398
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
@@ -1078,12 +1419,15 @@ class EntityAPI(AuthenticatedAPI):
1078
1419
 
1079
1420
  def security_tag_async(self, entity: Entity, new_tag: str):
1080
1421
  """
1081
- Change the security tag for a folder or asset
1422
+ Change the security tag of an asset or folder
1423
+ This is a non blocking call which returns immediately.
1082
1424
 
1083
- Returns a process ID asynchronous (without blocking)
1084
-
1085
- :param entity: The entity to change
1086
- :param new_tag: The new security tag
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
1087
1431
  """
1088
1432
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
1089
1433
  end_point = f"/{entity.path}/{entity.reference}/security-descriptor"
@@ -1102,11 +1446,11 @@ class EntityAPI(AuthenticatedAPI):
1102
1446
 
1103
1447
  def metadata(self, uri: str) -> str:
1104
1448
  """
1105
- Retrieve the metadata fragment which is referenced by the URI
1449
+ Fetch the metadata document by its identifier, this is the key from the entity metadata map
1106
1450
 
1107
- Returns XML document as a string
1108
-
1109
- :param uri: The endpoint of the metadata fragment
1451
+ :param str uri: The metadata identifier
1452
+ :return: An XML document as a string
1453
+ :rtype: str
1110
1454
  """
1111
1455
  request = self.session.get(uri, headers={HEADER_TOKEN: self.token})
1112
1456
  if request.status_code == requests.codes.ok:
@@ -1124,14 +1468,17 @@ class EntityAPI(AuthenticatedAPI):
1124
1468
  logger.error(exception)
1125
1469
  raise exception
1126
1470
 
1127
- def entity(self, entity_type: EntityType, reference: str) -> Entity:
1471
+ def entity(self, entity_type: EntityType, reference: str) -> EntityT:
1128
1472
  """
1129
- Retrieve an entity by its type and reference
1473
+ Returns a generic entity based on its reference identifier
1130
1474
 
1131
- Returns Entity (Asset, Folder, ContentObject)
1132
-
1133
- :param entity_type: The type of entity to fetch
1134
- :param reference: The unique identifier of the entity
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
1135
1482
  """
1136
1483
  if entity_type is EntityType.CONTENT_OBJECT:
1137
1484
  return self.content_object(reference)
@@ -1139,6 +1486,7 @@ class EntityAPI(AuthenticatedAPI):
1139
1486
  return self.folder(reference)
1140
1487
  if entity_type is EntityType.ASSET:
1141
1488
  return self.asset(reference)
1489
+ return None
1142
1490
 
1143
1491
  def add_physical_asset(self, title: str, description: str, parent: Folder, security_tag: str = "open") -> Asset:
1144
1492
  """
@@ -1146,10 +1494,12 @@ class EntityAPI(AuthenticatedAPI):
1146
1494
 
1147
1495
  Returns Asset
1148
1496
 
1149
- :param title: The title of the new Asset
1150
- :param description: The description of the new Asset
1151
- :param parent: The parent folder
1152
- :param security_tag: The security setting
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
1153
1503
  """
1154
1504
 
1155
1505
  if (self.major_version < 7) and (self.minor_version < 4):
@@ -1169,7 +1519,8 @@ class EntityAPI(AuthenticatedAPI):
1169
1519
 
1170
1520
  xml_request = xml.etree.ElementTree.tostring(xip_object, encoding='utf-8')
1171
1521
 
1172
- request = self.session.post(f'{self.protocol}://{self.server}/api/entity/{IO_PATH}', data=xml_request, headers=headers)
1522
+ request = self.session.post(f'{self.protocol}://{self.server}/api/entity/{IO_PATH}', data=xml_request,
1523
+ headers=headers)
1173
1524
  if request.status_code == requests.codes.ok:
1174
1525
  xml_string = str(request.content.decode("utf-8"))
1175
1526
  entity = self.entity_from_string(xml_string)
@@ -1185,15 +1536,134 @@ class EntityAPI(AuthenticatedAPI):
1185
1536
  logger.error(exception)
1186
1537
  raise exception
1187
1538
 
1188
- def asset(self, reference: str) -> Asset:
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')
1611
+ elif request.status_code == requests.codes.unauthorized:
1612
+ self.token = self.__token__()
1613
+ return self.merge_folder(folder)
1614
+ else:
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:
1189
1627
  """
1190
1628
  Retrieve an Asset by its reference
1191
1629
 
1192
- Returns Asset
1630
+ Returns an XML document of the full Asset
1193
1631
 
1194
1632
  :param reference: The unique identifier of the entity
1195
1633
  """
1196
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)
1637
+ if request.status_code == requests.codes.ok:
1638
+ xml_response = str(request.content.decode('utf-8'))
1639
+ return xml_response
1640
+ elif request.status_code == requests.codes.unauthorized:
1641
+ self.token = self.__token__()
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
1647
+ else:
1648
+ exception = HTTPException(reference, request.status_code, request.url, "xml_asset",
1649
+ request.content.decode('utf-8'))
1650
+ logger.error(exception)
1651
+ raise exception
1652
+
1653
+
1654
+ def asset(self, reference: str) -> Asset:
1655
+
1656
+ """
1657
+ Returns an asset object back by its internal reference identifier
1658
+
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
1664
+
1665
+ """
1666
+ headers = {HEADER_TOKEN: self.token}
1197
1667
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{IO_PATH}/{reference}', headers=headers)
1198
1668
  if request.status_code == requests.codes.ok:
1199
1669
  xml_response = str(request.content.decode('utf-8'))
@@ -1219,11 +1689,13 @@ class EntityAPI(AuthenticatedAPI):
1219
1689
 
1220
1690
  def folder(self, reference: str) -> Folder:
1221
1691
  """
1222
- Retrieve a Folder by its reference
1223
-
1224
- Returns Folder
1692
+ Returns a folder object back by its internal reference identifier
1225
1693
 
1226
- :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
1227
1699
  """
1228
1700
  headers = {HEADER_TOKEN: self.token}
1229
1701
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{SO_PATH}/{reference}', headers=headers)
@@ -1251,11 +1723,13 @@ class EntityAPI(AuthenticatedAPI):
1251
1723
 
1252
1724
  def content_object(self, reference: str) -> ContentObject:
1253
1725
  """
1254
- Retrieve an ContentObject by its reference
1726
+ Returns a content object back by its internal reference identifier
1255
1727
 
1256
- Returns ContentObject
1257
-
1258
- :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
1259
1733
  """
1260
1734
  headers = {HEADER_TOKEN: self.token}
1261
1735
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{CO_PATH}/{reference}', headers=headers)
@@ -1281,17 +1755,20 @@ class EntityAPI(AuthenticatedAPI):
1281
1755
  logger.error(exception)
1282
1756
  raise exception
1283
1757
 
1284
- def content_objects(self, representation: Representation) -> list:
1758
+ def content_objects(self, representation: Representation) -> list[ContentObject]:
1285
1759
  """
1286
- Retrieve a list of content objects within a representation
1760
+ Return a list of content objects for a representation
1761
+
1762
+ :param representation: The representation
1763
+ :type representation: Representation
1764
+ :return: List of content objects
1765
+ :rtype: list(ContentObject)
1287
1766
 
1288
- :param representation:
1289
- :returns list[ContentObject]
1290
1767
  """
1291
1768
  headers = {HEADER_TOKEN: self.token}
1292
1769
  if not isinstance(representation, Representation):
1293
1770
  logger.warning("representation is not of type Representation")
1294
- return None
1771
+ return []
1295
1772
  request = self.session.get(f'{representation.url}', headers=headers)
1296
1773
  if request.status_code == requests.codes.ok:
1297
1774
  results = []
@@ -1315,13 +1792,17 @@ class EntityAPI(AuthenticatedAPI):
1315
1792
  logger.error(exception)
1316
1793
  raise exception
1317
1794
 
1318
- def generation(self, url: str) -> Generation:
1795
+ def generation(self, url: str, content_ref: str = None) -> Generation:
1796
+ """
1797
+ Retrieve a list of generation objects
1798
+
1799
+ :param url:
1800
+ :param content_ref:
1801
+
1802
+ :return: Generation
1803
+ :rtype: Generation
1319
1804
  """
1320
- Retrieve a list of generation objects
1321
1805
 
1322
- :param url:
1323
- :returns Generation
1324
- """
1325
1806
  headers = {HEADER_TOKEN: self.token}
1326
1807
  request = self.session.get(url, headers=headers)
1327
1808
  if request.status_code == requests.codes.ok:
@@ -1331,14 +1812,53 @@ class EntityAPI(AuthenticatedAPI):
1331
1812
  ge = entity_response.find(f'.//{{{self.xip_ns}}}Generation')
1332
1813
  format_group = entity_response.find(f'.//{{{self.xip_ns}}}FormatGroup')
1333
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
+
1334
1846
  bitstreams = entity_response.findall(f'./{{{self.entity_ns}}}Bitstreams/{{{self.entity_ns}}}Bitstream')
1335
1847
  bitstream_list = []
1336
1848
  for bit in bitstreams:
1337
- bitstream_list.append(self.bitstream(bit.text))
1338
- return Generation(strtobool(ge.attrib['original']), strtobool(ge.attrib['active']),
1339
- format_group.text if hasattr(format_group, 'text') else None,
1340
- effective_date.text if hasattr(effective_date, 'text') else None,
1341
- 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
1342
1862
  elif request.status_code == requests.codes.unauthorized:
1343
1863
  self.token = self.__token__()
1344
1864
  return self.generation(url)
@@ -1415,11 +1935,12 @@ class EntityAPI(AuthenticatedAPI):
1415
1935
 
1416
1936
  def bitstream(self, url: str) -> Bitstream:
1417
1937
  """
1418
- Retrieve a bitstream by its url
1419
-
1420
- Returns Bitstream
1938
+ Fetch a bitstream object from the server using its URL
1421
1939
 
1422
- :param url:
1940
+ :param url: The URL to the bitstream
1941
+ :type url: str
1942
+ :return: a bitstream object
1943
+ :rtype: Bitstream
1423
1944
  """
1424
1945
  headers = {HEADER_TOKEN: self.token}
1425
1946
  request = self.session.get(url, headers=headers)
@@ -1431,12 +1952,17 @@ class EntityAPI(AuthenticatedAPI):
1431
1952
  filesize = entity_response.find(f'.//{{{self.xip_ns}}}FileSize')
1432
1953
  fixity_values = entity_response.findall(f'.//{{{self.xip_ns}}}Fixity')
1433
1954
  content = entity_response.find(f'.//{{{self.entity_ns}}}Content')
1955
+
1956
+ index = int(url.rsplit("/", 1)[-1])
1957
+
1434
1958
  fixity = {}
1435
1959
  for f in fixity_values:
1436
1960
  fixity[f[0].text] = f[1].text
1437
1961
  bitstream = Bitstream(filename.text if hasattr(filename, 'text') else None,
1438
1962
  int(filesize.text) if hasattr(filesize, 'text') else None, fixity,
1439
1963
  content.text if hasattr(content, 'text') else None)
1964
+
1965
+ bitstream.bs_index = index
1440
1966
  return bitstream
1441
1967
  elif request.status_code == requests.codes.unauthorized:
1442
1968
  self.token = self.__token__()
@@ -1450,9 +1976,16 @@ class EntityAPI(AuthenticatedAPI):
1450
1976
  def replace_generation_sync(self, content_object: ContentObject, file_name, fixity_algorithm=None,
1451
1977
  fixity_value=None) -> str:
1452
1978
  """
1453
- 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.
1980
+
1981
+ Starts the workflow and blocks until the workflow completes.
1454
1982
 
1455
- Starts the workflow and blocks until the workflow completes.
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
1456
1989
 
1457
1990
  """
1458
1991
 
@@ -1471,7 +2004,14 @@ class EntityAPI(AuthenticatedAPI):
1471
2004
  """
1472
2005
  Replace the last active generation of a content object with a new digital file.
1473
2006
 
1474
- 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
1475
2015
 
1476
2016
  """
1477
2017
  if (self.major_version < 7) and (self.minor_version < 2) and (self.patch_version < 1):
@@ -1485,7 +2025,14 @@ class EntityAPI(AuthenticatedAPI):
1485
2025
  bitstream = generation.bitstreams.pop()
1486
2026
  for algo, value in bitstream.fixity.items():
1487
2027
  fixity_algorithm = algo
1488
- 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)
1489
2036
 
1490
2037
  if fixity_algorithm and fixity_value:
1491
2038
  if "MD5" in fixity_algorithm.upper():
@@ -1516,17 +2063,19 @@ class EntityAPI(AuthenticatedAPI):
1516
2063
  logger.error(exception)
1517
2064
  raise exception
1518
2065
 
1519
- def generations(self, content_object: ContentObject) -> list:
2066
+ def generations(self, content_object: ContentObject) -> list[Generation]:
1520
2067
  """
1521
- Retrieve list of generations on a content object
2068
+ Return a list of Generation objects for a content object
1522
2069
 
1523
- Returns list
1524
-
1525
- :param content_object:
2070
+ :param content_object: The content object
2071
+ :type content_object: ContentObject
2072
+ :return: list of generations
2073
+ :rtype: list(Generation)
1526
2074
  """
1527
2075
  headers = {HEADER_TOKEN: self.token}
1528
2076
  request = self.session.get(
1529
- f'{self.protocol}://{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)
1530
2079
  if request.status_code == requests.codes.ok:
1531
2080
  xml_response = str(request.content.decode('utf-8'))
1532
2081
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
@@ -1534,7 +2083,7 @@ class EntityAPI(AuthenticatedAPI):
1534
2083
  result = []
1535
2084
  for g in generations:
1536
2085
  if hasattr(g, 'text'):
1537
- generation = self.generation(g.text)
2086
+ generation = self.generation(g.text, content_object.reference)
1538
2087
  generation.asset = content_object.asset
1539
2088
  generation.content_object = content_object
1540
2089
  generation.representation_type = content_object.representation_type
@@ -1549,15 +2098,13 @@ class EntityAPI(AuthenticatedAPI):
1549
2098
  logger.error(exception)
1550
2099
  raise exception
1551
2100
 
1552
- def bitstreams_for_asset(self, asset: Asset) -> Iterable:
2101
+ def bitstreams_for_asset(self, asset: Union[Asset, Entity]) -> Iterable[Bitstream]:
1553
2102
  """
1554
-
1555
- Return all the bitstreams within an asset.
2103
+ Return all the active bitstreams within an asset.
1556
2104
  This includes all the representations and content objects
1557
2105
 
1558
-
1559
2106
  :param asset: The asset
1560
- :return:
2107
+ :return: Iterable
1561
2108
  """
1562
2109
 
1563
2110
  for representation in self.representations(asset):
@@ -1570,18 +2117,23 @@ class EntityAPI(AuthenticatedAPI):
1570
2117
  bitstream.generation = generation
1571
2118
  yield bitstream
1572
2119
 
1573
- def representations(self, asset: Asset) -> set:
2120
+ def representations(self, asset: Asset) -> set[Representation]:
1574
2121
  """
1575
- Retrieve set of representations on an Asset
2122
+ Return a set of representations for the asset
2123
+
2124
+ Representations are used to define how the information object are composed in terms of technology and structure.
1576
2125
 
1577
- :param asset: The asset
1578
- :returns set[Representation]
2126
+ :param asset: The asset containing the required representations
2127
+ :type asset: Asset
2128
+ :return: Set of Representation objects
2129
+ :rtype: set(Representation)
1579
2130
  """
1580
2131
  headers = {HEADER_TOKEN: self.token}
1581
2132
  if not isinstance(asset, Asset):
1582
- return None
1583
- request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{asset.path}/{asset.reference}/representations',
1584
- 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)
1585
2137
  if request.status_code == requests.codes.ok:
1586
2138
  xml_response = str(request.content.decode('utf-8'))
1587
2139
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
@@ -1602,11 +2154,11 @@ class EntityAPI(AuthenticatedAPI):
1602
2154
 
1603
2155
  def remove_thumbnail(self, entity: Entity):
1604
2156
  """
1605
- remove a thumbnail icon to a folder or asset
1606
-
2157
+ Remove the thumbnail for the entity to the uploaded image
1607
2158
 
1608
- :param entity: The Entity
1609
- """
2159
+ :param entity: The entity with the thumbnail
2160
+ :type entity: Entity
2161
+ """
1610
2162
  if self.major_version < 7 and self.minor_version < 2:
1611
2163
  raise RuntimeError("Thumbnail API is only available when connected to a v6.2 System")
1612
2164
 
@@ -1615,8 +2167,9 @@ class EntityAPI(AuthenticatedAPI):
1615
2167
 
1616
2168
  headers = {HEADER_TOKEN: self.token}
1617
2169
 
1618
- request = self.session.delete(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/preview',
1619
- headers=headers)
2170
+ request = self.session.delete(
2171
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/preview',
2172
+ headers=headers)
1620
2173
  if request.status_code == requests.codes.no_content:
1621
2174
  return str(request.content.decode('utf-8'))
1622
2175
  elif request.status_code == requests.codes.unauthorized:
@@ -1628,25 +2181,66 @@ class EntityAPI(AuthenticatedAPI):
1628
2181
  logger.error(exception)
1629
2182
  raise exception
1630
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
2221
+
1631
2222
  def add_thumbnail(self, entity: Entity, image_file: str):
1632
2223
  """
1633
- add a thumbnail icon to a folder or asset
2224
+ Set the thumbnail for the entity to the uploaded image
1634
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
+ """
1635
2231
 
1636
- :param entity: The Entity
1637
- :param image_file: Path to image file
1638
- """
1639
2232
  if self.major_version < 7 and self.minor_version < 2:
1640
2233
  raise RuntimeError("Thumbnail API is only available when connected to a v6.2 System")
1641
2234
 
1642
2235
  if isinstance(entity, ContentObject):
1643
2236
  raise RuntimeError("Thumbnails cannot be added to Content Objects")
1644
2237
 
1645
- headers = {HEADER_TOKEN: self.token} # , 'Content-Type': 'application/octet-stream'}
2238
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
1646
2239
 
1647
2240
  with open(image_file, 'rb') as fd:
1648
- request = self.session.put(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/preview',
1649
- data=fd, headers=headers)
2241
+ request = self.session.put(
2242
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/preview',
2243
+ data=fd, headers=headers)
1650
2244
 
1651
2245
  if request.status_code == requests.codes.no_content:
1652
2246
  return str(request.content.decode('utf-8'))
@@ -1670,11 +2264,12 @@ class EntityAPI(AuthenticatedAPI):
1670
2264
  headers = {HEADER_TOKEN: self.token}
1671
2265
  params = {'start': str(0), 'max': str(maximum)}
1672
2266
 
1673
- request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/event-actions',
1674
- params=params, headers=headers)
2267
+ request = self.session.get(
2268
+ f'{self.protocol}://{self.server}/api/entity/{entity.path}/{entity.reference}/event-actions',
2269
+ params=params, headers=headers)
1675
2270
 
1676
2271
  if request.status_code == requests.codes.ok:
1677
- pass
2272
+ return None
1678
2273
  elif request.status_code == requests.codes.unauthorized:
1679
2274
  self.token = self.__token__()
1680
2275
  return self._event_actions(entity, maximum=maximum)
@@ -1684,20 +2279,34 @@ class EntityAPI(AuthenticatedAPI):
1684
2279
  logger.error(exception)
1685
2280
  raise exception
1686
2281
 
1687
- def all_descendants(self, folder: Folder = None) -> Generator:
2282
+ def all_descendants(self, folder: Union[Folder, Entity] = None) -> Generator[Entity, None, None]:
1688
2283
  """
1689
- 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.
1690
2287
 
1691
- 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)
1692
2291
 
1693
- :param folder: The folder to find children of
1694
2292
  """
1695
2293
  for entity in self.descendants(folder=folder):
1696
2294
  yield entity
1697
2295
  if entity.entity_type == EntityType.FOLDER:
1698
2296
  yield from self.all_descendants(folder=entity)
1699
2297
 
1700
- def descendants(self, folder: Union[str, Folder] = None) -> Generator:
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)
2307
+
2308
+ """
2309
+
1701
2310
  maximum = 100
1702
2311
  paged_set = self.children(folder, maximum=maximum, next_page=None)
1703
2312
  for entity in paged_set.results:
@@ -1707,10 +2316,30 @@ class EntityAPI(AuthenticatedAPI):
1707
2316
  for entity in paged_set.results:
1708
2317
  yield entity
1709
2318
 
2319
+
2320
+
1710
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
+
1711
2336
  headers = {HEADER_TOKEN: self.token}
1712
2337
  data = {'start': str(0), 'max': str(maximum)}
1713
- folder_reference = folder
2338
+
2339
+ if isinstance(folder, Folder):
2340
+ folder_reference = folder.reference
2341
+ else:
2342
+ folder_reference = folder
1714
2343
  if next_page is None:
1715
2344
  if folder_reference is None:
1716
2345
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/root/children', params=data,
@@ -1750,12 +2379,22 @@ class EntityAPI(AuthenticatedAPI):
1750
2379
  self.token = self.__token__()
1751
2380
  return self.children(folder_reference, maximum=maximum, next_page=next_page)
1752
2381
  else:
1753
- exception = HTTPException(folder.reference, request.status_code, request.url,
2382
+ exception = HTTPException(folder_reference, request.status_code, request.url,
1754
2383
  "children", request.content.decode('utf-8'))
1755
2384
  logger.error(exception)
1756
2385
  raise exception
1757
2386
 
1758
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
+
1759
2398
  self.token = self.__token__()
1760
2399
  previous = datetime.utcnow() - timedelta(days=previous_days)
1761
2400
  from_date = previous.replace(tzinfo=timezone.utc).isoformat()
@@ -1771,6 +2410,14 @@ class EntityAPI(AuthenticatedAPI):
1771
2410
  yield entity
1772
2411
 
1773
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
+ """
1774
2421
  self.token = self.__token__()
1775
2422
  paged_set = self._all_events_page()
1776
2423
  for entity in paged_set.results:
@@ -1796,9 +2443,15 @@ class EntityAPI(AuthenticatedAPI):
1796
2443
  actions = entity_response.findall(f'.//{{{self.xip_ns}}}EventAction')
1797
2444
  result_list = []
1798
2445
  for action in actions:
1799
- entity_ref = action.findall(f'.//{{{self.xip_ns}}}Entity')
1800
- for refs in entity_ref:
1801
- result_list.append(refs.text)
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)
1802
2455
  next_url = entity_response.find(f'.//{{{self.entity_ns}}}Next')
1803
2456
  total_hits = entity_response.find(f'.//{{{self.entity_ns}}}TotalResults')
1804
2457
  has_more = True
@@ -1808,8 +2461,17 @@ class EntityAPI(AuthenticatedAPI):
1808
2461
  else:
1809
2462
  url = next_url.text
1810
2463
  return PagedSet(result_list, has_more, int(total_hits.text), url)
2464
+ return None
2465
+
2466
+
1811
2467
 
1812
- def entity_from_event(self, event_id: str):
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
+ """
1813
2475
  self.token = self.__token__()
1814
2476
  paged_set = self._entity_from_event_page(event_id, 25, None)
1815
2477
  for entity in paged_set.results:
@@ -1832,9 +2494,12 @@ class EntityAPI(AuthenticatedAPI):
1832
2494
  params["from"] = kwargs.get("from_date")
1833
2495
  if "to_date" in kwargs:
1834
2496
  params["to"] = kwargs.get("to_date")
2497
+ if "username" in kwargs:
2498
+ params["username"] = kwargs.get("username")
1835
2499
 
1836
2500
  if next_page is None:
1837
- request = self.session.get(f'{self.protocol}://{self.server}/api/entity/events', params=params, headers=headers)
2501
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/events', params=params,
2502
+ headers=headers)
1838
2503
  else:
1839
2504
  request = self.session.get(next_page, headers=headers)
1840
2505
 
@@ -1961,6 +2626,16 @@ class EntityAPI(AuthenticatedAPI):
1961
2626
  raise exception
1962
2627
 
1963
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
+ """
1964
2639
  self.token = self.__token__()
1965
2640
  paged_set = self._entity_events_page(entity)
1966
2641
  for entity in paged_set.results:
@@ -1971,6 +2646,17 @@ class EntityAPI(AuthenticatedAPI):
1971
2646
  yield entity
1972
2647
 
1973
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
+ """
2659
+
1974
2660
  self.token = self.__token__()
1975
2661
  maximum = 25
1976
2662
  paged_set = self._updated_entities_page(previous_days=previous_days, maximum=maximum, next_page=None)
@@ -2028,34 +2714,37 @@ class EntityAPI(AuthenticatedAPI):
2028
2714
  logger.error(exception)
2029
2715
  raise exception
2030
2716
 
2031
- def delete_asset(self, asset: Asset, operator_comment: str, supervisor_comment: str):
2717
+ def delete_asset(self, asset: Asset, operator_comment: str, supervisor_comment: str, credentials_path: str = "credentials.properties"):
2032
2718
  """
2033
- Delete an asset from the repository
2719
+ Initiate and approve the deletion of an asset.
2034
2720
 
2035
- :param asset: The Asset
2036
- :param operator_comment: The operator comment on the deletion
2037
- :param supervisor_comment: The supervisor comment on the deletion
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
2038
2726
  """
2039
2727
  if isinstance(asset, Asset):
2040
- return self._delete_entity(asset, operator_comment, supervisor_comment)
2728
+ return self._delete_entity(asset, operator_comment, supervisor_comment, credentials_path)
2041
2729
  else:
2042
2730
  raise RuntimeError("delete_asset only deletes assets")
2043
2731
 
2044
- 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"):
2045
2733
  """
2046
- Delete an asset from the repository
2047
-
2734
+ Initiate and approve the deletion of a folder.
2048
2735
 
2049
- :param folder: The Folder
2050
- :param operator_comment: The operator comment on the deletion
2051
- :param supervisor_comment: The supervisor comment on the deletion
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
2052
2741
  """
2053
2742
  if isinstance(folder, Folder):
2054
- return self._delete_entity(folder, operator_comment, supervisor_comment)
2743
+ return self._delete_entity(folder, operator_comment, supervisor_comment, credentials_path)
2055
2744
  else:
2056
2745
  raise RuntimeError("delete_folder only deletes folders")
2057
2746
 
2058
- 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"):
2059
2748
  """
2060
2749
  Delete an asset from the repository
2061
2750
 
@@ -2066,7 +2755,7 @@ class EntityAPI(AuthenticatedAPI):
2066
2755
 
2067
2756
  # check manager password is available:
2068
2757
  config = configparser.ConfigParser()
2069
- config.read('credentials.properties', encoding='utf-8')
2758
+ config.read(credentials_path, encoding='utf-8')
2070
2759
  try:
2071
2760
  manager_username = config['credentials']['manager.username']
2072
2761
  manager_password = config['credentials']['manager.password']
@@ -2095,6 +2784,8 @@ class EntityAPI(AuthenticatedAPI):
2095
2784
  entity_response = xml.etree.ElementTree.fromstring(req.content.decode("utf-8"))
2096
2785
  status = entity_response.find(".//{http://status.preservica.com}Status")
2097
2786
  if hasattr(status, 'text'):
2787
+ if status.text == "COMPLETED":
2788
+ return entity.reference
2098
2789
  if status.text == "PENDING":
2099
2790
  headers = {HEADER_TOKEN: self.manager_token(manager_username, manager_password),
2100
2791
  'Content-Type': 'application/xml;charset=UTF-8'}
@@ -2105,18 +2796,20 @@ class EntityAPI(AuthenticatedAPI):
2105
2796
  xml.etree.ElementTree.SubElement(approval_el, "Comment").text = supervisor_comment
2106
2797
  xml_request = xml.etree.ElementTree.tostring(xml_object, encoding='utf-8')
2107
2798
  logger.debug(xml_request)
2108
- approve = self.session.put(f"{self.protocol}://{self.server}/api/entity/actions/deletions/{progress}",
2109
- 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)
2110
2802
  if approve.status_code == requests.codes.accepted:
2111
2803
  return entity.reference
2112
2804
  else:
2113
2805
  logger.error(approve.content.decode('utf-8'))
2114
2806
  raise RuntimeError(approve.status_code, "delete_asset failed during approval")
2115
2807
  sleep(2.0)
2116
- req = self.session.get(f"{self.protocol}://{self.server}/api/entity/progress/{progress}", headers=headers)
2808
+ req = self.session.get(f"{self.protocol}://{self.server}/api/entity/progress/{progress}",
2809
+ headers=headers)
2117
2810
  elif request.status_code == requests.codes.unauthorized:
2118
2811
  self.token = self.__token__()
2119
- return self._delete_entity(entity, operator_comment, supervisor_comment)
2812
+ return self._delete_entity(entity, operator_comment, supervisor_comment, credentials_path)
2120
2813
  if request.status_code == requests.codes.unprocessable:
2121
2814
  logger.error(request.content.decode('utf-8'))
2122
2815
  raise RuntimeError(request.status_code, "no active workflow context for full deletion exists in the system")
@@ -2129,3 +2822,4 @@ class EntityAPI(AuthenticatedAPI):
2129
2822
  "_delete_entity", request.content.decode('utf-8'))
2130
2823
  logger.error(exception)
2131
2824
  raise exception
2825
+