pyPreservica 2.9.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,7 +8,7 @@ author: James Carr
8
8
  licence: Apache License 2.0
9
9
 
10
10
  """
11
- import hashlib
11
+
12
12
  import os.path
13
13
  import uuid
14
14
  import xml.etree.ElementTree
@@ -17,6 +17,7 @@ from io import BytesIO
17
17
  from time import sleep
18
18
  from typing import Any, Generator, Tuple, Iterable, Union, Callable
19
19
 
20
+
20
21
  from pyPreservica.common import *
21
22
 
22
23
  logger = logging.getLogger(__name__)
@@ -35,10 +36,10 @@ class EntityAPI(AuthenticatedAPI):
35
36
 
36
37
  def __init__(self, username: str = None, password: str = None, tenant: str = None, server: str = None,
37
38
  use_shared_secret: bool = False, two_fa_secret_key: str = None,
38
- protocol: str = "https", request_hook: Callable = None):
39
+ protocol: str = "https", request_hook: Callable = None, credentials_path: str = 'credentials.properties'):
39
40
 
40
41
  super().__init__(username, password, tenant, server, use_shared_secret, two_fa_secret_key,
41
- protocol, request_hook)
42
+ protocol, request_hook, credentials_path)
42
43
 
43
44
  xml.etree.ElementTree.register_namespace("oai_dc", "http://www.openarchives.org/OAI/2.0/oai_dc/")
44
45
  xml.etree.ElementTree.register_namespace("ead", "urn:isbn:1-931666-22-9")
@@ -58,11 +59,15 @@ class EntityAPI(AuthenticatedAPI):
58
59
 
59
60
  def bitstream_chunks(self, bitstream: Bitstream, chunk_size: int = CHUNK_SIZE) -> Generator:
60
61
  """
61
- Generator function to return bitstream chunks
62
+ Generator function to return bitstream chunks, allows the clients to
63
+ process chunks as they are downloaded.
62
64
 
63
- :param bitstream: The bitstream
64
- :param chunk_size: The chunk size to return
65
- :return: A chunk of the requested bitstream content
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
66
71
  """
67
72
  if not isinstance(bitstream, Bitstream):
68
73
  logger.error("bitstream_content argument is not a Bitstream object")
@@ -70,40 +75,40 @@ class EntityAPI(AuthenticatedAPI):
70
75
  with self.session.get(bitstream.content_url, headers={HEADER_TOKEN: self.token}, stream=True) as request:
71
76
  if request.status_code == requests.codes.unauthorized:
72
77
  self.token = self.__token__()
73
- return self.bitstream_chunks(bitstream)
78
+ yield from self.bitstream_chunks(bitstream)
74
79
  elif request.status_code == requests.codes.ok:
75
80
  for chunk in request.iter_content(chunk_size=chunk_size):
76
81
  yield chunk
77
82
  else:
78
- exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_content",
83
+ exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_chunks",
79
84
  request.content.decode('utf-8'))
80
85
  logger.error(exception)
81
86
  raise exception
82
87
 
83
88
  def bitstream_bytes(self, bitstream: Bitstream, chunk_size: int = CHUNK_SIZE) -> Union[BytesIO, None]:
84
89
  """
85
- Download a file represented as a Bitstream to a byteIO array
90
+ Download a file represented as a Bitstream to a byteIO array
86
91
 
87
- Returns the byteIO
88
- Returns None if the file does not contain the correct number of bytes (default 2k)
92
+ Returns the byteIO
93
+ Returns None if the file does not contain the correct number of bytes (default 2k)
89
94
 
90
- :param chunk_size: The buffer copy chunk size in bytes default
91
- :param bitstream: A Bitstream object
92
- :type bitstream: Bitstream
95
+ :param chunk_size: The buffer copy chunk size in bytes default
96
+ :param bitstream: A Bitstream object
97
+ :type bitstream: Bitstream
93
98
 
94
- :return: The file in bytes
95
- :rtype: byteIO
99
+ :return: The file in bytes
100
+ :rtype: byteIO
96
101
  """
97
102
  if not isinstance(bitstream, Bitstream):
98
103
  logger.error("bitstream_content argument is not a Bitstream object")
99
104
  raise RuntimeError("bitstream_bytes argument is not a Bitstream object")
100
- with self.session.get(bitstream.content_url, headers={HEADER_TOKEN: self.token}, stream=True) as request:
101
- if request.status_code == requests.codes.unauthorized:
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:
102
107
  self.token = self.__token__()
103
108
  return self.bitstream_bytes(bitstream)
104
- elif request.status_code == requests.codes.ok:
109
+ elif response.status_code == requests.codes.ok:
105
110
  file_bytes = BytesIO()
106
- for chunk in request.iter_content(chunk_size=chunk_size):
111
+ for chunk in response.iter_content(chunk_size=chunk_size):
107
112
  file_bytes.write(chunk)
108
113
  file_bytes.seek(0)
109
114
  if file_bytes.getbuffer().nbytes == bitstream.length:
@@ -113,11 +118,49 @@ class EntityAPI(AuthenticatedAPI):
113
118
  logger.error("Downloaded file size did not match the Preservica held value")
114
119
  return None
115
120
  else:
116
- exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_content",
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",
117
158
  request.content.decode('utf-8'))
118
159
  logger.error(exception)
119
160
  raise exception
120
161
 
162
+
163
+
121
164
  def bitstream_content(self, bitstream: Bitstream, filename: str, chunk_size: int = CHUNK_SIZE) -> Union[int, None]:
122
165
  """
