pyPreservica 2.7.2__py3-none-any.whl → 3.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyPreservica/entityAPI.py CHANGED
@@ -8,14 +8,15 @@ 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
15
15
  from datetime import datetime, timedelta, timezone
16
16
  from io import BytesIO
17
17
  from time import sleep
18
- from typing import Any, Generator, Tuple, Iterable, Union
18
+ from typing import Any, Generator, Tuple, Iterable, Union, Callable
19
+
19
20
 
20
21
  from pyPreservica.common import *
21
22
 
@@ -34,8 +35,12 @@ class EntityAPI(AuthenticatedAPI):
34
35
  """
35
36
 
36
37
  def __init__(self, username: str = None, password: str = None, tenant: str = None, server: str = None,
37
- use_shared_secret: bool = False, two_fa_secret_key: str = None, protocol: str = "https"):
38
- 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
+
39
44
  xml.etree.ElementTree.register_namespace("oai_dc", "http://www.openarchives.org/OAI/2.0/oai_dc/")
40
45
  xml.etree.ElementTree.register_namespace("ead", "urn:isbn:1-931666-22-9")
41
46
 
@@ -54,11 +59,15 @@ class EntityAPI(AuthenticatedAPI):
54
59
 
55
60
  def bitstream_chunks(self, bitstream: Bitstream, chunk_size: int = CHUNK_SIZE) -> Generator:
56
61
  """
57
- Generator function to return bitstream chunks
62
+ Generator function to return bitstream chunks, allows the clients to
63
+ process chunks as they are downloaded.
58
64
 
59
- :param bitstream: The bitstream
60
- :param chunk_size: The chunk size to return
61
- :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
62
71
  """
63
72
  if not isinstance(bitstream, Bitstream):
64
73
  logger.error("bitstream_content argument is not a Bitstream object")
@@ -66,40 +75,40 @@ class EntityAPI(AuthenticatedAPI):
66
75
  with self.session.get(bitstream.content_url, headers={HEADER_TOKEN: self.token}, stream=True) as request:
67
76
  if request.status_code == requests.codes.unauthorized:
68
77
  self.token = self.__token__()
69
- return self.bitstream_chunks(bitstream)
78
+ yield from self.bitstream_chunks(bitstream)
70
79
  elif request.status_code == requests.codes.ok:
71
80
  for chunk in request.iter_content(chunk_size=chunk_size):
72
81
  yield chunk
73
82
  else:
74
- exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_content",
83
+ exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_chunks",
75
84
  request.content.decode('utf-8'))
76
85
  logger.error(exception)
77
86
  raise exception
78
87
 
79
88
  def bitstream_bytes(self, bitstream: Bitstream, chunk_size: int = CHUNK_SIZE) -> Union[BytesIO, None]:
80
89
  """
81
- Download a file represented as a Bitstream to a byteIO array
90
+ Download a file represented as a Bitstream to a byteIO array
82
91
 
83
- Returns the byteIO
84
- 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)
85
94
 
86
- :param chunk_size: The buffer copy chunk size in bytes default
87
- :param bitstream: A Bitstream object
88
- :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
89
98
 
90
- :return: The file in bytes
91
- :rtype: byteIO
99
+ :return: The file in bytes
100
+ :rtype: byteIO
92
101
  """
93
102
  if not isinstance(bitstream, Bitstream):
94
103
  logger.error("bitstream_content argument is not a Bitstream object")
95
104
  raise RuntimeError("bitstream_bytes argument is not a Bitstream object")
96
- with self.session.get(bitstream.content_url, headers={HEADER_TOKEN: self.token}, stream=True) as request:
97
- 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:
98
107
  self.token = self.__token__()
99
108
  return self.bitstream_bytes(bitstream)
100
- elif request.status_code == requests.codes.ok:
109
+ elif response.status_code == requests.codes.ok:
101
110
  file_bytes = BytesIO()
102
- for chunk in request.iter_content(chunk_size=chunk_size):
111
+ for chunk in response.iter_content(chunk_size=chunk_size):
103
112
  file_bytes.write(chunk)
104
113
  file_bytes.seek(0)
105
114
  if file_bytes.getbuffer().nbytes == bitstream.length:
@@ -109,11 +118,49 @@ class EntityAPI(AuthenticatedAPI):
109
118
  logger.error("Downloaded file size did not match the Preservica held value")
110
119
  return None
111
120
  else:
112
- 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",
113
158
  request.content.decode('utf-8'))
114
159
  logger.error(exception)
115
160
  raise exception
116
161
 
162
+
163
+
117
164
  def bitstream_content(self, bitstream: Bitstream, filename: str, chunk_size: int = CHUNK_SIZE) -> Union[int, None]:
118
165
  """
119
166
  Download a file represented as a Bitstream to a local filename
@@ -303,6 +350,15 @@ class EntityAPI(AuthenticatedAPI):
303
350
  IncludedGenerations
304
351
  IncludeParentHierarchy
305
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
+
306
362
  """
307
363
  status = "ACTIVE"
308
364
  pid = self.__export_opex_start__(entity, **kwargs)
@@ -317,12 +373,12 @@ class EntityAPI(AuthenticatedAPI):
317
373
 
318
374
  def download(self, entity: Entity, filename: str) -> str:
319
375
  """
