pyPreservica 3.2.1__tar.gz → 3.2.3__tar.gz

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.

Files changed (50) hide show
  1. {pypreservica-3.2.1 → pypreservica-3.2.3}/PKG-INFO +4 -3
  2. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/__init__.py +1 -1
  3. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/adminAPI.py +19 -13
  4. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/authorityAPI.py +1 -1
  5. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/common.py +14 -5
  6. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/entityAPI.py +118 -32
  7. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/mdformsAPI.py +20 -0
  8. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/webHooksAPI.py +1 -0
  9. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica.egg-info/PKG-INFO +4 -3
  10. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica.egg-info/requires.txt +2 -2
  11. {pypreservica-3.2.1 → pypreservica-3.2.3}/setup.py +3 -2
  12. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_export_opex.py +29 -4
  13. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_schema.py +11 -11
  14. {pypreservica-3.2.1 → pypreservica-3.2.3}/LICENSE.txt +0 -0
  15. {pypreservica-3.2.1 → pypreservica-3.2.3}/README.md +0 -0
  16. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/contentAPI.py +0 -0
  17. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/monitorAPI.py +0 -0
  18. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/opex.py +0 -0
  19. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/parAPI.py +0 -0
  20. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/retentionAPI.py +0 -0
  21. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/settingsAPI.py +0 -0
  22. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/uploadAPI.py +0 -0
  23. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica/workflowAPI.py +0 -0
  24. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica.egg-info/SOURCES.txt +0 -0
  25. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica.egg-info/dependency_links.txt +0 -0
  26. {pypreservica-3.2.1 → pypreservica-3.2.3}/pyPreservica.egg-info/top_level.txt +0 -0
  27. {pypreservica-3.2.1 → pypreservica-3.2.3}/setup.cfg +0 -0
  28. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_authority_records.py +0 -0
  29. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_bitstream.py +0 -0
  30. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_children.py +0 -0
  31. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_content_api.py +0 -0
  32. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_crawl_fs.py +0 -0
  33. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_delete.py +0 -0
  34. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_download.py +0 -0
  35. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_entity.py +0 -0
  36. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_groups.py +0 -0
  37. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_identifier.py +0 -0
  38. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_ingest.py +0 -0
  39. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_integrity_check.py +0 -0
  40. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_metadata.py +0 -0
  41. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_par.py +0 -0
  42. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_replace.py +0 -0
  43. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_retention.py +0 -0
  44. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_security.py +0 -0
  45. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_settings.py +0 -0
  46. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_thumbnail.py +0 -0
  47. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_upload.py +0 -0
  48. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_users.py +0 -0
  49. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_workflow.py +0 -0
  50. {pypreservica-3.2.1 → pypreservica-3.2.3}/tests/test_xml_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyPreservica
3
- Version: 3.2.1
3
+ Version: 3.2.3
4
4
  Summary: Python library for the Preservica API
5
5
  Home-page: https://pypreservica.readthedocs.io/
6
6
  Author: James Carr
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.9
16
16
  Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
19
20
  Classifier: Operating System :: OS Independent
20
21
  Classifier: Topic :: System :: Archiving
21
22
  Description-Content-Type: text/markdown
@@ -23,8 +24,8 @@ License-File: LICENSE.txt
23
24
  Requires-Dist: requests
24
25
  Requires-Dist: urllib3
25
26
  Requires-Dist: certifi
26
- Requires-Dist: boto3>=1.36.0
27
- Requires-Dist: botocore>=1.36.0
27
+ Requires-Dist: boto3>=1.38.0
28
+ Requires-Dist: botocore>=1.38.0
28
29
  Requires-Dist: s3transfer
29
30
  Requires-Dist: azure-storage-blob
30
31
  Requires-Dist: tqdm
@@ -35,6 +35,6 @@ from .settingsAPI import SettingsAPI
35
35
  __author__ = "James Carr (drjamescarr@gmail.com)"
36
36
 
37
37
  # Version of the pyPreservica package
38
- __version__ = "3.2.1"
38
+ __version__ = "3.2.3"
39
39
 
40
40
  __license__ = "Apache License Version 2.0"