123
166
  Download a file represented as a Bitstream to a local filename
@@ -307,6 +350,15 @@ class EntityAPI(AuthenticatedAPI):
307
350
  IncludedGenerations
308
351
  IncludeParentHierarchy
309
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
+
310
362
  """
311
363
  status = "ACTIVE"
312
364
  pid = self.__export_opex_start__(entity, **kwargs)
@@ -321,12 +373,12 @@ class EntityAPI(AuthenticatedAPI):
321
373
 
322
374
  def download(self, entity: Entity, filename: str) -> str:
323
375
  """
324
- Download a file from an asset
376
+ Download the first generation of the access representation of an asset
325
377
 
326
- Returns the filename of the new file
327
-
328
- :param entity: The entity containing the file
329
- :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
330
382
  """
331
383
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
332
384
  params = {'id': f'sdb:{entity.entity_type.value}|{entity.reference}'}
@@ -373,13 +425,13 @@ class EntityAPI(AuthenticatedAPI):
373
425
 
374
426
  def thumbnail(self, entity: Entity, filename: str, size=Thumbnail.LARGE):
375
427
  """
376
- Download the thumbnail of an asset or folder
428
+ Get the thumbnail image for an asset or folder
377
429
 
378
- Returns the filename of the new thumbnail file or None if the entity has no thumbnail
379
-
380
- :param entity: The entity containing the file
381
- :param filename: The filename to write the bytes to
382
- :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
383
435
  """
384
436
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
385
437
  params = {'id': f'sdb:{entity.entity_type.value}|{entity.reference}', 'size': f'{size.value}'}
@@ -404,14 +456,14 @@ class EntityAPI(AuthenticatedAPI):
404
456
 
405
457
  def delete_identifiers(self, entity: Entity, identifier_type: str = None, identifier_value: str = None):
406
458
  """
407
- Delete external identifiers from an entity
459
+ Delete identifiers on an Entity object
408
460
 
409
- Returns the entity
410
-
411
- :param entity: The entity to delete identifiers from
412
- :param identifier_type: The type of the identifier to delete.
413
- :param identifier_value: The value of the identifier to delete.
414
- """
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
+ """
415
467
 
416
468
  if (self.major_version < 7) and (self.minor_version < 1):
417
469
  raise RuntimeError("delete_identifiers API call is not available when connected to a v6.0 System")
@@ -454,15 +506,61 @@ class EntityAPI(AuthenticatedAPI):
454
506
  logger.error(request)
455
507
  raise RuntimeError(request.status_code, "delete_identifier failed")
456
508
 
509
+ def entity_identifiers(self, entity: Entity, external_identifier_type = None) -> set[ExternIdentifier]:
510
+ """
511
+ Get all external identifiers on an entity
457
512
 
458
- def identifiers_for_entity(self, entity: Entity) -> set[Tuple]:
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
459
518
  """
460
- Get all external identifiers on an entity
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
461
556
 
462
- 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
463
560
 
464
- :param entity: The Entity (Asset or Folder)
465
- :type entity: Entity
561
+ :param Entity entity: The entity
562
+ :return: Set of identifiers as tuples
563
+ :rtype: set(Tuple)
466
564
  """
467
565
  headers = {HEADER_TOKEN: self.token}
468
566
  request = self.session.get(
@@ -492,16 +590,14 @@ class EntityAPI(AuthenticatedAPI):
492
590
  logger.error(exception)
493
591
  raise exception
494
592
 
495
-
496
-
497
- def identifier(self, identifier_type: str, identifier_value: str) -> set[Entity]:
593
+ def identifier(self, identifier_type: str, identifier_value: str) -> set[EntityT]:
498
594
  """
499
- Get all entities which have the external identifier
595
+ Return a set of entities with external identifiers which match the type and value
500
596
 
501
- Returns the set of entities which have the external identifier
502
-
503
- :param identifier_type: The identifier type
504
- :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)
505
601
  """
506
602
  headers = {HEADER_TOKEN: self.token}
507
603
  payload = {'type': identifier_type, 'value': identifier_value}
@@ -535,14 +631,14 @@ class EntityAPI(AuthenticatedAPI):
535
631
 
536
632
  def add_identifier(self, entity: Entity, identifier_type: str, identifier_value: str):
537
633
  """
538
- Add a new identifier to an entity
539
-
540
- Returns the internal identifier DB key
634
+ Add a new external identifier to an Entity object
541
635
 
542
- :param entity: The Entity
543
- :param identifier_type: The identifier type
544
- :param identifier_value: The identifier value
545
- """
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
+ """
546
642
 
547
643
  if self.major_version < 7 and self.minor_version < 1:
548
644
  raise RuntimeError("add_identifier API call is not available when connected to a v6.0 System")
