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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyPreservica/common.py CHANGED
@@ -1,36 +1,49 @@
1
1
  """
2
2
  Base class for authenticated API calls used by Entity, Content and Upload
3
3
 
4
+ Manages the authentication token lifetime and namespace versions.
5
+
4
6
  author: James Carr
5
7
  licence: Apache License 2.0
6
8
 
7
9
  """
8
-
9
10
  import configparser
11
+ import functools
10
12
  import hashlib
13
+ import json
14
+ import logging
11
15
  import os
16
+ import platform
17
+ import re
12
18
  import sys
13
19
  import threading
14
20
  import time
21
+ import unicodedata
15
22
  import xml.etree.ElementTree
16
23
  from enum import Enum
24
+ from pathlib import Path
25
+ import pyotp
26
+ from requests import Session
27
+ from urllib3.util import Retry
17
28
  import requests
18
- import logging
19
- import unicodedata
20
- import re
29
+ from requests.adapters import HTTPAdapter
30
+ from typing import TypeVar
31
+ from datetime import datetime
32
+ import dateutil
21
33
 
22
34
  import pyPreservica
23
35
 
24
36
  logger = logging.getLogger(__name__)
25
37
 
26
- CHUNK_SIZE = 1024 * 2
27
-
28
38
  NS_XIP_ROOT = "http://preservica.com/XIP/"
29
39
  NS_ENTITY_ROOT = "http://preservica.com/EntityAPI/"
30
40
  NS_RM_ROOT = "http://preservica.com/RetentionManagement/"
41
+ NS_SEC_ROOT = "http://preservica.com/SecurityAPI"
31
42
 
32
43
  NS_WORKFLOW = "http://workflow.preservica.com"
33
44
 
45
+ NS_ADMIN = "http://preservica.com/AdminAPI"
46
+
34
47
  NS_XIP_V6 = "http://preservica.com/XIP/v6.0"
35
48
  NS_ENTITY = "http://preservica.com/EntityAPI/v6.0"
36
49
 
@@ -41,6 +54,8 @@ SO_PATH = "structural-objects"
41
54
  CO_PATH = "content-objects"
42
55
 
43
56
  HASH_BLOCK_SIZE = 65536
57
+ TIME_OUT = 62
58
+ CHUNK_SIZE = 1024 * 4
44
59
 
45
60
 
46
61
  class FileHash:
@@ -65,8 +80,39 @@ class FileHash:
65
80
  return hash_algorithm.hexdigest()
66
81
 
67
82
 
83
+ def identifiersToDict(identifiers: set) -> dict:
84
+ """
85
+ Convert a set of tuples to a dict
86
+ :param identifiers:
87
+ :return:
88
+ """
89
+ result = {}
90
+ for identifier_tuple in identifiers:
91
+ result[identifier_tuple[0]] = identifier_tuple[1]
92
+ return result
93
+
94
+
95
+ def strtobool(val) -> bool:
96
+ """
97
+ Convert a string representation of truth to true (1) or false (0).
98
+
99
+ True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
100
+ are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
101
+ 'val' is anything else.
102
+ """
103
+ val = val.lower()
104
+ if val in ('y', 'yes', 't', 'true', 'on', '1'):
105
+ return True
106
+ elif val in ('n', 'no', 'f', 'false', 'off', '0'):
107
+ return False
108
+ else:
109
+ raise ValueError("invalid truth value %r" % (val,))
110
+
111
+
68
112
  def _make_stored_zipfile(base_name, base_dir, owner, group, verbose=0, dry_run=0, logger=None):
69
- """Create a zip file from all the files under 'base_dir'.
113
+ """
114
+ Create a non compressed zip file from all the files under 'base_dir'.
115
+
70
116
 
71
117
  The output zip file will be named 'base_name' + ".zip". Returns the
72
118
  name of the output zip file.