@@ -10,7 +10,7 @@ licence: Apache License 2.0
10
10
  """
11
11
  import csv
12
12
  import xml.etree.ElementTree
13
- from typing import List, Any
13
+ from typing import List, Any, Union
14
14
 
15
15
  from pyPreservica.common import *
16
16
 
@@ -36,7 +36,7 @@ class AdminAPI(AuthenticatedAPI):
36
36
  request = self.session.delete(f'{self.protocol}://{self.server}/api/admin/security/roles/{role_name}',
37
37
  headers=headers)
38
38
  if request.status_code == requests.codes.no_content:
39
- return
39
+ return None
40
40
  elif request.status_code == requests.codes.unauthorized:
41
41
  self.token = self.__token__()
42
42
  return self.delete_system_role(role_name)
@@ -61,7 +61,7 @@ class AdminAPI(AuthenticatedAPI):
61
61
  request = self.session.delete(f'{self.protocol}://{self.server}/api/admin/security/tags/{tag_name}',
62
62
  headers=headers)
63
63
  if request.status_code == requests.codes.no_content:
64
- return
64
+ return None
65
65
  elif request.status_code == requests.codes.unauthorized:
66
66
  self.token = self.__token__()
67
67
  return self.delete_security_tag(tag_name)
@@ -211,7 +211,7 @@ class AdminAPI(AuthenticatedAPI):
211
211
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
212
212
  request = self.session.delete(f'{self.protocol}://{self.server}/api/admin/users/{username}', headers=headers)
213
213
  if request.status_code == requests.codes.no_content:
214
- return
214
+ return None
215
215
  elif request.status_code == requests.codes.unauthorized:
216
216
  self.token = self.__token__()
217
217
  return self.delete_user(username)
@@ -463,7 +463,7 @@ class AdminAPI(AuthenticatedAPI):
463
463
  params=params,
464
464
  data=xml_data)
465
465
  if request.status_code == requests.codes.created:
466
- return
466
+ return None
467
467
  elif request.status_code == requests.codes.unauthorized:
468
468
  self.token = self.__token__()
469
469
  return self.add_xml_schema(name, description, originalName, xml_data)
@@ -513,7 +513,7 @@ class AdminAPI(AuthenticatedAPI):
513
513
  params=params,
514
514
  data=xml_data)
515
515
  if request.status_code == requests.codes.created:
516
- return
516
+ return None
517
517
  elif request.status_code == requests.codes.unauthorized:
518
518
  self.token = self.__token__()
519
519
  return self.add_xml_document(name, xml_data, document_type)
@@ -543,13 +543,14 @@ class AdminAPI(AuthenticatedAPI):
543
543
  f"{self.protocol}://{self.server}/api/admin/documents/{document['ApiId']}",
544
544
  headers=headers)
545
545
  if request.status_code == requests.codes.no_content:
546
- return
546
+ return None
547
547
  elif request.status_code == requests.codes.unauthorized:
548
548
  self.token = self.__token__()
549
549
  return self.delete_xml_document(uri)
550
550
  else:
551
551
  logger.error(request.content.decode('utf-8'))
552
552
  raise RuntimeError(request.status_code, "delete_xml_document failed")
553
+ return None
553
554
 
554
555
  def delete_xml_schema(self, uri: str):
555
556
  """
@@ -572,15 +573,16 @@ class AdminAPI(AuthenticatedAPI):
572
573
  request = self.session.delete(f"{self.protocol}://{self.server}/api/admin/schemas/{schema['ApiId']}",
573
574
  headers=headers)
574
575
  if request.status_code == requests.codes.no_content:
575
- return
576
+ return None
576
577
  elif request.status_code == requests.codes.unauthorized:
577
578
  self.token = self.__token__()
578
579
  return self.delete_xml_schema(uri)
579
580
  else:
580
581
  logger.error(request.content.decode('utf-8'))
581
582
  raise RuntimeError(request.status_code, "delete_xml_schema failed")
583
+ return None
582
584
 
583
- def xml_schema(self, uri: str) -> str:
585
+ def xml_schema(self, uri: str) -> Union[str, None]:
584
586
  """
585
587
  Fetch the metadata schema XSD document as a string by its URI
586
588
 
@@ -607,8 +609,9 @@ class AdminAPI(AuthenticatedAPI):
607
609
  else:
608
610
  logger.error(request.content.decode('utf-8'))
609
611
  raise RuntimeError(request.status_code, "xml_schema failed")
612
+ return None
610
613
 
611
- def xml_document(self, uri: str) -> str:
614
+ def xml_document(self, uri: str) -> Union[str, None]:
612
615
  """
613
616
  fetch the metadata XML document as a string by its URI
614
617
 
@@ -634,6 +637,7 @@ class AdminAPI(AuthenticatedAPI):
634
637
  else:
635
638
  logger.error(request.content.decode('utf-8'))
636
639
  raise RuntimeError(request.status_code, "xml_document failed")
640
+ return None
637
641
 
638
642
  def xml_documents(self) -> List:
639
643
  """
@@ -754,7 +758,7 @@ class AdminAPI(AuthenticatedAPI):
754
758
  logger.error(request.content.decode('utf-8'))
755
759
  raise RuntimeError(request.status_code, "xml_transforms failed")
756
760
 
757
- def xml_transform(self, input_uri: str, output_uri: str) -> str:
761
+ def xml_transform(self, input_uri: str, output_uri: str) -> Union[str, None]:
758
762
  """