@@ -684,7 +780,7 @@ class EntityAPI(AuthenticatedAPI):
684
780
  end_point = f"{entity.path}/{entity.reference}/links/{relationship.api_id}"
685
781
  request = self.session.delete(f'{self.protocol}://{self.server}/api/entity/{end_point}', headers=headers)
686
782
  if request.status_code == requests.codes.no_content:
687
- print(relationship)
783
+ return None
688
784
  elif request.status_code == requests.codes.unauthorized:
689
785
  self.token = self.__token__()
690
786
  return self.__delete_relationship(relationship)
@@ -703,7 +799,7 @@ class EntityAPI(AuthenticatedAPI):
703
799
  :type: page_size: int
704
800
 
705
801
  :param entity: The Source Entity
706
- :type: entity: Entity
802
+ :type: entity: An Entity type such as Asset, Folder etc
707
803
 
708
804
  :return: Generator
709
805
  :rtype: Relationship
@@ -771,7 +867,7 @@ class EntityAPI(AuthenticatedAPI):
771
867
 
772
868
  return PagedSet(results, has_more, int(total_hits.text), url)
773
869
  elif request.status_code == requests.codes.unauthorized:
774
- self.__relationships__(entity=entity, maximum=maximum, next_page=next_page)
870
+ return self.__relationships__(entity=entity, maximum=maximum, next_page=next_page)
775
871
  else:
776
872
  exception = HTTPException(entity.reference, request.status_code, request.url, "relationships",
777
873
  request.content.decode('utf-8'))
@@ -782,10 +878,10 @@ class EntityAPI(AuthenticatedAPI):
782
878
  """
783
879
  Add a new relationship link between two Assets or Folders
784
880
 
785
- :param from_entity: The Source Entity
881
+ :param from_entity: The Source entity to link from
786
882
  :type from_entity: Entity
787
883
 
788
- :param to_entity: The Target Entity
884
+ :param to_entity: The Target entity
789
885
  :type to_entity: Entity
790
886
 
791
887
  :param relationship_type: The Relationship type
@@ -829,15 +925,17 @@ class EntityAPI(AuthenticatedAPI):
829
925
  logger.error(exception)
830
926
  raise exception
831
927
 
832
- def delete_metadata(self, entity: Entity, schema: str) -> Entity:
928
+ def delete_metadata(self, entity: EntityT, schema: str) -> EntityT:
833
929
  """
834
- Deletes all the metadata fragments on an entity which match the schema URI
835
-
836
- 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
837
932
 
838
- :param entity: The Entity to delete metadata from
839
- :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
840
937
  """
938
+
841
939
  headers = {HEADER_TOKEN: self.token}
842
940
  for url in entity.metadata:
843
941
  if schema == entity.metadata[url]:
@@ -855,15 +953,45 @@ class EntityAPI(AuthenticatedAPI):
855
953
 
856
954
  return self.entity(entity.entity_type, entity.reference)
857
955
 
858
- def update_metadata(self, entity: Entity, schema: str, data: Any) -> Entity:
956
+
957
+ def add_group_metadata(self, csv_file: str) -> str:
859
958
  """
860
- 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
861
963
 
862
- 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'}
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
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
863
989
 
864
- :param data: The updated XML as a string or as IO bytes
865
- :param entity: The Entity to update
866
- :param schema: The schema URI to match against
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
867
995
  """
868
996
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
869
997
 
@@ -886,7 +1014,7 @@ class EntityAPI(AuthenticatedAPI):
886
1014
  content.append(tree.getroot())
887
1015
  else:
888
1016
  raise RuntimeError("Unknown data type")
889
- 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")
890
1018
  logger.debug(xml_request)
891
1019
  request = self.session.put(url, data=xml_request, headers=headers)
892
1020
  if request.status_code == requests.codes.ok:
@@ -901,15 +1029,51 @@ class EntityAPI(AuthenticatedAPI):
901
1029
  raise exception
902
1030
  return self.entity(entity.entity_type, entity.reference)
903
1031
 
904
- 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:
905
1033
  """
906
- 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
907
1036
 
908
1037
  Returns The updated Entity
909
1038
 
910
- :param data: The new XML as a string or as IO bytes
911
- :param entity: The Entity to update
912
- :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
1043
+ """
1044
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
1045
+
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
913
1077
  """
914
1078
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
915
1079
 
@@ -941,13 +1105,15 @@ class EntityAPI(AuthenticatedAPI):
941
1105
  logger.error(exception)
942
1106
  raise exception
943
1107
 
944
- def save(self, entity: Entity) -> Entity:
1108
+ def save(self, entity: EntityT) -> EntityT:
945
1109
  """
946
- Save the title and description of an entity
947
-
948
- Returns The updated Entity
1110
+ Updates the title and description of an entity
1111
+ The security tag and parent are not saved via this method call
949
1112
 
950
- :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
951
1117
  """
952
1118
 
953
1119
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
@@ -993,6 +1159,7 @@ class EntityAPI(AuthenticatedAPI):
993
1159
  if 'CustomType' in response:
994
1160
  content_object.custom_type = response['CustomType']
995
1161
  return content_object