320
- Download a file from an asset
321
-
322
- Returns the filename of the new file
376
+ Download the first generation of the access representation of an asset
323
377
 
324
- :param entity: The entity containing the file
325
- :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
326
382
  """
327
383
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
328
384
  params = {'id': f'sdb:{entity.entity_type.value}|{entity.reference}'}
@@ -369,13 +425,13 @@ class EntityAPI(AuthenticatedAPI):
369
425
 
370
426
  def thumbnail(self, entity: Entity, filename: str, size=Thumbnail.LARGE):
371
427
  """
372
- Download the thumbnail of an asset or folder
373
-
374
- 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
375
429
 
376
- :param entity: The entity containing the file
377
- :param filename: The filename to write the bytes to
378
- :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
379
435
  """
380
436
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/octet-stream'}
381
437
  params = {'id': f'sdb:{entity.entity_type.value}|{entity.reference}', 'size': f'{size.value}'}
@@ -400,14 +456,14 @@ class EntityAPI(AuthenticatedAPI):
400
456
 
401
457
  def delete_identifiers(self, entity: Entity, identifier_type: str = None, identifier_value: str = None):
402
458
  """
403
- Delete external identifiers from an entity
404
-
405
- Returns the entity
459
+ Delete identifiers on an Entity object
406
460
 
407
- :param entity: The entity to delete identifiers from
408
- :param identifier_type: The type of the identifier to delete.
409
- :param identifier_value: The value of the identifier to delete.
410
- """
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
+ """
411
467
 
412
468
  if (self.major_version < 7) and (self.minor_version < 1):
413
469
  raise RuntimeError("delete_identifiers API call is not available when connected to a v6.0 System")
@@ -450,14 +506,61 @@ class EntityAPI(AuthenticatedAPI):
450
506
  logger.error(request)
451
507
  raise RuntimeError(request.status_code, "delete_identifier failed")
452
508
 
453
- def identifiers_for_entity(self, entity: Entity) -> set[Tuple]:
509
+ def entity_identifiers(self, entity: Entity, external_identifier_type = None) -> set[ExternIdentifier]:
454
510
  """
455
- Get all external identifiers on an entity
511
+ Get all external identifiers on an entity
456
512
 
457
- Returns the set of external identifiers on the entity
513
+ Returns the set of external identifiers on the entity
458
514
 
459
- :param entity: The Entity (Asset or Folder)
460
- :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)
461
564
  """
462
565
  headers = {HEADER_TOKEN: self.token}