759
763
  fetch the XML transform as a string by its URIs
760
764
 
@@ -782,6 +786,7 @@ class AdminAPI(AuthenticatedAPI):
782
786
  else:
783
787
  logger.error(request.content.decode('utf-8'))
784
788
  raise RuntimeError(request.status_code, "xml_transform failed")
789
+ return None
785
790
 
786
791
  def delete_xml_transform(self, input_uri: str, output_uri: str):
787
792
  """
@@ -808,13 +813,14 @@ class AdminAPI(AuthenticatedAPI):
808
813
  f"{self.protocol}://{self.server}/api/admin/transforms/{transform['ApiId']}",
809
814
  headers=headers)
810
815
  if request.status_code == requests.codes.no_content:
811
- return
816
+ return None
812
817
  elif request.status_code == requests.codes.unauthorized:
813
818
  self.token = self.__token__()
814
819
  return self.delete_xml_transform(input_uri, output_uri)
815
820
  else:
816
821
  logger.error(request.content.decode('utf-8'))
817
822
  raise RuntimeError(request.status_code, "delete_xml_transform failed")
823
+ return None
818
824
 
819
825
  def add_xml_transform(self, name: str, input_uri: str, output_uri: str, purpose: str, originalName: str,
820
826
  xml_data: Any):
@@ -860,7 +866,7 @@ class AdminAPI(AuthenticatedAPI):
860
866
  params=params,
861
867
  data=xml_data)
862
868
  if request.status_code == requests.codes.created:
863
- return
869
+ return None
864
870
 
865
871
  if request.status_code == requests.codes.unauthorized:
866
872
  self.token = self.__token__()
@@ -54,7 +54,7 @@ class AuthorityAPI(AuthenticatedAPI):
54
54
  self.token = self.__token__()
55
55
  return self.delete_record(reference)
56
56
  if response.status_code == requests.codes.no_content:
57
- return
57
+ return None
58
58
  else:
59
59
  exception = HTTPException("", response.status_code, response.url, "delete_record",
60
60
  response.content.decode('utf-8'))
@@ -673,19 +673,24 @@ class AuthenticatedAPI:
673
673
  logger.error(f"The AdminAPI requires the user to have ROLE_SDB_MANAGER_USER")
674
674
  raise RuntimeError(f"The API requires the user to have at least the ROLE_SDB_MANAGER_USER")
675
675
 
676
- def _find_user_roles_(self) -> list:
676
+ def _find_user_roles_(self) -> list[str]:
677
677
  """
678
678
  Get a list of roles for the user
679
679
  :return list of roles:
680
680
  """
681
- headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
681
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/json'}
682
682
  request = self.session.get(f"{self.protocol}://{self.server}/api/user/details", headers=headers)
683
+ logger.debug(request.headers)
683
684
  if request.status_code == requests.codes.ok:
684
- roles = json.loads(str(request.content.decode('utf-8')))['roles']
685
+ json_document = str(request.content.decode('utf-8'))
686
+ logger.debug(json_document)
687
+ roles: list[str] = json.loads(json_document)['roles']
685
688
  return roles
686
689
  elif request.status_code == requests.codes.unauthorized:
687
690
  self.token = self.__token__()
688
691
  return self._find_user_roles_()
692
+ return []
693
+
689
694
 
690
695
  def security_tags_base(self, with_permissions: bool = False) -> dict:
691
696
  """
@@ -816,6 +821,10 @@ class AuthenticatedAPI:
816
821
  self.major_version = int(version_numbers[0])
817
822
  self.minor_version = int(version_numbers[1])
818
823
  self.patch_version = int(version_numbers[2])
824
+
825
+ if self.server == "preview.preservica.com":
826
+ self.minor_version = 1
827
+
819
828
  return version
820
829
  elif request.status_code == requests.codes.unauthorized:
821
830
  self.token = self.__token__()
@@ -843,7 +852,7 @@ class AuthenticatedAPI:
843
852
  with open('credentials.properties', 'wt', encoding="utf-8") as configfile:
844
853
  config.write(configfile)
845
854
 
846
- def manager_token(self, username: str, password: str):
855
+ def manager_token(self, username: str, password: str) -> str:
847
856
  data = {'username': username, 'password': password, 'tenant': self.tenant}
848
857
  response = self.session.post(f'{self.protocol}://{self.server}/api/accesstoken/login', data=data)
849
858
  if response.status_code == requests.codes.ok:
@@ -855,7 +864,7 @@ class AuthenticatedAPI:
855
864
  logger.error(str(response.content))
856
865
  RuntimeError(response.status_code, "Could not generate valid manager approval token")