1162
+ return None
996
1163
  elif request.status_code == requests.codes.unauthorized:
997
1164
  self.token = self.__token__()
998
1165
  return self.save(entity)
@@ -1012,8 +1179,10 @@ class EntityAPI(AuthenticatedAPI):
1012
1179
 
1013
1180
  Returns The updated Entity
1014
1181
 
1015
- :param entity: The Entity to update
1016
- :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
1017
1186
  """
1018
1187
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
1019
1188
  if isinstance(entity, Asset) and dest_folder is None:
@@ -1036,7 +1205,18 @@ class EntityAPI(AuthenticatedAPI):
1036
1205
  logger.error(exception)
1037
1206
  raise exception
1038
1207
 
1208
+ def get_progress(self, pid: str) -> AsyncProgress:
1209
+ return AsyncProgress[self.get_async_progress(pid)]
1210
+
1039
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
+ """
1040
1220
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
1041
1221
  request = self.session.get(f"{self.protocol}://{self.server}/api/entity/progress/{pid}", headers=headers)
1042
1222
  if request.status_code == requests.codes.ok:
@@ -1055,16 +1235,15 @@ class EntityAPI(AuthenticatedAPI):
1055
1235
  logger.error(exception)
1056
1236
  raise exception
1057
1237
 
1058
- def move_sync(self, entity: Entity, dest_folder: Folder) -> Entity:
1238
+ def move_sync(self, entity: EntityT, dest_folder: Folder) -> EntityT:
1059
1239
  """
1060
- Move an Entity (Asset or Folder) to a new Folder
1061
- 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
1062
1242
 
1063
- Returns The updated Entity.
1064
- Blocks until the move is complete.
1065
-
1066
- :param entity: The Entity to update
1067
- :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
1068
1247
  """
1069
1248
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
1070
1249
  if isinstance(entity, Asset) and dest_folder is None:
@@ -1095,15 +1274,15 @@ class EntityAPI(AuthenticatedAPI):
1095
1274
  logger.error(exception)
1096
1275
  raise exception
1097
1276
 
1098
- def move(self, entity: Entity, dest_folder: Folder) -> Entity:
1277
+ def move(self, entity: EntityT, dest_folder: Folder) -> EntityT:
1099
1278
  """
1100
- Move an Entity (Asset or Folder) to a new Folder
1101
- If dest_folder is None then the entity must be a Folder and will be moved to the root of the repository
1102
-
1103
- 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.
1104
1281
 
1105
- :param entity: The Entity to update
1106
- :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
1107
1286
  """
1108
1287
  return self.move_sync(entity, dest_folder)
1109
1288
 
@@ -1149,13 +1328,15 @@ class EntityAPI(AuthenticatedAPI):
1149
1328
  logger.error(exception)
1150
1329
  raise exception
1151
1330
 
1152
- def all_metadata(self, entity: Entity) -> Tuple:
1331
+ def all_metadata(self, entity: Entity) -> Generator[Tuple[str, str], None, None]:
1153
1332
  """
1154
1333
  Retrieve all metadata fragments on an entity
1155
1334
 
1156
1335
  Returns XML documents in a tuple
1157
1336
 
1158
- :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]]
1159
1340
  """
1160
1341
 
1161
1342
  for uri, schema in entity.metadata.items():
@@ -1163,12 +1344,12 @@ class EntityAPI(AuthenticatedAPI):
1163
1344
 
1164
1345
  def metadata_for_entity(self, entity: Entity, schema: str) -> Union[str, None]:
1165
1346
  """
1166
- 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
1167
1348
 
1168
- Returns XML document as a string
1169
-
1170
- :param entity: The entity with the metadata
1171
- :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
1172
1353
  """
1173
1354
 
1174
1355
  # if the entity is a lightweight enum version request the full object
@@ -1178,9 +1359,9 @@ class EntityAPI(AuthenticatedAPI):
1178
1359
  for uri, schema_name in entity.metadata.items():
1179
1360
  if schema == schema_name:
1180
1361
  return self.metadata(uri)
1181
- return
1362
+ return None
1182
1363
 
1183
- 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]:
1184
1365
  """
1185
1366
  Retrieve the first value of the tag from a metadata template given by schema
1186
1367
 
@@ -1195,19 +1376,23 @@ class EntityAPI(AuthenticatedAPI):
1195
1376
  xml_doc = self.metadata_for_entity(entity, schema)
1196
1377
  if xml_doc:
1197
1378
  xml_object = xml.etree.ElementTree.fromstring(xml_doc)
1198
- if isXpath is False:
1379
+ if not isXpath:
1199
1380
  return xml_object.find(f'.//{{*}}{tag}').text
1200
1381
  else:
1201
1382
  return xml_object.find(tag).text
1383
+ return None
1202
1384
 
1203
- def security_tag_sync(self, entity: Entity, new_tag: str):
1385
+ def security_tag_sync(self, entity: EntityT, new_tag: str) -> EntityT:
1204
1386
  """
1205
- Change the security tag for a folder or asset
1387
+ Change the security tag of an asset or folder
1388
+ This is a blocking call which returns after all entities have been updated.
1206
1389
 