463
566
  request = self.session.get(
@@ -487,14 +590,14 @@ class EntityAPI(AuthenticatedAPI):
487
590
  logger.error(exception)
488
591
  raise exception
489
592
 
490
- def identifier(self, identifier_type: str, identifier_value: str) -> set[Entity]:
593
+ def identifier(self, identifier_type: str, identifier_value: str) -> set[EntityT]:
491
594
  """
492
- Get all entities which have the external identifier
493
-
494
- 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
495
596
 
496
- :param identifier_type: The identifier type
497
- :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)
498
601
  """
499
602
  headers = {HEADER_TOKEN: self.token}
500
603
  payload = {'type': identifier_type, 'value': identifier_value}
@@ -528,14 +631,14 @@ class EntityAPI(AuthenticatedAPI):
528
631
 
529
632
  def add_identifier(self, entity: Entity, identifier_type: str, identifier_value: str):
530
633
  """
531
- Add a new identifier to an entity
634
+ Add a new external identifier to an Entity object
532
635
 
533
- Returns the internal identifier DB key
534
-
535
- :param entity: The Entity
536
- :param identifier_type: The identifier type
537
- :param identifier_value: The identifier value
538
- """
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
+ """
539
642
 
540
643
  if self.major_version < 7 and self.minor_version < 1:
541
644
  raise RuntimeError("add_identifier API call is not available when connected to a v6.0 System")
@@ -568,6 +671,76 @@ class EntityAPI(AuthenticatedAPI):
568
671
  logger.error(exception)
569
672
  raise exception
570
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
+
571
744
  def delete_relationships(self, entity: Entity, relationship_type: str = None):
572
745
  """
573
746
  Delete a relationship between two entities by its internal id
@@ -607,7 +780,7 @@ class EntityAPI(AuthenticatedAPI):
607
780
  end_point = f"{entity.path}/{entity.reference}/links/{relationship.api_id}"
608
781
  request = self.session.delete(f'{self.protocol}://{self.server}/api/entity/{end_point}', headers=headers)
609
782
  if request.status_code == requests.codes.no_content:
610
- print(relationship)
783
+ return None
611
784
  elif request.status_code == requests.codes.unauthorized:
612
785
  self.token = self.__token__()
613
786
  return self.__delete_relationship(relationship)
@@ -626,7 +799,7 @@ class EntityAPI(AuthenticatedAPI):
626
799
  :type: page_size: int
627
800
 
628
801
  :param entity: The Source Entity
629
- :type: entity: Entity
802
+ :type: entity: An Entity type such as Asset, Folder etc
630
803
 
631
804
  :return: Generator
632
805
  :rtype: Relationship
@@ -694,7 +867,7 @@ class EntityAPI(AuthenticatedAPI):
694
867
 
695
868
  return PagedSet(results, has_more, int(total_hits.text), url)
696
869
  elif request.status_code == requests.codes.unauthorized:
697
- self.__relationships__(entity=entity, maximum=maximum, next_page=next_page)
870
+ return self.__relationships__(entity=entity, maximum=maximum, next_page=next_page)
698
871
  else:
699
872
  exception = HTTPException(entity.reference, request.status_code, request.url, "relationships",
700
873
  request.content.decode('utf-8'))
@@ -705,10 +878,10 @@ class EntityAPI(AuthenticatedAPI):
705
878
  """
706
879
  Add a new relationship link between two Assets or Folders
707
880
 
708
- :param from_entity: The Source Entity
881
+ :param from_entity: The Source entity to link from
709
882
  :type from_entity: Entity
710
883
 
711
- :param to_entity: The Target Entity
884
+ :param to_entity: The Target entity
712
885
  :type to_entity: Entity
713
886
 
714
887
  :param relationship_type: The Relationship type
@@ -752,15 +925,17 @@ class EntityAPI(AuthenticatedAPI):
752
925
  logger.error(exception)
753
926
  raise exception
754
927
 
755
- def delete_metadata(self, entity: Entity, schema: str) -> Entity:
928
+ def delete_metadata(self, entity: EntityT, schema: str) -> EntityT:
756
929
  """
757
- Deletes all the metadata fragments on an entity which match the schema URI
758
-
759
- 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
760
932
 
761
- :param entity: The Entity to delete metadata from
762
- :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
763
937
  """
938
+
764
939
  headers = {HEADER_TOKEN: self.token}
765
940
  for url in entity.metadata:
766
941
  if schema == entity.metadata[url]:
@@ -778,15 +953,45 @@ class EntityAPI(AuthenticatedAPI):
778
953
 
779
954
  return self.entity(entity.entity_type, entity.reference)
780
955
 
781
- def update_metadata(self, entity: Entity, schema: str, data: Any) -> Entity:
956
+
957
+ def add_group_metadata(self, csv_file: str) -> str:
782
958
  """
783
- Update all existing metadata fragments which match the schema
959
+ Perform bulk additions of metadata with a CSV file.
960
+ This is designed for metadata which populates a New Gen Metadata Group
961
+ Returns The process ID which will track the updates
962
+ Requires DataManagement permission
963
+
964
+ :param str csv_file: The path of the CSV metadata file
965
+ :return: The process ID
966
+ :rtype: str
967
+ """
968
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/csv;charset=UTF-8'}
969
+
970
+ url = f'{self.protocol}://{self.server}/api/entity/actions/metadata-csv-edits'
971
+
972
+ with open(csv_file, 'rb') as fd:
973
+ with self.session.post(url, headers=headers, data=fd) as request:
974
+ if request.status_code == requests.codes.unauthorized:
975
+ self.token = self.__token__()
976
+ return self.add_group_metadata(csv_file)
977
+ elif request.status_code == requests.codes.accepted:
978
+ return str(request.content.decode('utf-8'))
979
+ else:
980
+ exception = HTTPException(None, request.status_code, request.url, "add_group_metadata",
981
+ request.content.decode('utf-8'))
982
+ logger.error(exception)
983
+ raise exception
784
984
 
785
- Returns The updated Entity
786
985
 
787
- :param data: The updated XML as a string or as IO bytes
788
- :param entity: The Entity to update
789
- :param schema: The schema URI to match against
986
+ def update_metadata(self, entity: EntityT, schema: str, data: Any) -> EntityT:
987
+ """
988
+ Update an existing descriptive XML document on an entity
989
+
990
+ :param Entity entity: The entity to add the metadata to
991
+ :param str schema: The metadata schema URI
992
+ :param data data: The XML document as a string or as a file bytes
993
+ :return: The updated Entity
994
+ :rtype: Entity
790
995
  """
791
996
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
792
997
 
@@ -809,7 +1014,7 @@ class EntityAPI(AuthenticatedAPI):
809
1014
  content.append(tree.getroot())
810
1015
  else:
811
1016
  raise RuntimeError("Unknown data type")
812
- 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")
813
1018
  logger.debug(xml_request)
814
1019
  request = self.session.put(url, data=xml_request, headers=headers)
815
1020
  if request.status_code == requests.codes.ok:
@@ -824,15 +1029,51 @@ class EntityAPI(AuthenticatedAPI):
824
1029
  raise exception
825
1030
  return self.entity(entity.entity_type, entity.reference)
826
1031
 
827
- 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:
828
1033
  """
829
- 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
830
1036
 
831
1037
  Returns The updated Entity
832
1038
 
833
- :param data: The new XML as a string or as IO bytes
834
- :param entity: The Entity to update
835
- :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
836
1077
  """
837
1078
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
838
1079
 
@@ -864,13 +1105,15 @@ class EntityAPI(AuthenticatedAPI):
864
1105
  logger.error(exception)
865
1106
  raise exception
866
1107
 
867
- def save(self, entity: Entity) -> Entity:
1108
+ def save(self, entity: EntityT) -> EntityT:
868
1109
  """
869
- 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
870
1112
 
871
- Returns The updated Entity
872
-
873
- :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
874
1117
  """
875
1118
 
876
1119
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
@@ -916,6 +1159,7 @@ class EntityAPI(AuthenticatedAPI):
916
1159
  if 'CustomType' in response:
917
1160
  content_object.custom_type = response['CustomType']
918
1161
  return content_object
1162
+ return None
919
1163
  elif request.status_code == requests.codes.unauthorized:
920
1164
  self.token = self.__token__()
921
1165
  return self.save(entity)
@@ -935,8 +1179,10 @@ class EntityAPI(AuthenticatedAPI):
935
1179
 
936
1180
  Returns The updated Entity
937
1181
 
938
- :param entity: The Entity to update
939
- :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
940
1186
  """
941
1187
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
942
1188
  if isinstance(entity, Asset) and dest_folder is None:
@@ -959,7 +1205,18 @@ class EntityAPI(AuthenticatedAPI):
959
1205
  logger.error(exception)
960
1206
  raise exception
961
1207
 
1208
+ def get_progress(self, pid: str) -> AsyncProgress:
1209
+ return AsyncProgress[self.get_async_progress(pid)]
1210
+
962
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
+ """
963
1220
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
964
1221
  request = self.session.get(f"{self.protocol}://{self.server}/api/entity/progress/{pid}", headers=headers)
965
1222
  if request.status_code == requests.codes.ok:
@@ -978,16 +1235,15 @@ class EntityAPI(AuthenticatedAPI):
978
1235
  logger.error(exception)
979
1236
  raise exception
980
1237
 
981
- def move_sync(self, entity: Entity, dest_folder: Folder) -> Entity:
1238
+ def move_sync(self, entity: EntityT, dest_folder: Folder) -> EntityT:
982
1239
  """
983
- Move an Entity (Asset or Folder) to a new Folder
984
- If dest_folder is None then the entity must be a Folder and will be moved to the root of the repository
985
-
986
- Returns The updated Entity.
987
- 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
988
1242
 
989
- :param entity: The Entity to update
990
- :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
991
1247
  """
992
1248
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
993
1249
  if isinstance(entity, Asset) and dest_folder is None:
@@ -1018,15 +1274,15 @@ class EntityAPI(AuthenticatedAPI):
1018
1274
  logger.error(exception)
1019
1275
  raise exception
1020
1276
 
1021
- def move(self, entity: Entity, dest_folder: Folder) -> Entity:
1277
+ def move(self, entity: EntityT, dest_folder: Folder) -> EntityT:
1022
1278
  """
1023
- Move an Entity (Asset or Folder) to a new Folder
1024
- If dest_folder is None then the entity must be a Folder and will be moved to the root of the repository
1025
-
1026
- 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.
1027
1281
 
1028
- :param entity: The Entity to update
1029
- :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
1030
1286
  """
1031
1287
  return self.move_sync(entity, dest_folder)
1032
1288
 
@@ -1072,13 +1328,15 @@ class EntityAPI(AuthenticatedAPI):
1072
1328
  logger.error(exception)
1073
1329
  raise exception
1074
1330
 
1075
- def all_metadata(self, entity: Entity) -> Tuple:
1331
+ def all_metadata(self, entity: Entity) -> Generator[Tuple[str, str], None, None]:
1076
1332
  """
1077
1333
  Retrieve all metadata fragments on an entity
1078
1334
 
1079
1335
  Returns XML documents in a tuple
1080
1336
 
1081
- :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]]
1082
1340
  """
1083
1341
 
1084
1342
  for uri, schema in entity.metadata.items():
@@ -1086,12 +1344,12 @@ class EntityAPI(AuthenticatedAPI):
1086
1344
 
1087
1345
  def metadata_for_entity(self, entity: Entity, schema: str) -> Union[str, None]:
1088
1346
  """