857
866
 
858
- def __token__(self):
867
+ def __token__(self) -> str:
859
868
  """
860
869
  Generate am API token to use to authenticate calls
861
870
  :return: API Token
@@ -82,28 +82,28 @@ class EntityAPI(AuthenticatedAPI):
82
82
 
83
83
  def bitstream_bytes(self, bitstream: Bitstream, chunk_size: int = CHUNK_SIZE) -> Union[BytesIO, None]:
84
84
  """
85
- Download a file represented as a Bitstream to a byteIO array
85
+ Download a file represented as a Bitstream to a byteIO array
86
86
 
87
- Returns the byteIO
88
- Returns None if the file does not contain the correct number of bytes (default 2k)
87
+ Returns the byteIO
88
+ Returns None if the file does not contain the correct number of bytes (default 2k)
89
89
 
90
- :param chunk_size: The buffer copy chunk size in bytes default
91
- :param bitstream: A Bitstream object
92
- :type bitstream: Bitstream
90
+ :param chunk_size: The buffer copy chunk size in bytes default
91
+ :param bitstream: A Bitstream object
92
+ :type bitstream: Bitstream
93
93
 
94
- :return: The file in bytes
95
- :rtype: byteIO
94
+ :return: The file in bytes
95
+ :rtype: byteIO
96
96
  """
97
97
  if not isinstance(bitstream, Bitstream):
98
98
  logger.error("bitstream_content argument is not a Bitstream object")
99
99
  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:
100
+ with self.session.get(bitstream.content_url, headers={HEADER_TOKEN: self.token}, stream=True) as response:
101
+ if response.status_code == requests.codes.unauthorized:
102
102
  self.token = self.__token__()
103
103
  return self.bitstream_bytes(bitstream)
104
- elif request.status_code == requests.codes.ok:
104
+ elif response.status_code == requests.codes.ok:
105
105
  file_bytes = BytesIO()
106
- for chunk in request.iter_content(chunk_size=chunk_size):
106
+ for chunk in response.iter_content(chunk_size=chunk_size):
107
107
  file_bytes.write(chunk)
108
108
  file_bytes.seek(0)
109
109
  if file_bytes.getbuffer().nbytes == bitstream.length:
@@ -113,8 +113,8 @@ class EntityAPI(AuthenticatedAPI):
113
113
  logger.error("Downloaded file size did not match the Preservica held value")
114
114
  return None
115
115
  else:
116
- exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_content",
117
- request.content.decode('utf-8'))
116
+ exception = HTTPException(bitstream.filename, response.status_code, response.url, "bitstream_content",
117
+ response.content.decode('utf-8'))
118
118
  logger.error(exception)
119
119
  raise exception
120
120
 
@@ -131,23 +131,24 @@ class EntityAPI(AuthenticatedAPI):
131
131
  url: str = f'{self.protocol}://{self.server}/api/entity/content-objects/{bitstream.co_ref}/generations/{bitstream.gen_index}/bitstreams/{bitstream.bs_index}/storage-locations'
132
132
 
133
133
  with self.session.get(url, headers={HEADER_TOKEN: self.token}, stream=True) as request:
134
- if request.status_code == requests.codes.unauthorized:
135
- self.token = self.__token__()
136
- return self.bitstream_location(bitstream)
137
- elif request.status_code == requests.codes.ok:
134
+ if request.status_code == requests.codes.ok:
138
135
  xml_response = str(request.content.decode('utf-8'))
139
136
  entity_response = xml.etree.ElementTree.fromstring(xml_response)
140
137
  logger.debug(xml_response)
141
138
  locations = entity_response.find(f'.//{{{self.entity_ns}}}StorageLocation')
142
139
  for adapter in locations:
143
140
  storage_locations.append(adapter.attrib['name'])
141
+ return storage_locations
142
+
143
+ if request.status_code == requests.codes.unauthorized:
144
+ self.token = self.__token__()
145
+ return self.bitstream_location(bitstream)
144
146
  else:
145
147
  exception = HTTPException(bitstream.filename, request.status_code, request.url, "bitstream_location",
146
148
  request.content.decode('utf-8'))
147
149
  logger.error(exception)
148
150
  raise exception
149
151
 
150
- return storage_locations
151
152
 
152
153
 
153
154
  def bitstream_content(self, bitstream: Bitstream, filename: str, chunk_size: int = CHUNK_SIZE) -> Union[int, None]:
@@ -534,7 +535,6 @@ class EntityAPI(AuthenticatedAPI):
534
535
  logger.error(exception)
535
536
  raise exception
536
537
 
537
-
538
538
  def identifiers_for_entity(self, entity: Entity) -> set[Tuple]:
539
539
  """
540
540
  Get all external identifiers on an entity