1207
- Returns the updated entity after the security tag has been updated.
1208
-
1209
- :param entity: The entity to change
1210
- :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
1211
1396
  """
1212
1397
  self.token = self.__token__()
1213
1398
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
@@ -1234,12 +1419,15 @@ class EntityAPI(AuthenticatedAPI):
1234
1419
 
1235
1420
  def security_tag_async(self, entity: Entity, new_tag: str):
1236
1421
  """
1237
- 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.
1238
1424
 
1239
- Returns a process ID asynchronous (without blocking)
1240
-
1241
- :param entity: The entity to change
1242
- :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
1243
1431
  """
1244
1432
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
1245
1433
  end_point = f"/{entity.path}/{entity.reference}/security-descriptor"
@@ -1258,11 +1446,11 @@ class EntityAPI(AuthenticatedAPI):
1258
1446
 
1259
1447
  def metadata(self, uri: str) -> str:
1260
1448
  """
1261
- Retrieve the metadata fragment which is referenced by the URI
1262
-
1263
- Returns XML document as a string
1449
+ Fetch the metadata document by its identifier, this is the key from the entity metadata map
1264
1450
 
1265
- :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
1266
1454
  """
1267
1455
  request = self.session.get(uri, headers={HEADER_TOKEN: self.token})
1268
1456
  if request.status_code == requests.codes.ok:
@@ -1280,14 +1468,17 @@ class EntityAPI(AuthenticatedAPI):
1280
1468
  logger.error(exception)
1281
1469
  raise exception
1282
1470
 
1283
- def entity(self, entity_type: EntityType, reference: str) -> Entity:
1471
+ def entity(self, entity_type: EntityType, reference: str) -> EntityT:
1284
1472
  """
1285
- Retrieve an entity by its type and reference
1473
+ Returns a generic entity based on its reference identifier
1286
1474
 
1287
- Returns Entity (Asset, Folder, ContentObject)
1288
-
1289
- :param entity_type: The type of entity to fetch
1290
- :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
1291
1482
  """
1292
1483
  if entity_type is EntityType.CONTENT_OBJECT:
1293
1484
  return self.content_object(reference)
@@ -1295,6 +1486,7 @@ class EntityAPI(AuthenticatedAPI):
1295
1486
  return self.folder(reference)
1296
1487
  if entity_type is EntityType.ASSET:
1297
1488
  return self.asset(reference)
1489
+ return None
1298
1490
 
1299
1491
  def add_physical_asset(self, title: str, description: str, parent: Folder, security_tag: str = "open") -> Asset:
1300
1492
  """
@@ -1302,10 +1494,12 @@ class EntityAPI(AuthenticatedAPI):
1302
1494
 
1303
1495
  Returns Asset
1304
1496
 
1305
- :param title: The title of the new Asset
1306
- :param description: The description of the new Asset
1307
- :param parent: The parent folder
1308
- :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
1309
1503
  """
1310
1504
 
1311
1505
  if (self.major_version < 7) and (self.minor_version < 4):
@@ -1342,15 +1536,134 @@ class EntityAPI(AuthenticatedAPI):
1342
1536
  logger.error(exception)
1343
1537
  raise exception
1344
1538
 
1345
- 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:
1346
1627
  """
1347
1628
  Retrieve an Asset by its reference
1348
1629
 
1349
- Returns Asset
1630
+ Returns an XML document of the full Asset
1350
1631
 
1351
1632
  :param reference: The unique identifier of the entity
1352
1633
  """
1353
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}
1354
1667
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{IO_PATH}/{reference}', headers=headers)
1355
1668
  if request.status_code == requests.codes.ok:
1356
1669
  xml_response = str(request.content.decode('utf-8'))
@@ -1376,11 +1689,13 @@ class EntityAPI(AuthenticatedAPI):
1376
1689
 
1377
1690
  def folder(self, reference: str) -> Folder:
1378
1691
  """
1379
- Retrieve a Folder by its reference
1692
+ Returns a folder object back by its internal reference identifier
1380
1693
 
1381
- Returns Folder
1382
-
1383
- :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
1384
1699
  """
1385
1700
  headers = {HEADER_TOKEN: self.token}
1386
1701
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{SO_PATH}/{reference}', headers=headers)
@@ -1408,11 +1723,13 @@ class EntityAPI(AuthenticatedAPI):
1408
1723
 
1409
1724
  def content_object(self, reference: str) -> ContentObject:
1410
1725
  """
1411
- Retrieve an ContentObject by its reference
1726
+ Returns a content object back by its internal reference identifier
1412
1727
 
1413
- Returns ContentObject
1414
-
1415
- :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
1416
1733
  """
1417
1734
  headers = {HEADER_TOKEN: self.token}
1418
1735
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{CO_PATH}/{reference}', headers=headers)
@@ -1440,11 +1757,12 @@ class EntityAPI(AuthenticatedAPI):
1440
1757
 
1441
1758
  def content_objects(self, representation: Representation) -> list[ContentObject]:
1442
1759
  """
1443
- Retrieve a list of content objects within a representation
1760
+ Return a list of content objects for a representation
1444
1761
 