1089
- Retrieve the first metadata fragment on an entity with a matching schema URI
1090
-
1091
- Returns XML document as a string
1347
+ Fetch the first metadata document which matches the schema URI from an entity
1092
1348
 
1093
- :param entity: The entity with the metadata
1094
- :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
1095
1353
  """
1096
1354
 
1097
1355
  # if the entity is a lightweight enum version request the full object
@@ -1101,9 +1359,9 @@ class EntityAPI(AuthenticatedAPI):
1101
1359
  for uri, schema_name in entity.metadata.items():
1102
1360
  if schema == schema_name:
1103
1361
  return self.metadata(uri)
1104
- return
1362
+ return None
1105
1363
 
1106
- 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]:
1107
1365
  """
1108
1366
  Retrieve the first value of the tag from a metadata template given by schema
1109
1367
 
@@ -1118,19 +1376,23 @@ class EntityAPI(AuthenticatedAPI):
1118
1376
  xml_doc = self.metadata_for_entity(entity, schema)
1119
1377
  if xml_doc:
1120
1378
  xml_object = xml.etree.ElementTree.fromstring(xml_doc)
1121
- if isXpath is False:
1379
+ if not isXpath:
1122
1380
  return xml_object.find(f'.//{{*}}{tag}').text
1123
1381
  else:
1124
1382
  return xml_object.find(tag).text
1383
+ return None
1125
1384
 
1126
- def security_tag_sync(self, entity: Entity, new_tag: str):
1385
+ def security_tag_sync(self, entity: EntityT, new_tag: str) -> EntityT:
1127
1386
  """
1128
- Change the security tag for a folder or asset
1129
-
1130
- 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.
1131
1389
 
1132
- :param entity: The entity to change
1133
- :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
1134
1396
  """
1135
1397
  self.token = self.__token__()