@@ -109,21 +155,13 @@ def _make_stored_zipfile(base_name, base_dir, owner, group, verbose=0, dry_run=0
109
155
  return zip_filename
110
156
 
111
157
 
112
- def only_assets(entity):
113
- return bool(entity.entity_type is EntityType.ASSET)
114
-
115
-
116
- def only_folders(entity):
117
- return bool(entity.entity_type is EntityType.FOLDER)
118
-
119
-
120
158
  class PagedSet:
121
159
  """
122
160
  Class to represent a page of results
123
161
  The results object contains the list of objects of interest
124
162
  """
125
163
 
126
- def __init__(self, results, has_more, total, next_page):
164
+ def __init__(self, results, has_more: bool, total: int, next_page: str):
127
165
  self.results = results
128
166
  self.has_more = bool(has_more)
129
167
  self.total = int(total)
@@ -160,12 +198,83 @@ class Sha512FixityCallBack:
160
198
  return "SHA512", sha(full_path)
161
199
 
162
200
 
201
+ class ReportProgressConsoleCallback:
202
+
203
+ def __init__(self, prefix='Progress:', suffix='', length=100, fill='█', printEnd="\r"):
204
+ self.prefix = prefix
205
+ self.suffix = suffix
206
+ self.length = length
207
+ self.fill = fill
208
+ self.printEnd = printEnd
209
+ self._lock = threading.Lock()
210
+ self.print_progress_bar(0)
211
+
212
+ def __call__(self, value):
213
+ with self._lock:
214
+ values = value.split(":")
215
+ self.total = int(values[1])
216
+ self.current = int(values[0])
217
+ if self.total == 0:
218
+ percentage = 100.0
219
+ else:
220
+ percentage = (self.current / self.total) * 100
221
+ self.print_progress_bar(percentage)
222
+ if int(percentage) == int(100):
223
+ self.print_progress_bar(100.0)
224
+ sys.stdout.write(self.printEnd)
225
+ sys.stdout.flush()
226
+
227
+ def print_progress_bar(self, percentage):
228
+ filled_length = int(self.length * (percentage / 100.0))
229
+ bar_sym = self.fill * filled_length + '-' * (self.length - filled_length)
230
+ sys.stdout.write(
231
+ '\r%s |%s| (%.2f%%) %s ' % (self.prefix, bar_sym, percentage, self.suffix))
232
+ sys.stdout.flush()
233
+
234
+
235
+ class UploadProgressConsoleCallback:
236
+
237
+ def __init__(self, filename: str, prefix='Progress:', suffix='', length=100, fill='█', printEnd="\r"):
238
+ self.prefix = prefix
239
+ self.suffix = suffix
240
+ self.length = length
241
+ self.fill = fill
242
+ self.printEnd = printEnd
243
+ self._filename = filename
244
+ self._size = float(Path(filename).stat().st_size)
245
+ self._seen_so_far = 0
246
+ self.start = time.time()
247
+ self._lock = threading.Lock()
248
+ self.print_progress_bar(0, 0)
249
+
250
+ def __call__(self, bytes_amount):
251
+ with self._lock:
252
+ seconds = time.time() - self.start
253
+ if seconds == 0:
254
+ seconds = 1.0
255
+ self._seen_so_far += bytes_amount
256
+ percentage = (self._seen_so_far / self._size) * float(100.0)
257
+ rate = (self._seen_so_far / (1024 * 1024)) / seconds
258
+ self.print_progress_bar(percentage, rate)
259
+ if int(self._seen_so_far) == int(self._size):
260
+ self.print_progress_bar(100.0, rate)
261
+ sys.stdout.write(self.printEnd)
262
+ sys.stdout.flush()
263
+
264
+ def print_progress_bar(self, percentage, rate):
265
+ filled_length = int(self.length * (percentage / 100.0))
266
+ bar_sym = self.fill * filled_length + '-' * (self.length - filled_length)
267
+ sys.stdout.write(
268
+ '\r%s |%s| (%.2f%%) (%.2f %s) %s ' % (self.prefix, bar_sym, percentage, rate, "Mb/s", self.suffix))
269
+ sys.stdout.flush()
270
+
271
+
163
272
  class UploadProgressCallback:
164
273
  """
165
274
  Default implementation of a callback class to show upload progress of a file
166
275
  """
167
276
 
168
- def __init__(self, filename):
277
+ def __init__(self, filename: str):
169
278
  self._filename = filename
170
279
  self._size = float(os.path.getsize(filename))
171
280
  self._seen_so_far = 0
@@ -179,6 +288,87 @@ class UploadProgressCallback:
179
288
  sys.stdout.flush()
180
289
 
181
290
 
291
+ class RelationshipDirection(Enum):
292
+ FROM = "From"
293
+ TO = "To"
294
+
295
+
296
+ class EntityType(Enum):
297
+ """
298
+ Enumeration of the Entity Types
299
+ """
300
+ ASSET = "IO"
301
+ FOLDER = "SO"
302
+ CONTENT_OBJECT = "CO"
303
+
304
+
305
+ class HTTPException(Exception):
306
+ """
307
+ Custom Exception non 404 errors
308
+ """
309
+
310
+ def __init__(self, reference, http_status_code, url, method_name, message):
311
+ self.reference = reference
312
+ self.url = url
313
+ self.method_name = method_name
314
+ self.http_status_code = http_status_code
315
+ self.msg = message
316
+ Exception.__init__(self, self.reference, self.http_status_code, self.url, self.msg)
317
+
318
+ def __str__(self):
319
+ return f"Calling method {self.method_name}() {self.url} returned HTTP {self.http_status_code}. {self.msg}"
320
+
321
+
322
+ class ReferenceNotFoundException(Exception):
323
+ """
324
+ Custom Exception for failed lookups by reference 404 Errors
325
+ """
326
+
327
+ def __init__(self, reference, http_status_code, url, method_name):
328
+ self.reference = reference
329
+ self.url = url
330
+ self.method_name = method_name
331
+ self.http_status_code = http_status_code
332
+ self.msg = f"The requested reference {self.reference} is not found in the repository"
333
+ Exception.__init__(self, self.reference, self.http_status_code, self.url, self.msg)
334
+
335
+ def __str__(self):
336
+ return f"Calling method {self.method_name}() {self.url} returned HTTP {self.http_status_code}. {self.msg}"
337
+
338
+
339
+ class Relationship:
340
+ DCMI_hasFormat = "http://purl.org/dc/terms/hasFormat"
341
+ DCMI_isFormatOf = "http://purl.org/dc/terms/isFormatOf"
342
+ DCMI_hasPart = "http://purl.org/dc/terms/hasPart"
343
+ DCMI_isPartOf = "http://purl.org/dc/terms/isPartOf"
344
+ DCMI_hasVersion = "http://purl.org/dc/terms/hasVersion"
345
+ DCMI_isVersionOf = "http://purl.org/dc/terms/isVersionOf"
346
+ DCMI_isReferencedBy = "http://purl.org/dc/terms/isReferencedBy"
347
+ DCMI_references = "http://purl.org/dc/terms/references"
348
+ DCMI_isReplacedBy = "http://purl.org/dc/terms/isReplacedBy"
349
+ DCMI_replaces = "http://purl.org/dc/terms/replaces"
350
+ DCMI_isRequiredBy = "http://purl.org/dc/terms/isRequiredBy"
351
+ DCMI_requires = "http://purl.org/dc/terms/requires"
352
+ DCMI_conformsTo = "http://purl.org/dc/terms/conformsTo"
353
+
354
+ def __init__(self, relationship_id: str, relationship_type: str, direction: RelationshipDirection, other_ref: str,
355
+ title: str, entity_type: EntityType, this_ref: str, api_id: str):
356
+ self.api_id = api_id
357
+ self.this_ref = this_ref
358
+ self.entity_type = entity_type
359
+ self.title = title
360
+ self.other_ref = other_ref
361
+ self.direction = direction
362
+ self.relationship_type = relationship_type
363
+ self.relationship_id = relationship_id
364
+
365
+ def __str__(self):
366
+ if self.direction == RelationshipDirection.FROM:
367
+ return f"{self.this_ref} {self.relationship_type} {self.other_ref}"
368
+ else:
369
+ return f"{self.other_ref} {self.relationship_type} {self.this_ref}"
370
+
371
+
182
372
  class IntegrityCheck:
183
373
  """
184
374
  Class to hold information about completed integrity checks
@@ -208,64 +398,75 @@ class IntegrityCheck:
208
398
  return self.success
209
399
 
210
400
 
211
- class Representation:
401
+ class Bitstream:
212
402
  """
213
- Class to represent the Representation Object in the Preservica data model
403
+ Class to represent the Bitstream Object or digital file in the Preservica data model
214
404
  """
215
405
 
216
- def __init__(self, asset, rep_type, name, url):
217
- self.asset = asset
218
- self.rep_type = rep_type
219
- self.name = name
220
- self.url = url
406
+ def __init__(self, filename: str, length: int, fixity: dict, content_url: str):
407
+ self.filename = filename
408
+ self.length = int(length)
409
+ self.fixity = fixity
410
+ self.content_url = content_url
411
+ self.bs_index = None
412
+ self.gen_index = None
413
+ self.co_ref = None
221
414
 
222
415
  def __str__(self):
223
- return f"Type:\t\t\t{self.rep_type}\n" \
224
- f"Name:\t\t\t{self.name}\n" \
225
- f"URL:\t{self.url}"
416
+ return f"""
417
+ Filename: {self.filename}
418
+ File Length: {self.length}
419
+ Fixity: {self.fixity}
420
+ """
226
421
 
227
422
  def __repr__(self):
228
423
  return self.__str__()
229
424
 
230
425
 
231
- class Bitstream:
426
+ class ExternIdentifier:
232
427
  """
233
- Class to represent the Bitstream Object or digital file in the Preservica data model
428
+ Class to represent the External Identifier Object in the Preservica data model
234
429
  """
235
430
 
236
- def __init__(self, filename, length, fixity, content_url):
237
- self.filename = filename
238
- self.length = int(length)
239
- self.fixity = fixity
240
- self.content_url = content_url
431
+ def __init__(self, identifier_type: str, identifier_value: str):
432
+ self.type = identifier_type
433
+ self.value = identifier_value
434
+ self.id = None
241
435
 
242
436
  def __str__(self):
243
- return f"Filename:\t\t\t{self.filename}\n" \
244
- f"FileSize:\t\t\t{self.length}\n" \
245
- f"Content:\t{self.content_url}\n" \
246
- f"Fixity:\t{self.fixity}"
437
+ return f"""
438
+ Identifier: {self.id}
439
+ Identifier Type: {self.type}
440
+ Identifier Value: {self.value}
441
+ """
247
442
 
248
443
  def __repr__(self):
249
444
  return self.__str__()
250
445
 
251
-
252
446
  class Generation:
253
447
  """
254
448
  Class to represent the Generation Object in the Preservica data model
255
- """
449
+ """
256
450
 
257
- def __init__(self, original, active, format_group, effective_date, bitstreams):
258
- self.original = original
259
- self.active = active
451
+ def __init__(self, original: bool, active: bool, format_group: str, effective_date: str, bitstreams: list):
452
+ self.original = bool(original)
453
+ self.active = bool(active)
260
454
  self.content_object = None
261
455
  self.format_group = format_group
262
456
  self.effective_date = effective_date
263
457
  self.bitstreams = bitstreams
458
+ self.properties = list()
459
+ self.formats = list()
264
460
 
265
461
  def __str__(self):
266
- return f"Active:\t\t\t{self.active}\n" \
267
- f"Original:\t\t\t{self.original}\n" \
268
- f"Format_group:\t{self.format_group}"
462
+ return f"""
463
+ Active: {self.active}
464
+ Original: {self.original}
465
+ Format Group: {self.format_group}
466
+ Effective Date: {self.effective_date}
467
+ Formats: {self.formats}
468
+ Properties: {self.properties}
469
+ """
269
470
 
270
471
  def __repr__(self):
271
472
  return self.__str__()
@@ -276,7 +477,7 @@ class Entity:
276
477
  Base Class of Assets, Folders and Content Objects
277
478
  """
278
479
 
279
- def __init__(self, reference, title, description, security_tag, parent, metadata):
480
+ def __init__(self, reference: str, title: str, description: str, security_tag: str, parent: str, metadata: dict):
280
481
  self.reference = reference
281
482
  self.title = title
282
483
  self.description = description
@@ -286,24 +487,36 @@ class Entity:
286
487
  self.entity_type = None
287
488
  self.path = None
288
489
  self.tag = None
490
+ self.custom_type = None
289
491
 
290
492
  def __str__(self):
291
- return f"Ref:\t\t\t{self.reference}\n" \
292
- f"Title:\t\t\t{self.title}\n" \
293
- f"Description:\t{self.description}\n" \
294
- f"Security Tag:\t{self.security_tag}\n" \
295
- f"Parent:\t\t\t{self.parent}\n\n"
493
+ return f"""
494
+ Entity: {self.entity_type}
495
+ Entity Ref: {self.reference}
496
+ Title: {self.title}
497
+ Description: {self.description}
498
+ Security Tag: {self.security_tag}
499
+ Parent: {self.parent}
500
+ Custom Type: {self.custom_type}
501
+ """
296
502
 
297
503
  def __repr__(self):
298
504
  return self.__str__()
299
505
 
506
+ def has_metadata(self) -> bool:
507
+ return bool(self.metadata)
508
+
509
+ def metadata_namespaces(self) -> list:
510
+ return list(self.metadata.values())
511
+
300
512
 
301
513
  class Folder(Entity):
302
514
  """
303
515
  Class to represent the Structural Object or Folder in the Preservica data model
304
516
  """
305
517
 
306
- def __init__(self, reference, title, description, security_tag, parent, metadata):
518
+ def __init__(self, reference: str, title: str, description: str = None, security_tag: str = None,
519
+ parent: str = None, metadata: dict = None):
307
520
  super().__init__(reference, title, description, security_tag, parent, metadata)
308
521
  self.entity_type = EntityType.FOLDER
309
522
  self.path = SO_PATH
@@ -315,7 +528,8 @@ class Asset(Entity):
315
528
  Class to represent the Information Object or Asset in the Preservica data model
316
529
  """
317
530
 
318
- def __init__(self, reference, title, description, security_tag, parent, metadata):
531
+ def __init__(self, reference: str, title: str, description: str = None, security_tag: str = None,
532
+ parent: str = None, metadata: dict = None):
319
533
  super().__init__(reference, title, description, security_tag, parent, metadata)
320
534
  self.entity_type = EntityType.ASSET
321
535
  self.path = IO_PATH
@@ -327,7 +541,8 @@ class ContentObject(Entity):
327
541
  Class to represent the Content Object in the Preservica data model
328
542
  """
329
543
 
330
- def __init__(self, reference, title, description, security_tag, parent, metadata):
544
+ def __init__(self, reference: str, title: str, description: str = None, security_tag: str = None,
545
+ parent: str = None, metadata: dict = None):
331
546
  super().__init__(reference, title, description, security_tag, parent, metadata)
332
547
  self.entity_type = EntityType.CONTENT_OBJECT
333
548
  self.representation_type = None
@@ -336,7 +551,38 @@ class ContentObject(Entity):
336
551
  self.tag = "ContentObject"
337
552
 
338
553
 
339
- def content_api_identifier_to_type(ref):
554
+ EntityT = TypeVar("EntityT", Folder, Asset, ContentObject, None)
555
+
556
+
557
+ class Representation:
558
+ """
559
+ Class to represent the Representation Object in the Preservica data model
560
+ """
561
+
562
+ def __init__(self, asset: Asset, rep_type: str, name: str, url: str):
563
+ self.asset = asset
564
+ self.rep_type = rep_type
565
+ self.name = name
566
+ self.url = url
567
+
568
+ def __str__(self):
569
+ return f"Type:\t\t\t{self.rep_type}\n" \
570
+ f"Name:\t\t\t{self.name}\n" \
571
+ f"URL:\t{self.url}"
572
+
573
+ def __repr__(self):
574
+ return self.__str__()
575
+
576
+
577
+ def only_assets(entity: Entity):
578
+ return bool(entity.entity_type is EntityType.ASSET)
579
+
580
+
581
+ def only_folders(entity: Entity):
582
+ return bool(entity.entity_type is EntityType.FOLDER)
583
+
584
+
585
+ def content_api_identifier_to_type(ref: str):
340
586
  ref = ref.replace('sdb:', '')
341
587
  parts = ref.split("|")
342
588
  return tuple((EntityType(parts[0]), parts[1]))
@@ -348,19 +594,30 @@ class Thumbnail(Enum):
348
594
  LARGE = "large"
349
595
 
350
596
 
351
- class EntityType(Enum):
352
- ASSET = "IO"
353
- FOLDER = "SO"
354
- CONTENT_OBJECT = "CO"
355
-
356
-
357
- def sanitize(filename):
358
- """Return a fairly safe version of the filename.
597
+ class AsyncProgress(Enum):
598
+ """
599
+ Enumeration of the possible status of an asynchronous process
600
+ """
601
+ ABORTED = "ABORTED"
602
+ ACTIVE = "ACTIVE"
603
+ COMPLETED = "COMPLETED"
604
+ PENDING = "PENDING"
605
+ SUSPENDING = "SUSPENDING"
606
+ SUSPENDED = "SUSPENDED"
607
+ UNKNOWN = "UNKNOWN"
608
+ FAILED = "FAILED"
609
+ FINISHED_MIXED_OUTCOME = "FINISHED_MIXED_OUTCOME"
610
+ CANCELLED = "CANCELLED"
611
+
612
+
613
+ def sanitize(filename) -> str:
614
+ """
615
+ Return a fairly safe version of the filename.
359
616
 
360
617
  We don't limit ourselves to ascii, because we want to keep municipality
361
- names, etc, but we do want to get rid of anything potentially harmful,
618
+ names, etc., but we do want to get rid of anything potentially harmful,
362
619
  and make sure we do not exceed Windows filename length limits.
363
- Hence a less safe blacklist, rather than a whitelist.
620
+ Hence, a less safe blacklist, rather than a whitelist.
364
621
  """
365
622
  blacklist = ["\\", "/", ":", "*", "?", "\"", "<", ">", "|", "\0"]
366
623
  reserved = [
@@ -403,16 +660,91 @@ def sanitize(filename):
403
660
 
404
661
  class AuthenticatedAPI:
405
662
  """
406
- Base class for authenticated calls which need an access token
663
+ Base class for authenticated calls which need an access token
664
+ Authenticated calls include a "Preservica-Access-Token" header in the request
407
665
  """
408
666
 
409
- def entity_from_string(self, xml_data):
667
+ def _check_if_user_has_manager_role(self):
668
+ """
669
+ Check if the current user has a least a manager role
670
+ :return: None
671
+
672
+ Throws RuntimeError if the user does not have required roles
673
+ """
674
+ if ('ROLE_SDB_MANAGER_USER' not in self.roles) and ('ROLE_SDB_ADMIN_USER' not in self.roles):
675
+ logger.error(f"The AdminAPI requires the user to have ROLE_SDB_MANAGER_USER")
676
+ raise RuntimeError(f"The API requires the user to have at least the ROLE_SDB_MANAGER_USER")
677
+
678
+ def _find_user_roles_(self) -> list[str]:
679
+ """
680
+ Get a list of roles for the user
681
+ :return list of roles:
682
+ """
683
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/json'}
684
+ request = self.session.get(f"{self.protocol}://{self.server}/api/user/details", headers=headers)
685
+ logger.debug(request.headers)
686
+ if request.status_code == requests.codes.ok:
687
+ json_document = str(request.content.decode('utf-8'))
688
+ logger.debug(json_document)
689
+ roles: list[str] = json.loads(json_document)['roles']
690
+ return roles
691
+ elif request.status_code == requests.codes.unauthorized:
692
+ self.token = self.__token__()
693
+ return self._find_user_roles_()
694
+ return []
695
+
696
+
697
+ def security_tags_base(self, with_permissions: bool = False) -> dict:
698
+ """
699
+ Return security tags available for the current user
700
+
701
+ :return: dict of security tags
702
+ :rtype: dict
703
+ """
704
+
705
+ if (self.major_version < 7) and (self.minor_version < 4) and (self.patch_version < 1):
706
+ raise RuntimeError("security_tags API call is only available with a Preservica v6.3.1 system or higher")
707
+
708
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/xml;charset=UTF-8'}
709
+
710
+ request = self.session.get(f'{self.protocol}://{self.server}/api/security/tags', headers=headers)
711
+ if request.status_code == requests.codes.ok:
712
+ xml_response = str(request.content.decode('utf-8'))
713
+ logger.debug(xml_response)
714
+ entity_response = xml.etree.ElementTree.fromstring(xml_response)
715
+ security_tags = {}
716
+ tags = entity_response.findall(f'.//{{{self.sec_ns}}}Tag')
717
+ for tag in tags:
718
+ if with_permissions:
719
+ permissions = []
720
+ for p in tag.findall(f'.//{{{self.sec_ns}}}Permission'):
721
+ permissions.append(p.text)
722
+ security_tags[tag.attrib['name']] = permissions
723
+ else:
724
+ security_tags[tag.attrib['name']] = tag.attrib['name']
725
+ return security_tags
726
+ if request.status_code == requests.codes.unauthorized:
727
+ self.token = self.__token__()
728
+ return self.security_tags_base()
729
+ else:
730
+ logger.error(f'security_tags failed {request.status_code}')
731
+ raise RuntimeError(request.status_code, "security_tags failed")
732
+
733
+ def entity_from_string(self, xml_data: str) -> dict:
734
+ """
735
+ Create a basic entity from XML data
736
+
737
+ :param xml_data:
738
+ :return: dict
739
+ """
410
740
  entity_response = xml.etree.ElementTree.fromstring(xml_data)
411
741
  reference = entity_response.find(f'.//{{{self.xip_ns}}}Ref')
412
742
  title = entity_response.find(f'.//{{{self.xip_ns}}}Title')
413
743
  security_tag = entity_response.find(f'.//{{{self.xip_ns}}}SecurityTag')
414
744
  description = entity_response.find(f'.//{{{self.xip_ns}}}Description')
415
745
  parent = entity_response.find(f'.//{{{self.xip_ns}}}Parent')
746
+ custom_type = entity_response.find(f'.//{{{self.xip_ns}}}CustomType')
747
+
416
748
  if hasattr(parent, 'text'):
417
749
  parent = parent.text
418
750
  else:
@@ -423,14 +755,48 @@ class AuthenticatedAPI:
423
755
  for fragment in fragments:
424
756
  metadata[fragment.text] = fragment.attrib['schema']
425
757
 
426
- return {'reference': reference.text, 'title': title.text if hasattr(title, 'text') else None,
427
- 'description': description.text if hasattr(description, 'text') else None,
428
- 'security_tag': security_tag.text, 'parent': parent, 'metadata': metadata}
758
+ entity_dict = {'reference': reference.text, 'title': title.text if hasattr(title, 'text') else None,
759
+ 'description': description.text if hasattr(description, 'text') else None,
760
+ 'security_tag': security_tag.text, 'parent': parent, 'metadata': metadata}
761
+
762
+ if hasattr(custom_type, 'text'):
763
+ entity_dict['CustomType'] = custom_type.text
764
+
765
+ return entity_dict
766
+
767
+ def edition(self) -> str:
768
+ """
769
+ Return the edition of this tenancy
770
+ """
771
+ if self.major_version < 8 and self.minor_version < 3:
772
+ raise RuntimeError("Entitlement API is only available when connected to a v7.3 System")
773
+
774
+ headers = {HEADER_TOKEN: self.token, 'Content-Type': 'application/json'}
775
+
776
+ response = self.session.get(f'{self.protocol}://{self.server}/api/entitlement/edition', headers=headers)
777
+
778
+ if response.status_code == requests.codes.ok:
779
+ return response.json()['edition']
780
+ elif response.status_code == requests.codes.unauthorized:
781
+ self.token = self.__token__()
782
+ return self.edition()
783
+ else:
784
+ exception = HTTPException("", response.status_code, response.url,
785
+ "edition", response.content.decode('utf-8'))
786
+ logger.error(exception)
787
+ raise exception
429
788
 
430
789
  def __version_namespace__(self):
431
790
  """
432
791
  Generate version specific namespaces from the server version
433
792
  """
793
+ if self.major_version > 6:
794
+ self.xip_ns = f"{NS_XIP_ROOT}v{self.major_version}.{self.minor_version}"
795
+ self.entity_ns = f"{NS_ENTITY_ROOT}v{self.major_version}.{self.minor_version}"
796
+ self.rm_ns = f"{NS_RM_ROOT}v{6}.{2}"
797
+ self.sec_ns = f"{NS_SEC_ROOT}/v{self.major_version}.{self.minor_version}"
798
+ self.admin_ns = f"{NS_ADMIN}/v{self.major_version}.{self.minor_version}"
799
+
434
800
  if self.major_version == 6:
435
801
  if self.minor_version < 2:
436
802
  self.xip_ns = NS_XIP_V6
@@ -439,13 +805,18 @@ class AuthenticatedAPI:
439
805
  self.xip_ns = f"{NS_XIP_ROOT}v{self.major_version}.{self.minor_version}"
440
806
  self.entity_ns = f"{NS_ENTITY_ROOT}v{self.major_version}.{self.minor_version}"
441
807
  self.rm_ns = f"{NS_RM_ROOT}v{self.major_version}.{2}"
808
+ self.sec_ns = f"{NS_SEC_ROOT}/v{self.major_version}.{self.minor_version}"
809
+ self.admin_ns = f"{NS_ADMIN}/v{self.major_version}.{self.minor_version}"
810
+
811
+ xml.etree.ElementTree.register_namespace("xip", f"{self.xip_ns}")
442
812
 
443
813
  def __version_number__(self):
444
814
  """
445
815
  Determine the version number of the server
446
816
  """
447
817
  headers = {HEADER_TOKEN: self.token}
448
- request = self.session.get(f'https://{self.server}/api/entity/versiondetails/version', headers=headers)
818
+ request = self.session.get(f'{self.protocol}://{self.server}/api/entity/versiondetails/version',
819
+ headers=headers)
449
820
  if request.status_code == requests.codes.ok:
450
821
  xml_ = str(request.content.decode('utf-8'))
451
822
  version = xml_[xml_.find("<CurrentVersion>") + len("<CurrentVersion>"):xml_.find("</CurrentVersion>")]
@@ -453,6 +824,7 @@ class AuthenticatedAPI:
453
824
  self.major_version = int(version_numbers[0])
454
825
  self.minor_version = int(version_numbers[1])
455
826
  self.patch_version = int(version_numbers[2])
827
+
456
828
  return version
457
829
  elif request.status_code == requests.codes.unauthorized:
458
830
  self.token = self.__token__()
@@ -461,24 +833,31 @@ class AuthenticatedAPI:
461
833
  logger.error(f"version number failed with http response {request.status_code}")
462
834
  logger.error(str(request.content))
463
835
  RuntimeError(request.status_code, "version number failed")
836
+ return None
837
+
838
+
464
839
 
465
840
  def __str__(self):
466
- return f"pyPreservica version: {pyPreservica.__version__} (Preservica 6.2 Compatible) " \
467
- f"Connected to: {self.server} Preservica version: {self.version} as {self.username}"
841
+ return f"pyPreservica version: {pyPreservica.__version__} (Preservica 8.0 Compatible) " \
842
+ f"Connected to: {self.server} Preservica version: {self.version} as {self.username} " \
843
+ f"in tenancy {self.tenant}"
468
844
 
469
845
  def __repr__(self):
470
846
  return self.__str__()
471
847
 
472
848
  def save_config(self):
473
- config = configparser.ConfigParser()
849
+ config = configparser.RawConfigParser(interpolation=None)
474
850
  config['credentials'] = {'username': self.username, 'password': self.password, 'tenant': self.tenant,
475
851
  'server': self.server}
476
- with open('credentials.properties', 'wt') as configfile:
852
+ if self.two_fa_secret_key is not None:
853
+ config['credentials']['twoFactorToken'] = self.two_fa_secret_key
854
+
855
+ with open('credentials.properties', 'wt', encoding="utf-8") as configfile:
477
856
  config.write(configfile)
478
857
 
479
- def manager_token(self, username, password):
858
+ def manager_token(self, username: str, password: str) -> str:
480
859
  data = {'username': username, 'password': password, 'tenant': self.tenant}
481
- response = self.session.post(f'https://{self.server}/api/accesstoken/login', data=data)
860
+ response = self.session.post(f'{self.protocol}://{self.server}/api/accesstoken/login', data=data)
482
861
  if response.status_code == requests.codes.ok:
483
862
  return response.json()['token']
484
863
  else:
@@ -486,19 +865,58 @@ class AuthenticatedAPI:
486
865
  logger.error(msg)
487
866
  logger.error(response.status_code)
488
867
  logger.error(str(response.content))
489
- RuntimeError(response.status_code, "Could not generate valid manager approval password")
868
+ RuntimeError(response.status_code, "Could not generate valid manager approval token")
490
869
 
491
- def __token__(self):
870
+ def __token__(self) -> str:
871
+ """
872
+ Generate am API token to use to authenticate calls
873
+ :return: API Token
874
+ """
492
875
  logger.debug("Token Expired Requesting New Token")
493
876
  if self.shared_secret is False:
494
- if self.tenant == "%":
495
- data = {'username': self.username, 'password': self.password}
877
+ if self.tenant is None:
878
+ data = {'username': self.username, 'password': self.password, 'includeUserDetails': 'true'}
496
879
  else:
497
880
  data = {'username': self.username, 'password': self.password, 'tenant': self.tenant}
498
- response = self.session.post(f'https://{self.server}/api/accesstoken/login', data=data)
881
+ response = self.session.post(f'{self.protocol}://{self.server}/api/accesstoken/login', data=data)
499
882
  if response.status_code == requests.codes.ok:
883
+ if self.tenant is None:
884
+ self.tenant = response.json()['tenant']
500
885
  return response.json()['token']
501
886
  else:
887
+ if 'message' in response.json():
888
+ if response.json()['message'] == "needs.2fa":
889
+ logger.debug("2FA Found")
890
+ if self.tenant is None:
891
+ self.tenant = response.json()['tenant']
892
+ if self.two_fa_secret_key:
893
+ logger.debug("Found Two Factor Token")
894
+ totp = pyotp.TOTP(self.two_fa_secret_key)
895
+ data = {'username': self.username,
896
+ 'continuationToken': response.json()['continuationToken'],
897
+ 'tenant': self.tenant, 'twoFactorToken': totp.now()}
898
+
899
+ header = {'Content-Type': 'application/x-www-form-urlencoded'}
900
+ response_2fa = self.session.post(
901
+ f'{self.protocol}://{self.server}/api/accesstoken/complete-2fa',
902
+ data=data, headers=header)
903
+ if response_2fa.status_code == requests.codes.ok:
904
+ return response_2fa.json()['token']
905
+ else:
906
+ msg = "Failed to create a 2FA authentication token. Check your credentials are correct"
907
+ logger.error(msg)
908
+ logger.error(str(response_2fa.content))
909
+ raise RuntimeError(response_2fa.status_code, msg)
910
+ else:
911
+ msg = "2FA twoFactorToken required to authenticate against this account using 2FA"
912
+ logger.error(msg)
913
+ logger.error(str(response.content))
914
+ raise RuntimeError(response.status_code, msg)
915
+ if response.json()['message'] == "needs.2fa.setup":
916
+ msg = "2FA is activated but not yet set up"
917
+ logger.error(msg)
918
+ logger.error(str(response.content))
919
+ raise RuntimeError(response.status_code, msg)
502
920
  msg = "Failed to create a password based authentication token. Check your credentials are correct"
503
921
  logger.error(msg)
504
922
  logger.error(str(response.content))
@@ -511,7 +929,7 @@ class AuthenticatedAPI:
511
929
  sha1 = hashlib.sha1()
512
930
  sha1.update(to_hash.encode(encoding='utf-8'))
513
931
  data = {"username": self.username, "tenant": self.tenant, "timestamp": timestamp, "hash": sha1.hexdigest()}
514
- response = self.session.post(f'https://{self.server}/{endpoint}', data=data)
932
+ response = self.session.post(f'{self.protocol}://{self.server}/{endpoint}', data=data)
515
933
  if response.status_code == requests.codes.ok:
516
934
  return response.json()['token']
517
935
  else:
@@ -519,12 +937,40 @@ class AuthenticatedAPI:
519
937
  logger.error(msg)
520
938
  raise RuntimeError(response.status_code, msg)
521
939
 
522
- def __init__(self, username=None, password=None, tenant=None, server=None, use_shared_secret=False):
523
- config = configparser.ConfigParser()
524
- config.read('credentials.properties')
525
- self.session = requests.Session()
526
- self.shared_secret = bool(use_shared_secret)
940
+ def __init__(self, username: str = None, password: str = None, tenant: str = None, server: str = None,
941
+ use_shared_secret: bool = False, two_fa_secret_key: str = None,
942
+ protocol: str = "https", request_hook=None, credentials_path: str = 'credentials.properties'):
943
+
944
+ config = configparser.ConfigParser(interpolation=configparser.Interpolation())
945
+ config.read(os.path.relpath(credentials_path), encoding='utf-8')
946
+ self.session: Session = requests.Session()
947
+
948
+ if request_hook is not None:
949
+ self.session.hooks['response'].append(request_hook)
527
950
 
951
+ retries = Retry(
952
+ total=3,
953
+ backoff_factor=0.1,
954
+ status_forcelist=[502, 503, 504],
955
+ allowed_methods=Retry.DEFAULT_ALLOWED_METHODS
956
+ )
957
+
958
+ self.shared_secret: bool = bool(use_shared_secret)
959
+ self.protocol = protocol
960
+ self.two_fa_secret_key = two_fa_secret_key
961
+
962
+ self.session.mount(f'{self.protocol}://', HTTPAdapter(max_retries=retries))
963
+
964
+ self.session.request = functools.partial(self.session.request, timeout=TIME_OUT)
965
+
966
+ if not two_fa_secret_key:
967
+ two_fa_secret_key = os.environ.get('PRESERVICA_2FA_TOKEN')
968
+ if two_fa_secret_key is None:
969
+ try:
970
+ two_fa_secret_key = config['credentials']['twoFactorToken']
971
+ except KeyError:
972
+ pass
973
+ self.two_fa_secret_key = two_fa_secret_key
528
974
  if not username:
529
975
  username = os.environ.get('PRESERVICA_USERNAME')
530
976
  if username is None:
@@ -562,10 +1008,8 @@ class AuthenticatedAPI:
562
1008
  pass
563
1009
  if not tenant:
564
1010
  msg = "No valid tenant found in method arguments, environment variables or credentials.properties file"
565
- logger.error(msg)
566
- raise RuntimeError(msg)
567
- else:
568
- self.tenant = tenant
1011
+ logger.debug(msg)
1012
+ self.tenant = tenant
569
1013
 
570
1014
  if not server:
571
1015
  server = os.environ.get('PRESERVICA_SERVER')
@@ -584,7 +1028,22 @@ class AuthenticatedAPI:
584
1028
  self.token = self.__token__()
585
1029
  self.version = self.__version_number__()
586
1030
  self.__version_namespace__()
1031
+ self.roles = self._find_user_roles_()
1032
+
1033
+ self.session.headers.update({'User-Agent': f'pyPreservica SDK/({pyPreservica.__version__}) '
1034
+ f' ({platform.platform()}/{os.name}/{sys.platform})'})
587
1035
 
588
- logger.debug(str(self))
589
1036
  logger.debug(self.xip_ns)
590
1037
  logger.debug(self.entity_ns)
1038
+
1039
+ def parse_date_to_iso(date):
1040
+ try:
1041
+ date = datetime.datetime.fromisoformat(date.replace('Z','+0000'))
1042
+ if date.tzinfo is None or date.tzinfo.utcoffset(date) is None:
1043
+ date = date.replace(tzinfo=datetime.timezone.utc)
1044
+ date = date.strftime('%Y-%m-%dT%H:%M:%S.%f%z')
1045
+ except ValueError:
1046
+ date = dateutil.parser.parse(date)
1047
+ if date.tzinfo is None or date.tzinfo.utcoffset(date) is None:
1048
+ date = date.replace(tzinfo=datetime.timezone.utc)
1049
+ date = date.strftime('%Y-%m-%dT%H:%M:%S.%f%z')