1445
- :param representation:
1446
-
1447
- :returns list[ContentObject]
1762
+ :param representation: The representation
1763
+ :type representation: Representation
1764
+ :return: List of content objects
1765
+ :rtype: list(ContentObject)
1448
1766
 
1449
1767
  """
1450
1768
  headers = {HEADER_TOKEN: self.token}
@@ -1474,13 +1792,17 @@ class EntityAPI(AuthenticatedAPI):
1474
1792
  logger.error(exception)
1475
1793
  raise exception
1476
1794
 
1477
- 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
1478
1804
  """
1479
- Retrieve a list of generation objects
1480
1805
 
1481
- :param url:
1482
- :returns Generation
1483
- """
1484
1806
  headers = {HEADER_TOKEN: self.token}
1485
1807
  request = self.session.get(url, headers=headers)
1486
1808
  if request.status_code == requests.codes.ok:
@@ -1524,7 +1846,11 @@ class EntityAPI(AuthenticatedAPI):
1524
1846
  bitstreams = entity_response.findall(f'./{{{self.entity_ns}}}Bitstreams/{{{self.entity_ns}}}Bitstream')
1525
1847
  bitstream_list = []
1526
1848
  for bit in bitstreams:
1527
- bitstream_list.append(self.bitstream(bit.text))
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)
1528
1854
  generation = Generation(strtobool(ge.attrib['original']), strtobool(ge.attrib['active']),
1529
1855
  format_group.text if hasattr(format_group, 'text') else None,
1530
1856
  effective_date.text if hasattr(effective_date, 'text') else None,
@@ -1609,11 +1935,12 @@ class EntityAPI(AuthenticatedAPI):
1609
1935
 
1610
1936
  def bitstream(self, url: str) -> Bitstream:
1611
1937
  """
1612
- Retrieve a bitstream by its url
1613
-
1614
- Returns Bitstream
1938
+ Fetch a bitstream object from the server using its URL
1615
1939
 
1616
- :param url:
1940
+ :param url: The URL to the bitstream
1941
+ :type url: str
1942
+ :return: a bitstream object
1943
+ :rtype: Bitstream
1617
1944
  """
1618
1945
  headers = {HEADER_TOKEN: self.token}
1619
1946
  request = self.session.get(url, headers=headers)
@@ -1649,9 +1976,16 @@ class EntityAPI(AuthenticatedAPI):
1649
1976
  def replace_generation_sync(self, content_object: ContentObject, file_name, fixity_algorithm=None,
1650
1977
  fixity_value=None) -> str:
1651
1978
  """
1652
- 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.
1653
1982
 
1654
- 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
1655
1989
 
1656
1990
  """
1657
1991
 
@@ -1670,7 +2004,14 @@ class EntityAPI(AuthenticatedAPI):
1670
2004
  """
1671
2005
  Replace the last active generation of a content object with a new digital file.
1672
2006
 
1673
- 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
1674
2015
 
1675
2016
  """
1676
2017
  if (self.major_version < 7) and (self.minor_version < 2) and (self.patch_version < 1):
@@ -1724,11 +2065,12 @@ class EntityAPI(AuthenticatedAPI):
1724
2065
 
1725
2066
  def generations(self, content_object: ContentObject) -> list[Generation]:
1726
2067
  """
1727
- Retrieve list of generations on a content object
1728
-
1729
- Returns list
2068
+ Return a list of Generation objects for a content object
1730
2069
 
1731
- :param content_object:
2070
+ :param content_object: The content object
2071
+ :type content_object: ContentObject
2072
+ :return: list of generations
2073
+ :rtype: list(Generation)
1732
2074
  """
1733
2075
  headers = {HEADER_TOKEN: self.token}
1734
2076
  request = self.session.get(
@@ -1741,7 +2083,7 @@ class EntityAPI(AuthenticatedAPI):
1741
2083
  result = []
1742
2084
  for g in generations:
1743
2085
  if hasattr(g, 'text'):
1744
- generation = self.generation(g.text)
2086
+ generation = self.generation(g.text, content_object.reference)
1745
2087
  generation.asset = content_object.asset
1746
2088
  generation.content_object = content_object
1747
2089
  generation.representation_type = content_object.representation_type
@@ -1758,13 +2100,11 @@ class EntityAPI(AuthenticatedAPI):
1758
2100
 
1759
2101
  def bitstreams_for_asset(self, asset: Union[Asset, Entity]) -> Iterable[Bitstream]:
1760
2102
  """
1761
-
1762
- Return all the bitstreams within an asset.
2103
+ Return all the active bitstreams within an asset.
1763
2104
  This includes all the representations and content objects
1764
2105
 
1765
-
1766
2106
  :param asset: The asset
1767
- :return:
2107
+ :return: Iterable
1768
2108
  """
1769
2109
 
1770
2110
  for representation in self.representations(asset):
@@ -1779,10 +2119,14 @@ class EntityAPI(AuthenticatedAPI):
1779
2119
 
1780
2120
  def representations(self, asset: Asset) -> set[Representation]:
1781
2121
  """
1782
- 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.
1783
2125
 