1136
1398
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
@@ -1157,12 +1419,15 @@ class EntityAPI(AuthenticatedAPI):
1157
1419
 
1158
1420
  def security_tag_async(self, entity: Entity, new_tag: str):
1159
1421
  """
1160
- 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.
1161
1424
 
1162
- Returns a process ID asynchronous (without blocking)
1163
-
1164
- :param entity: The entity to change
1165
- :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
1166
1431
  """
1167
1432
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
1168
1433
  end_point = f"/{entity.path}/{entity.reference}/security-descriptor"
@@ -1181,11 +1446,11 @@ class EntityAPI(AuthenticatedAPI):
1181
1446
 
1182
1447
  def metadata(self, uri: str) -> str:
1183
1448
  """
1184
- Retrieve the metadata fragment which is referenced by the URI
1185
-
1186
- Returns XML document as a string
1449
+ Fetch the metadata document by its identifier, this is the key from the entity metadata map
1187
1450
 
1188
- :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
1189
1454
  """
1190
1455
  request = self.session.get(uri, headers={HEADER_TOKEN: self.token})
1191
1456
  if request.status_code == requests.codes.ok:
@@ -1203,14 +1468,17 @@ class EntityAPI(AuthenticatedAPI):
1203
1468
  logger.error(exception)
1204
1469
  raise exception
1205
1470
 
1206
- def entity(self, entity_type: EntityType, reference: str) -> Entity:
1471
+ def entity(self, entity_type: EntityType, reference: str) -> EntityT:
1207
1472
  """
1208
- Retrieve an entity by its type and reference
1209
-
1210
- Returns Entity (Asset, Folder, ContentObject)
1473
+ Returns a generic entity based on its reference identifier
1211
1474
 
1212
- :param entity_type: The type of entity to fetch
1213
- :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
1214
1482
  """
1215
1483
  if entity_type is EntityType.CONTENT_OBJECT:
1216
1484
  return self.content_object(reference)
@@ -1218,6 +1486,7 @@ class EntityAPI(AuthenticatedAPI):
1218
1486
  return self.folder(reference)
1219
1487
  if entity_type is EntityType.ASSET:
1220
1488
  return self.asset(reference)
1489
+ return None
1221
1490
 
1222
1491
  def add_physical_asset(self, title: str, description: str, parent: Folder, security_tag: str = "open") -> Asset:
1223
1492
  """
@@ -1225,10 +1494,12 @@ class EntityAPI(AuthenticatedAPI):
1225
1494
 
1226
1495
  Returns Asset
1227
1496
 
1228
- :param title: The title of the new Asset
1229
- :param description: The description of the new Asset
1230
- :param parent: The parent folder
1231
- :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
1232
1503
  """
1233
1504
 
1234
1505
  if (self.major_version < 7) and (self.minor_version < 4):
@@ -1265,15 +1536,134 @@ class EntityAPI(AuthenticatedAPI):
1265
1536
  logger.error(exception)
1266
1537
  raise exception
1267
1538
 
1268
- 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:
1269
1627
  """
1270
1628
  Retrieve an Asset by its reference
1271
1629
 
1272
- Returns Asset
1630
+ Returns an XML document of the full Asset
1273
1631
 
1274
1632
  :param reference: The unique identifier of the entity
1275
1633
  """
1276
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}
1277
1667
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{IO_PATH}/{reference}', headers=headers)
1278
1668
  if request.status_code == requests.codes.ok:
1279
1669
  xml_response = str(request.content.decode('utf-8'))
@@ -1299,11 +1689,13 @@ class EntityAPI(AuthenticatedAPI):
1299
1689
 
1300
1690
  def folder(self, reference: str) -> Folder:
1301
1691
  """
1302
- Retrieve a Folder by its reference
1303
-
1304
- Returns Folder
1692
+ Returns a folder object back by its internal reference identifier
1305
1693
 
1306
- :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
1307
1699
  """
1308
1700
  headers = {HEADER_TOKEN: self.token}
1309
1701
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{SO_PATH}/{reference}', headers=headers)
@@ -1331,11 +1723,13 @@ class EntityAPI(AuthenticatedAPI):
1331
1723
 
1332
1724
  def content_object(self, reference: str) -> ContentObject:
1333
1725
  """
1334
- Retrieve an ContentObject by its reference
1335
-
1336
- Returns ContentObject
1726
+ Returns a content object back by its internal reference identifier
1337
1727
 
1338
- :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
1339
1733
  """
1340
1734
  headers = {HEADER_TOKEN: self.token}
1341
1735
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/{CO_PATH}/{reference}', headers=headers)
@@ -1363,11 +1757,12 @@ class EntityAPI(AuthenticatedAPI):
1363
1757
 
1364
1758
  def content_objects(self, representation: Representation) -> list[ContentObject]:
1365
1759
  """
1366
- Retrieve a list of content objects within a representation
1367
-
1368
- :param representation:
1760
+ Return a list of content objects for a representation
1369
1761
 
1370
- :returns list[ContentObject]
1762
+ :param representation: The representation
1763
+ :type representation: Representation
1764
+ :return: List of content objects
1765
+ :rtype: list(ContentObject)
1371
1766
 
1372
1767
  """
1373
1768
  headers = {HEADER_TOKEN: self.token}
@@ -1397,13 +1792,17 @@ class EntityAPI(AuthenticatedAPI):
1397
1792
  logger.error(exception)