@@ -572,8 +572,6 @@ class EntityAPI(AuthenticatedAPI):
572
572
  logger.error(exception)
573
573
  raise exception
574
574
 
575
-
576
-
577
575
  def identifier(self, identifier_type: str, identifier_value: str) -> set[EntityT]:
578
576
  """
579
577
  Get all entities which have the external identifier
@@ -764,7 +762,7 @@ class EntityAPI(AuthenticatedAPI):
764
762
  end_point = f"{entity.path}/{entity.reference}/links/{relationship.api_id}"
765
763
  request = self.session.delete(f'{self.protocol}://{self.server}/api/entity/{end_point}', headers=headers)
766
764
  if request.status_code == requests.codes.no_content:
767
- print(relationship)
765
+ return None
768
766
  elif request.status_code == requests.codes.unauthorized:
769
767
  self.token = self.__token__()
770
768
  return self.__delete_relationship(relationship)
@@ -783,7 +781,7 @@ class EntityAPI(AuthenticatedAPI):
783
781
  :type: page_size: int
784
782
 
785
783
  :param entity: The Source Entity
786
- :type: entity: Entity
784
+ :type: entity: An Entity type such as Asset, Folder etc
787
785
 
788
786
  :return: Generator
789
787
  :rtype: Relationship
@@ -851,7 +849,7 @@ class EntityAPI(AuthenticatedAPI):
851
849
 
852
850
  return PagedSet(results, has_more, int(total_hits.text), url)
853
851
  elif request.status_code == requests.codes.unauthorized:
854
- self.__relationships__(entity=entity, maximum=maximum, next_page=next_page)
852
+ return self.__relationships__(entity=entity, maximum=maximum, next_page=next_page)
855
853
  else:
856
854
  exception = HTTPException(entity.reference, request.status_code, request.url, "relationships",
857
855
  request.content.decode('utf-8'))
@@ -1112,6 +1110,7 @@ class EntityAPI(AuthenticatedAPI):
1112
1110
  if 'CustomType' in response:
1113
1111
  content_object.custom_type = response['CustomType']
1114
1112
  return content_object
1113
+ return None
1115
1114
  elif request.status_code == requests.codes.unauthorized:
1116
1115
  self.token = self.__token__()
1117
1116
  return self.save(entity)
@@ -1158,7 +1157,6 @@ class EntityAPI(AuthenticatedAPI):
1158
1157
  def get_progress(self, pid: str) -> AsyncProgress:
1159
1158
  return AsyncProgress[self.get_async_progress(pid)]
1160
1159
 
1161
-
1162
1160
  def get_async_progress(self, pid: str) -> str:
1163
1161
  headers = {HEADER_TOKEN: self.token, 'Content-Type': 'text/plain'}
1164
1162
  request = self.session.get(f"{self.protocol}://{self.server}/api/entity/progress/{pid}", headers=headers)
@@ -1301,9 +1299,9 @@ class EntityAPI(AuthenticatedAPI):
1301
1299
  for uri, schema_name in entity.metadata.items():
1302
1300
  if schema == schema_name:
1303
1301
  return self.metadata(uri)
1304
- return
1302
+ return None
1305
1303
 
1306
- def metadata_tag_for_entity(self, entity: Entity, schema: str, tag: str, isXpath: bool = False) -> str:
1304
+ def metadata_tag_for_entity(self, entity: Entity, schema: str, tag: str, isXpath: bool = False) -> Union[str, None]:
1307
1305
  """
1308
1306
  Retrieve the first value of the tag from a metadata template given by schema
1309
1307
 
@@ -1318,10 +1316,11 @@ class EntityAPI(AuthenticatedAPI):
1318
1316
  xml_doc = self.metadata_for_entity(entity, schema)
1319
1317
  if xml_doc:
1320
1318
  xml_object = xml.etree.ElementTree.fromstring(xml_doc)
1321
- if isXpath is False:
1319
+ if not isXpath:
1322
1320
  return xml_object.find(f'.//{{*}}{tag}').text
1323
1321
  else:
1324
1322
  return xml_object.find(tag).text
1323
+ return None
1325
1324
 
1326
1325
  def security_tag_sync(self, entity: EntityT, new_tag: str) -> EntityT:
1327
1326
  """
@@ -1418,6 +1417,7 @@ class EntityAPI(AuthenticatedAPI):
1418
1417
  return self.folder(reference)
1419
1418
  if entity_type is EntityType.ASSET:
1420
1419
  return self.asset(reference)
1420
+ return None
1421
1421
 
1422
1422
  def add_physical_asset(self, title: str, description: str, parent: Folder, security_tag: str = "open") -> Asset:
1423
1423
  """
@@ -1465,6 +1465,92 @@ class EntityAPI(AuthenticatedAPI):
1465
1465
  logger.error(exception)