1784
- :param asset: The asset
1785
- :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)
1786
2130
  """
1787
2131
  headers = {HEADER_TOKEN: self.token}
1788
2132
  if not isinstance(asset, Asset):
@@ -1810,11 +2154,11 @@ class EntityAPI(AuthenticatedAPI):
1810
2154
 
1811
2155
  def remove_thumbnail(self, entity: Entity):
1812
2156
  """
1813
- remove a thumbnail icon to a folder or asset
2157
+ Remove the thumbnail for the entity to the uploaded image
1814
2158
 
1815
-
1816
- :param entity: The Entity
1817
- """
2159
+ :param entity: The entity with the thumbnail
2160
+ :type entity: Entity
2161
+ """
1818
2162
  if self.major_version < 7 and self.minor_version < 2:
1819
2163
  raise RuntimeError("Thumbnail API is only available when connected to a v6.2 System")
1820
2164
 
@@ -1837,13 +2181,14 @@ class EntityAPI(AuthenticatedAPI):
1837
2181
  logger.error(exception)
1838
2182
  raise exception
1839
2183
 
2184
+
1840
2185
  def add_access_representation(self, entity: Entity, access_file: str, name: str = "Access"):
1841
2186
  """
1842
- Add a new representation to an existing asset.
2187
+ Add a new Access representation to an existing asset.
1843
2188
 
1844
- :param entity: The existing asset which will receive the new representation
1845
- :param access_file: The new digital file
1846
- :param name: The name of the new access representation defaults to "Access"
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"
1847
2192
  :return:
1848
2193
  """
1849
2194
 
@@ -1876,12 +2221,14 @@ class EntityAPI(AuthenticatedAPI):
1876
2221
 
1877
2222
  def add_thumbnail(self, entity: Entity, image_file: str):
1878
2223
  """
1879
- add a thumbnail icon to a folder or asset
2224
+ Set the thumbnail for the entity to the uploaded image
1880
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
+ """
1881
2231
 
1882
- :param entity: The Entity
1883
- :param image_file: Path to image file
1884
- """
1885
2232
  if self.major_version < 7 and self.minor_version < 2:
1886
2233
  raise RuntimeError("Thumbnail API is only available when connected to a v6.2 System")
1887
2234
 
@@ -1922,7 +2269,7 @@ class EntityAPI(AuthenticatedAPI):
1922
2269
  params=params, headers=headers)
1923
2270
 
1924
2271
  if request.status_code == requests.codes.ok:
1925
- pass
2272
+ return None
1926
2273
  elif request.status_code == requests.codes.unauthorized:
1927
2274
  self.token = self.__token__()
1928
2275
  return self._event_actions(entity, maximum=maximum)
@@ -1934,11 +2281,14 @@ class EntityAPI(AuthenticatedAPI):
1934
2281
 
1935
2282
  def all_descendants(self, folder: Union[Folder, Entity] = None) -> Generator[Entity, None, None]:
1936
2283
  """
1937
- 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.
1938
2287
 
1939
- 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)
1940
2291
 
1941
- :param folder: The folder to find children of
1942
2292
  """
1943
2293
  for entity in self.descendants(folder=folder):
1944
2294
  yield entity
@@ -1946,6 +2296,17 @@ class EntityAPI(AuthenticatedAPI):
1946
2296
  yield from self.all_descendants(folder=entity)
1947
2297
 
1948
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
+
1949
2310
  maximum = 100
1950
2311
  paged_set = self.children(folder, maximum=maximum, next_page=None)
1951
2312
  for entity in paged_set.results:
@@ -1955,7 +2316,23 @@ class EntityAPI(AuthenticatedAPI):
1955
2316
  for entity in paged_set.results:
1956
2317
  yield entity
1957
2318
 
2319
+
2320
+
1958
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
+
1959
2336
  headers = {HEADER_TOKEN: self.token}
1960
2337
  data = {'start': str(0), 'max': str(maximum)}
1961
2338
 
@@ -2002,12 +2379,22 @@ class EntityAPI(AuthenticatedAPI):
2002
2379
  self.token = self.__token__()
2003
2380
  return self.children(folder_reference, maximum=maximum, next_page=next_page)
2004
2381
  else:
2005
- exception = HTTPException(folder.reference, request.status_code, request.url,
2382
+ exception = HTTPException(folder_reference, request.status_code, request.url,
2006
2383
  "children", request.content.decode('utf-8'))
2007
2384
  logger.error(exception)
2008
2385
  raise exception
2009
2386
 
2010
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
+
2011
2398
  self.token = self.__token__()
2012
2399
  previous = datetime.utcnow() - timedelta(days=previous_days)
2013
2400
  from_date = previous.replace(tzinfo=timezone.utc).isoformat()
@@ -2023,6 +2410,14 @@ class EntityAPI(AuthenticatedAPI):
2023
2410
  yield entity
2024
2411
 
2025
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
+ """
2026
2421
  self.token = self.__token__()
2027
2422
  paged_set = self._all_events_page()
2028
2423
  for entity in paged_set.results:
@@ -2048,9 +2443,15 @@ class EntityAPI(AuthenticatedAPI):
2048
2443
  actions = entity_response.findall(f'.//{{{self.xip_ns}}}EventAction')