1398
1793
  raise exception
1399
1794
 
1400
- 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
1401
1804
  """
1402
- Retrieve a list of generation objects
1403
1805
 
1404
- :param url:
1405
- :returns Generation
1406
- """
1407
1806
  headers = {HEADER_TOKEN: self.token}
1408
1807
  request = self.session.get(url, headers=headers)
1409
1808
  if request.status_code == requests.codes.ok:
@@ -1447,7 +1846,11 @@ class EntityAPI(AuthenticatedAPI):
1447
1846
  bitstreams = entity_response.findall(f'./{{{self.entity_ns}}}Bitstreams/{{{self.entity_ns}}}Bitstream')
1448
1847
  bitstream_list = []
1449
1848
  for bit in bitstreams:
1450
- 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)
1451
1854
  generation = Generation(strtobool(ge.attrib['original']), strtobool(ge.attrib['active']),
1452
1855
  format_group.text if hasattr(format_group, 'text') else None,
1453
1856
  effective_date.text if hasattr(effective_date, 'text') else None,
@@ -1532,11 +1935,12 @@ class EntityAPI(AuthenticatedAPI):
1532
1935
 
1533
1936
  def bitstream(self, url: str) -> Bitstream:
1534
1937
  """
1535
- Retrieve a bitstream by its url
1536
-
1537
- Returns Bitstream
1938
+ Fetch a bitstream object from the server using its URL
1538
1939
 
1539
- :param url:
1940
+ :param url: The URL to the bitstream
1941
+ :type url: str
1942
+ :return: a bitstream object
1943
+ :rtype: Bitstream
1540
1944
  """
1541
1945
  headers = {HEADER_TOKEN: self.token}
1542
1946
  request = self.session.get(url, headers=headers)
@@ -1572,9 +1976,16 @@ class EntityAPI(AuthenticatedAPI):
1572
1976
  def replace_generation_sync(self, content_object: ContentObject, file_name, fixity_algorithm=None,
1573
1977
  fixity_value=None) -> str:
1574
1978
  """
1575
- 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.
1576
1980
 
1577
- Starts the workflow and blocks until the workflow completes.
1981
+ Starts the workflow and blocks until the workflow completes.
1982
+
1983
+ :param ContentObject content_object: The content object to replace
1984
+ :param str file_name: The path to the new content object
1985
+ :param str fixity_algorithm: Optional fixity algorithm
1986
+ :param str fixity_value: Optional fixity value
1987
+ :return: Completed workflow status
1988
+ :rtype: str
1578
1989
 
1579
1990
  """
1580
1991
 
@@ -1593,7 +2004,14 @@ class EntityAPI(AuthenticatedAPI):
1593
2004
  """
1594
2005
  Replace the last active generation of a content object with a new digital file.
1595
2006
 
1596
- 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
1597
2015
 
1598
2016
  """
1599
2017
  if (self.major_version < 7) and (self.minor_version < 2) and (self.patch_version < 1):
@@ -1647,11 +2065,12 @@ class EntityAPI(AuthenticatedAPI):
1647
2065
 
1648
2066
  def generations(self, content_object: ContentObject) -> list[Generation]:
1649
2067
  """
1650
- Retrieve list of generations on a content object
2068
+ Return a list of Generation objects for a content object
1651
2069
 
1652
- Returns list
1653
-
1654
- :param content_object:
2070
+ :param content_object: The content object
2071
+ :type content_object: ContentObject
2072
+ :return: list of generations
2073
+ :rtype: list(Generation)
1655
2074
  """
1656
2075
  headers = {HEADER_TOKEN: self.token}
1657
2076
  request = self.session.get(
@@ -1664,7 +2083,7 @@ class EntityAPI(AuthenticatedAPI):
1664
2083
  result = []
1665
2084
  for g in generations:
1666
2085
  if hasattr(g, 'text'):
1667
- generation = self.generation(g.text)
2086
+ generation = self.generation(g.text, content_object.reference)
1668
2087
  generation.asset = content_object.asset
1669
2088
  generation.content_object = content_object
1670
2089
  generation.representation_type = content_object.representation_type
@@ -1681,13 +2100,11 @@ class EntityAPI(AuthenticatedAPI):
1681
2100
 
1682
2101
  def bitstreams_for_asset(self, asset: Union[Asset, Entity]) -> Iterable[Bitstream]:
1683
2102
  """
1684
-
1685
- Return all the bitstreams within an asset.
2103
+ Return all the active bitstreams within an asset.
1686
2104
  This includes all the representations and content objects
1687
2105
 
1688
-
1689
2106
  :param asset: The asset
1690
- :return:
2107
+ :return: Iterable
1691
2108
  """
1692
2109
 
1693
2110
  for representation in self.representations(asset):
@@ -1702,10 +2119,14 @@ class EntityAPI(AuthenticatedAPI):
1702
2119
 
1703
2120
  def representations(self, asset: Asset) -> set[Representation]:
1704
2121
  """
1705
- 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.
1706
2125
 
1707
- :param asset: The asset
1708
- :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)
1709
2130
  """
1710
2131
  headers = {HEADER_TOKEN: self.token}
1711
2132
  if not isinstance(asset, Asset):
@@ -1733,11 +2154,11 @@ class EntityAPI(AuthenticatedAPI):
1733
2154
 
1734
2155
  def remove_thumbnail(self, entity: Entity):
1735
2156
  """
1736
- remove a thumbnail icon to a folder or asset
2157
+ Remove the thumbnail for the entity to the uploaded image
1737
2158
 
1738
-
1739
- :param entity: The Entity
1740
- """
2159
+ :param entity: The entity with the thumbnail
2160
+ :type entity: Entity
2161
+ """
1741
2162
  if self.major_version < 7 and self.minor_version < 2:
1742
2163
  raise RuntimeError("Thumbnail API is only available when connected to a v6.2 System")
1743
2164
 
@@ -1760,13 +2181,14 @@ class EntityAPI(AuthenticatedAPI):
1760
2181
  logger.error(exception)
1761
2182
  raise exception
1762
2183
 
2184
+
1763
2185
  def add_access_representation(self, entity: Entity, access_file: str, name: str = "Access"):
1764
2186
  """
1765
- Add a new representation to an existing asset.
2187
+ Add a new Access representation to an existing asset.
1766
2188
 
1767
- :param entity: The existing asset which will receive the new representation
1768
- :param access_file: The new digital file
1769
- :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"
1770
2192
  :return:
1771
2193
  """
1772
2194
 
@@ -1799,12 +2221,14 @@ class EntityAPI(AuthenticatedAPI):
1799
2221
 
1800
2222
  def add_thumbnail(self, entity: Entity, image_file: str):
1801
2223
  """
1802
- add a thumbnail icon to a folder or asset
2224
+ Set the thumbnail for the entity to the uploaded image
1803
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
+ """
1804
2231
 
1805
- :param entity: The Entity
1806
- :param image_file: Path to image file
1807
- """
1808
2232
  if self.major_version < 7 and self.minor_version < 2:
1809
2233
  raise RuntimeError("Thumbnail API is only available when connected to a v6.2 System")
1810
2234
 
@@ -1845,7 +2269,7 @@ class EntityAPI(AuthenticatedAPI):
1845
2269
  params=params, headers=headers)
1846
2270
 
1847
2271
  if request.status_code == requests.codes.ok:
1848
- pass
2272
+ return None
1849
2273
  elif request.status_code == requests.codes.unauthorized:
1850
2274
  self.token = self.__token__()
1851
2275
  return self._event_actions(entity, maximum=maximum)
@@ -1857,11 +2281,14 @@ class EntityAPI(AuthenticatedAPI):
1857
2281
 
1858
2282
  def all_descendants(self, folder: Union[Folder, Entity] = None) -> Generator[Entity, None, None]:
1859
2283
  """
1860
- 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.
1861
2287
 
1862
- 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)
1863
2291
 
1864
- :param folder: The folder to find children of
1865
2292
  """
1866
2293
  for entity in self.descendants(folder=folder):
1867
2294
  yield entity
@@ -1869,6 +2296,17 @@ class EntityAPI(AuthenticatedAPI):
1869
2296
  yield from self.all_descendants(folder=entity)
1870
2297
 
1871
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
+
1872
2310
  maximum = 100
1873
2311
  paged_set = self.children(folder, maximum=maximum, next_page=None)
1874
2312
  for entity in paged_set.results:
@@ -1878,7 +2316,23 @@ class EntityAPI(AuthenticatedAPI):
1878
2316
  for entity in paged_set.results:
1879
2317
  yield entity
1880
2318
 
2319
+
2320
+
1881
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
+
1882
2336
  headers = {HEADER_TOKEN: self.token}
1883
2337
  data = {'start': str(0), 'max': str(maximum)}
1884
2338
 
@@ -1925,12 +2379,22 @@ class EntityAPI(AuthenticatedAPI):
1925
2379
  self.token = self.__token__()
1926
2380
  return self.children(folder_reference, maximum=maximum, next_page=next_page)
1927
2381
  else:
1928
- exception = HTTPException(folder.reference, request.status_code, request.url,
2382
+ exception = HTTPException(folder_reference, request.status_code, request.url,
1929
2383
  "children", request.content.decode('utf-8'))
1930
2384
  logger.error(exception)
1931
2385
  raise exception
1932
2386
 
1933
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
+
1934
2398
  self.token = self.__token__()
1935
2399
  previous = datetime.utcnow() - timedelta(days=previous_days)
1936
2400
  from_date = previous.replace(tzinfo=timezone.utc).isoformat()
@@ -1946,6 +2410,14 @@ class EntityAPI(AuthenticatedAPI):
1946
2410
  yield entity
1947
2411
 
1948
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
+ """
1949
2421
  self.token = self.__token__()
1950
2422
  paged_set = self._all_events_page()
1951
2423
  for entity in paged_set.results:
@@ -1971,9 +2443,15 @@ class EntityAPI(AuthenticatedAPI):
1971
2443
  actions = entity_response.findall(f'.//{{{self.xip_ns}}}EventAction')
1972
2444
  result_list = []
1973
2445
  for action in actions:
1974
- entity_ref = action.findall(f'.//{{{self.xip_ns}}}Entity')
1975
- for refs in entity_ref:
1976
- 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)
1977
2455
  next_url = entity_response.find(f'.//{{{self.entity_ns}}}Next')
1978
2456
  total_hits = entity_response.find(f'.//{{{self.entity_ns}}}TotalResults')
1979
2457
  has_more = True
@@ -1983,8 +2461,17 @@ class EntityAPI(AuthenticatedAPI):
1983
2461
  else:
1984
2462
  url = next_url.text
1985
2463
  return PagedSet(result_list, has_more, int(total_hits.text), url)
2464
+ return None
2465
+
2466
+
2467
+
1986
2468
 
1987
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
+ """
1988
2475
  self.token = self.__token__()
1989
2476
  paged_set = self._entity_from_event_page(event_id, 25, None)
1990
2477
  for entity in paged_set.results:
@@ -2007,6 +2494,8 @@ class EntityAPI(AuthenticatedAPI):
2007
2494
  params["from"] = kwargs.get("from_date")
2008
2495
  if "to_date" in kwargs:
2009
2496
  params["to"] = kwargs.get("to_date")
2497
+ if "username" in kwargs:
2498
+ params["username"] = kwargs.get("username")
2010
2499
 
2011
2500
  if next_page is None:
2012
2501
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/events', params=params,
@@ -2137,6 +2626,16 @@ class EntityAPI(AuthenticatedAPI):
2137
2626
  raise exception
2138
2627
 
2139
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
+ """
2140
2639
  self.token = self.__token__()
2141
2640
  paged_set = self._entity_events_page(entity)
2142
2641
  for entity in paged_set.results:
@@ -2147,6 +2646,17 @@ class EntityAPI(AuthenticatedAPI):
2147
2646
  yield entity
2148
2647
 
2149
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
+
2150
2660
  self.token = self.__token__()
2151
2661
  maximum = 25
2152
2662
  paged_set = self._updated_entities_page(previous_days=previous_days, maximum=maximum, next_page=None)
@@ -2204,34 +2714,37 @@ class EntityAPI(AuthenticatedAPI):
2204
2714
  logger.error(exception)
2205
2715
  raise exception
2206
2716
 
2207
- 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"):
2208
2718
  """
2209
- Delete an asset from the repository
2719
+ Initiate and approve the deletion of an asset.
2210
2720
 
2211
- :param asset: The Asset
2212
- :param operator_comment: The operator comment on the deletion
2213
- :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
2214
2726
  """
2215
2727
  if isinstance(asset, Asset):
2216
- return self._delete_entity(asset, operator_comment, supervisor_comment)
2728
+ return self._delete_entity(asset, operator_comment, supervisor_comment, credentials_path)
2217
2729
  else:
2218
2730
  raise RuntimeError("delete_asset only deletes assets")
2219
2731
 
2220
- 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"):
2221
2733
  """
2222
- Delete an asset from the repository
2223
-
2734
+ Initiate and approve the deletion of a folder.
2224
2735
 
2225
- :param folder: The Folder
2226
- :param operator_comment: The operator comment on the deletion
2227
- :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
2228
2741
  """
2229
2742
  if isinstance(folder, Folder):
2230
- return self._delete_entity(folder, operator_comment, supervisor_comment)
2743
+ return self._delete_entity(folder, operator_comment, supervisor_comment, credentials_path)
2231
2744
  else:
2232
2745
  raise RuntimeError("delete_folder only deletes folders")
2233
2746
 
2234
- 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"):
2235
2748
  """
2236
2749
  Delete an asset from the repository
2237
2750
 
@@ -2242,7 +2755,7 @@ class EntityAPI(AuthenticatedAPI):
2242
2755
 
2243
2756
  # check manager password is available:
2244
2757
  config = configparser.ConfigParser()
2245
- config.read('credentials.properties', encoding='utf-8')
2758
+ config.read(credentials_path, encoding='utf-8')
2246
2759
  try:
2247
2760
  manager_username = config['credentials']['manager.username']
2248
2761
  manager_password = config['credentials']['manager.password']
@@ -2271,6 +2784,8 @@ class EntityAPI(AuthenticatedAPI):
2271
2784
  entity_response = xml.etree.ElementTree.fromstring(req.content.decode("utf-8"))
2272
2785
  status = entity_response.find(".//{http://status.preservica.com}Status")
2273
2786
  if hasattr(status, 'text'):
2787
+ if status.text == "COMPLETED":
2788
+ return entity.reference
2274
2789
  if status.text == "PENDING":
2275
2790
  headers = {HEADER_TOKEN: self.manager_token(manager_username, manager_password),
2276
2791
  'Content-Type': 'application/xml;charset=UTF-8'}
@@ -2294,7 +2809,7 @@ class EntityAPI(AuthenticatedAPI):
2294
2809
  headers=headers)
2295
2810
  elif request.status_code == requests.codes.unauthorized:
2296
2811
  self.token = self.__token__()
2297
- return self._delete_entity(entity, operator_comment, supervisor_comment)
2812
+ return self._delete_entity(entity, operator_comment, supervisor_comment, credentials_path)
2298
2813
  if request.status_code == requests.codes.unprocessable:
2299
2814
  logger.error(request.content.decode('utf-8'))
2300
2815
  raise RuntimeError(request.status_code, "no active workflow context for full deletion exists in the system")
@@ -2307,3 +2822,4 @@ class EntityAPI(AuthenticatedAPI):
2307
2822
  "_delete_entity", request.content.decode('utf-8'))
2308
2823
  logger.error(exception)
2309
2824
  raise exception
2825
+