1466
1466
  raise exception
1467
1467
 
1468
+ def merge_assets(self, assets: list[Asset], title: str, description: str) -> str:
1469
+ """
1470
+ Create a new Asset with the content from each Asset in supplied list
1471
+ This call will create a new multipart Asset which contains all the content from list of Assets.
1472
+
1473
+ The return value is the progress status of the merge operation.
1474
+ """
1475
+
1476
+ headers = {
1477
+ HEADER_TOKEN: self.token,
1478
+ "Content-Type": "application/xml;charset=UTF-8",
1479
+ "accept": "text/plain;charset=UTF-8",
1480
+ }
1481
+
1482
+ merge_object = xml.etree.ElementTree.Element("MergeAction", {"xmlns": self.entity_ns, "xmlns:xip": self.xip_ns})
1483
+ xml.etree.ElementTree.SubElement(merge_object, "Title").text = str(title)
1484
+ xml.etree.ElementTree.SubElement(merge_object, "Description").text = str(description)
1485
+ for a in assets:
1486
+ xml.etree.ElementTree.SubElement(merge_object, "Entity", {
1487
+ "excludeIdentifiers": "true",
1488
+ "excludeLinks": "true",
1489
+ "excludeMetadata": "true",
1490
+ "ref": a.reference,
1491
+ "type": EntityType.ASSET.value}
1492
+ )
1493
+ # order_object = xml.etree.ElementTree.SubElement(merge_object, "Order")
1494
+ # for a in assets:
1495
+ # xml.etree.ElementTree.SubElement(order_object, "Entity", {
1496
+ # "ref": a.reference,
1497
+ # "type": EntityType.CONTENT_OBJECT.value}
1498
+ # )
1499
+ xml_request = xml.etree.ElementTree.tostring(merge_object, encoding="utf-8")
1500
+ print(xml_request)
1501
+ request = self.session.post(
1502
+ f"{self.protocol}://{self.server}/api/entity/actions/merges", data=xml_request, headers=headers)
1503
+ if request.status_code == requests.codes.accepted:
1504
+ return request.content.decode('utf-8')
1505
+ elif request.status_code == requests.codes.unauthorized:
1506
+ self.token = self.__token__()
1507
+ return self.merge_assets(assets, title, description)
1508
+ else:
1509
+ exception = HTTPException(
1510
+ "",
1511
+ request.status_code,
1512
+ request.url,
1513
+ "merge_assets",
1514
+ request.content.decode("utf-8"),
1515
+ )
1516
+ logger.error(exception)
1517
+ raise exception
1518
+
1519
+ def merge_folder(self, folder: Folder) -> str:
1520
+ """
1521
+ Create a new Asset with the content from each Asset in the Folder
1522
+
1523
+ This call will create a new multipart Asset which contains all the content from the Folder.
1524
+
1525
+ The new Asset which is created will have the same title, description and parent as the Folder.
1526
+
1527
+ The return value is the progress status of the merge operation.
1528
+ """
1529
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8', 'accept': 'text/plain;charset=UTF-8'}
1530
+ payload = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1531
+ <MergeAction xmlns="{self.entity_ns}" xmlns:xip="{self.xip_ns}">
1532
+ <Title>{folder.title}</Title>
1533
+ <Description>{folder.description}</Description>
1534
+ <Entity excludeIdentifiers="true" excludeLinks="true" excludeMetadata="true" ref="{folder.reference}" type="SO"/>
1535
+ </MergeAction>"""
1536
+ request = self.session.post(
1537
+ f"{self.protocol}://{self.server}/api/entity/actions/merges", data=payload, headers=headers)
1538
+ if request.status_code == requests.codes.accepted:
1539
+ return request.content.decode('utf-8')
1540
+ elif request.status_code == requests.codes.unauthorized:
1541
+ self.token = self.__token__()
1542
+ return self.merge_folder(folder)
1543
+ else:
1544
+ exception = HTTPException(
1545
+ folder.reference,
1546
+ request.status_code,
1547
+ request.url,
1548
+ "merge_folder",
1549
+ request.content.decode("utf-8"),
1550
+ )
1551
+ logger.error(exception)
1552
+ raise exception
1553
+
1468
1554
  def asset(self, reference: str) -> Asset:
1469
1555
  """
1470
1556
  Retrieve an Asset by its reference
@@ -2087,7 +2173,7 @@ class EntityAPI(AuthenticatedAPI):
2087
2173
  params=params, headers=headers)
2088
2174
 
2089
2175
  if request.status_code == requests.codes.ok:
2090
- pass
2176
+ return None
2091
2177
  elif request.status_code == requests.codes.unauthorized:
2092
2178
  self.token = self.__token__()