2049
2444
  result_list = []
2050
2445
  for action in actions:
2051
- entity_ref = action.findall(f'.//{{{self.xip_ns}}}Entity')
2052
- for refs in entity_ref:
2053
- 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)
2054
2455
  next_url = entity_response.find(f'.//{{{self.entity_ns}}}Next')
2055
2456
  total_hits = entity_response.find(f'.//{{{self.entity_ns}}}TotalResults')
2056
2457
  has_more = True
@@ -2060,8 +2461,17 @@ class EntityAPI(AuthenticatedAPI):
2060
2461
  else:
2061
2462
  url = next_url.text
2062
2463
  return PagedSet(result_list, has_more, int(total_hits.text), url)
2464
+ return None
2465
+
2466
+
2467
+
2063
2468
 
2064
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
+ """
2065
2475
  self.token = self.__token__()
2066
2476
  paged_set = self._entity_from_event_page(event_id, 25, None)
2067
2477
  for entity in paged_set.results:
@@ -2084,6 +2494,8 @@ class EntityAPI(AuthenticatedAPI):
2084
2494
  params["from"] = kwargs.get("from_date")
2085
2495
  if "to_date" in kwargs:
2086
2496
  params["to"] = kwargs.get("to_date")
2497
+ if "username" in kwargs:
2498
+ params["username"] = kwargs.get("username")
2087
2499
 
2088
2500
  if next_page is None:
2089
2501
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/events', params=params,
@@ -2214,6 +2626,16 @@ class EntityAPI(AuthenticatedAPI):
2214
2626
  raise exception
2215
2627
 
2216
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
+ """
2217
2639
  self.token = self.__token__()
2218
2640
  paged_set = self._entity_events_page(entity)
2219
2641
  for entity in paged_set.results:
@@ -2224,6 +2646,17 @@ class EntityAPI(AuthenticatedAPI):
2224
2646
  yield entity
2225
2647
 
2226
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
+
2227
2660
  self.token = self.__token__()
2228
2661
  maximum = 25
2229
2662
  paged_set = self._updated_entities_page(previous_days=previous_days, maximum=maximum, next_page=None)
@@ -2281,34 +2714,37 @@ class EntityAPI(AuthenticatedAPI):
2281
2714
  logger.error(exception)
2282
2715
  raise exception
2283
2716
 
2284
- 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"):
2285
2718
  """
2286
- Delete an asset from the repository
2719
+ Initiate and approve the deletion of an asset.
2287
2720
 
2288
- :param asset: The Asset
2289
- :param operator_comment: The operator comment on the deletion
2290
- :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
2291
2726
  """
2292
2727
  if isinstance(asset, Asset):
2293
- return self._delete_entity(asset, operator_comment, supervisor_comment)
2728
+ return self._delete_entity(asset, operator_comment, supervisor_comment, credentials_path)
2294
2729
  else:
2295
2730
  raise RuntimeError("delete_asset only deletes assets")
2296
2731
 
2297
- 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"):
2298
2733
  """
2299
- Delete an asset from the repository
2300
-
2734
+ Initiate and approve the deletion of a folder.
2301
2735
 
2302
- :param folder: The Folder
2303
- :param operator_comment: The operator comment on the deletion
2304
- :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
2305
2741
  """
2306
2742
  if isinstance(folder, Folder):
2307
- return self._delete_entity(folder, operator_comment, supervisor_comment)
2743
+ return self._delete_entity(folder, operator_comment, supervisor_comment, credentials_path)
2308
2744
  else:
2309
2745
  raise RuntimeError("delete_folder only deletes folders")
2310
2746
 
2311
- 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"):
2312
2748
  """
2313
2749
  Delete an asset from the repository
2314
2750
 
@@ -2319,7 +2755,7 @@ class EntityAPI(AuthenticatedAPI):
2319
2755
 
2320
2756
  # check manager password is available:
2321
2757
  config = configparser.ConfigParser()
2322
- config.read('credentials.properties', encoding='utf-8')
2758
+ config.read(credentials_path, encoding='utf-8')
2323
2759
  try:
2324
2760
  manager_username = config['credentials']['manager.username']
2325
2761
  manager_password = config['credentials']['manager.password']
@@ -2373,7 +2809,7 @@ class EntityAPI(AuthenticatedAPI):
2373
2809
  headers=headers)
2374
2810
  elif request.status_code == requests.codes.unauthorized:
2375
2811
  self.token = self.__token__()
2376
- return self._delete_entity(entity, operator_comment, supervisor_comment)
2812
+ return self._delete_entity(entity, operator_comment, supervisor_comment, credentials_path)
2377
2813
  if request.status_code == requests.codes.unprocessable:
2378
2814
  logger.error(request.content.decode('utf-8'))
2379
2815
  raise RuntimeError(request.status_code, "no active workflow context for full deletion exists in the system")
@@ -2386,3 +2822,4 @@ class EntityAPI(AuthenticatedAPI):
2386
2822
  "_delete_entity", request.content.decode('utf-8'))
2387
2823
  logger.error(exception)
2388
2824
  raise exception
2825
+