2093
2179
  return self._event_actions(entity, maximum=maximum)
@@ -2231,6 +2317,7 @@ class EntityAPI(AuthenticatedAPI):
2231
2317
  else:
2232
2318
  url = next_url.text
2233
2319
  return PagedSet(result_list, has_more, int(total_hits.text), url)
2320
+ return None
2234
2321
 
2235
2322
  def entity_from_event(self, event_id: str) -> Generator:
2236
2323
  self.token = self.__token__()
@@ -2258,7 +2345,6 @@ class EntityAPI(AuthenticatedAPI):
2258
2345
  if "username" in kwargs:
2259
2346
  params["username"] = kwargs.get("username")
2260
2347
 
2261
-
2262
2348
  if next_page is None:
2263
2349
  request = self.session.get(f'{self.protocol}://{self.server}/api/entity/events', params=params,
2264
2350
  headers=headers)
@@ -2561,4 +2647,4 @@ class EntityAPI(AuthenticatedAPI):
2561
2647
  exception = HTTPException(entity.reference, request.status_code, request.url,
2562
2648
  "_delete_entity", request.content.decode('utf-8'))
2563
2649
  logger.error(exception)
2564
- raise exception
2650
+ raise exception
@@ -451,6 +451,26 @@ class MetadataGroupsAPI(AuthenticatedAPI):
451
451
  raise exception
452
452
 
453
453
 
454
+ def delete_form(self, form_id: str):
455
+ """
456
+ Delete a form by its ID
457
+ """
458
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/json;charset=UTF-8'}
459
+ url = f'{self.protocol}://{self.server}/api/metadata/forms/{form_id}'
460
+ with self.session.delete(url, headers=headers) as request:
461
+ if request.status_code == requests.codes.unauthorized:
462
+ self.token = self.__token__()
463
+ return self.delete_form(form_id)
464
+ elif request.status_code == requests.codes.no_content:
465
+ return None
466
+ else:
467
+ exception = HTTPException(None, request.status_code, request.url, "delete_form",
468
+ request.content.decode('utf-8'))
469
+ logger.error(exception)
470
+ raise exception
471
+
472
+
473
+
454
474
  def form(self, form_id: str) -> dict:
455
475
  """
456
476
  Return a Form as a JSON dict object
@@ -74,6 +74,7 @@ class TriggerType(Enum):
74
74
  INDEXED = "FULL_TEXT_INDEXED"
75
75
  SECURITY_CHANGED = "CHANGED_SECURITY_DESCRIPTOR"
76
76
  INGEST_FAILED = "INGEST_FAILED"
77
+ CHANGE_ASSET_VISIBILITY = "CHANGE_ASSET_VISIBILITY"
77
78
 
78
79
 
79
80
  class WebHooksAPI(AuthenticatedAPI):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyPreservica
3
- Version: 3.2.1
3
+ Version: 3.2.3
4
4
  Summary: Python library for the Preservica API
5
5
  Home-page: https://pypreservica.readthedocs.io/
6
6
  Author: James Carr
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.9
16
16
  Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
19
20
  Classifier: Operating System :: OS Independent
20
21
  Classifier: Topic :: System :: Archiving
21
22
  Description-Content-Type: text/markdown
@@ -23,8 +24,8 @@ License-File: LICENSE.txt
23
24
  Requires-Dist: requests
24
25
  Requires-Dist: urllib3
25
26
  Requires-Dist: certifi
26
- Requires-Dist: boto3>=1.36.0
27
- Requires-Dist: botocore>=1.36.0
27
+ Requires-Dist: boto3>=1.38.0
28
+ Requires-Dist: botocore>=1.38.0
28
29
  Requires-Dist: s3transfer
29
30
  Requires-Dist: azure-storage-blob
30
31
  Requires-Dist: tqdm
@@ -1,8 +1,8 @@
1
1
  requests
2
2
  urllib3
3
3
  certifi
4
- boto3>=1.36.0
5
- botocore>=1.36.0
4
+ boto3>=1.38.0
5
+ botocore>=1.38.0
6
6
  s3transfer
7
7
  azure-storage-blob
8
8
  tqdm
@@ -21,7 +21,7 @@ if sys.argv[-1] == 'publish':
21
21
  # This call to setup() does all the work
22
22
  setup(
23
23
  name=PKG,
24
- version="3.2.1",
24
+ version="3.2.3",
25
25
  description="Python library for the Preservica API",
26
26
  long_description=README,
27
27
  long_description_content_type="text/markdown",
@@ -37,11 +37,12 @@ setup(
37
37
  'Programming Language :: Python :: 3.10',
38
38
  'Programming Language :: Python :: 3.11',
39
39
  'Programming Language :: Python :: 3.12',
40
+ 'Programming Language :: Python :: 3.13',
40
41
  "Operating System :: OS Independent",
41
42
  "Topic :: System :: Archiving",
42
43
  ],
43
44
  keywords='Preservica API Preservation',
44
- install_requires=["requests", "urllib3", "certifi", "boto3>=1.36.0", "botocore>=1.36.0", "s3transfer", "azure-storage-blob", "tqdm", "pyotp", "python-dateutil"],
45
+ install_requires=["requests", "urllib3", "certifi", "boto3>=1.38.0", "botocore>=1.38.0", "s3transfer", "azure-storage-blob", "tqdm", "pyotp", "python-dateutil"],
45
46
  project_urls={
46
47
  'Documentation': 'https://pypreservica.readthedocs.io',
47
48
  'Source': 'https://github.com/carj/pyPreservica',
@@ -1,24 +1,48 @@
1
+ from os.path import isfile
2
+
1
3
  import pytest
2
4
 
3
5
  from zipfile import ZipFile
4
6
  from pyPreservica import *
5
7
 
6
8
 
7
- def test_export_file_wait():
9
+
10
+
11
+ def setup():
12
+ pass
13
+
14
+
15
+ def tear_down(zip_files):
16
+ for f in zip_files:
17
+ if os.path.exists(f):
18
+ os.remove(f)
19
+
20
+
21
+ @pytest.fixture
22
+ def setup_data():
23
+ print("\nSetting up resources...")
24
+ zip_files = []
25
+ setup()
26
+ yield zip_files
27
+ print(f"\nTearing down resources...")
28
+ tear_down(zip_files)
29
+
30
+ def test_export_file_wait(setup_data):
8
31
  client = EntityAPI()
9
32
  asset = client.asset("683f9db7-ff81-4859-9c03-f68cfa5d9c3d")
10
- zip_file = client.export_opex_sync(asset, IncludeContent='true')
33
+ zip_file = client.export_opex_sync(asset, IncludeContent='true', IncludeMetadata='true')
11
34
  assert os.path.exists(zip_file)
35
+ setup_data.append(zip_file)
12
36
  assert 1066650 < os.stat(zip_file).st_size
13
37
  with ZipFile(zip_file, 'r') as zipObj:
14
38
  assert len(zipObj.namelist()) == 6
15
39
  os.remove(zip_file)
16
40
 
17
41
 
18
- def test_export_file_no_wait():
42
+ def test_export_file_no_wait(setup_data):
19
43
  client = EntityAPI()
20
44
  asset = client.asset("683f9db7-ff81-4859-9c03-f68cfa5d9c3d")
21
- pid = client.export_opex_async(asset)
45
+ pid = client.export_opex_async(asset, IncludeContent='true', IncludeMetadata='true')
22
46
  status = "ACTIVE"
23
47
 
24
48
  while status == "ACTIVE":
@@ -28,6 +52,7 @@ def test_export_file_no_wait():
28
52
 
29
53
  zip_file = client.download_opex(pid)
30
54
  assert os.path.exists(zip_file)
55
+ setup_data.append(zip_file)
31
56
  assert 1066650 < os.stat(zip_file).st_size
32
57
  with ZipFile(zip_file, 'r') as zipObj:
33
58
  assert len(zipObj.namelist()) == 6
@@ -38,11 +38,11 @@ def test_get_xml_documents():
38
38
  xml_documents = client.xml_documents()
39
39
  assert type(xml_documents) is list
40
40
  assert len(xml_documents) > 10
41
- for xml in xml_documents:
42
- assert type(xml) is dict
43
- assert "Name" in xml
44
- assert "SchemaUri" in xml
45
- assert "ApiId" in xml
41
+ for x in xml_documents:
42
+ assert type(x) is dict
43
+ assert "Name" in x
44
+ assert "SchemaUri" in x
45
+ assert "ApiId" in x
46
46
 
47
47
 
48
48
  def test_get_xml_document_by_uri():
@@ -73,12 +73,12 @@ def test_get_xml_transforms():
73
73
  xml_transforms = client.xml_transforms()
74
74
  assert type(xml_transforms) is list
75
75
  assert len(xml_transforms) > 10
76
- for xml in xml_transforms:
77
- assert type(xml) is dict
78
- assert "Name" in xml
79
- assert "FromSchemaUri" in xml
80
- assert "ToSchemaUri" in xml
81
- assert "ApiId" in xml
76
+ for x in xml_transforms:
77
+ assert type(x) is dict
78
+ assert "Name" in x
79
+ assert "FromSchemaUri" in x
80
+ assert "ToSchemaUri" in x
81
+ assert "ApiId" in x
82
82
 
83
83
 
84
84
  def test_get_xml_transform_by_uri():
File without changes
File without changes
File without changes