launchpy 0.5.0.post1__tar.gz → 0.5.2__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.
Files changed (37) hide show
  1. {launchpy-0.5.0.post1/launchpy.egg-info → launchpy-0.5.2}/PKG-INFO +31 -3
  2. launchpy-0.5.2/README.md +67 -0
  3. {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/releases.md +15 -0
  4. launchpy-0.5.2/launchpy/__version__.py +1 -0
  5. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/cli/__main__.py +55 -16
  6. launchpy-0.5.2/launchpy/connector.py +193 -0
  7. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/property.py +3 -0
  8. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/synchronizer.py +68 -34
  9. {launchpy-0.5.0.post1 → launchpy-0.5.2/launchpy.egg-info}/PKG-INFO +31 -3
  10. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/SOURCES.txt +2 -1
  11. {launchpy-0.5.0.post1 → launchpy-0.5.2}/pyproject.toml +0 -2
  12. launchpy-0.5.2/tests/test_connector.py +253 -0
  13. launchpy-0.5.0.post1/README.md +0 -37
  14. launchpy-0.5.0.post1/launchpy/__version__.py +0 -1
  15. launchpy-0.5.0.post1/launchpy/connector.py +0 -237
  16. {launchpy-0.5.0.post1 → launchpy-0.5.2}/LICENSE +0 -0
  17. {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/admin.md +0 -0
  18. {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/cli.md +0 -0
  19. {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/getstarted.md +0 -0
  20. {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/library.md +0 -0
  21. {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/main.md +0 -0
  22. {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/property.md +0 -0
  23. {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/synchronizer.md +0 -0
  24. {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/translator.md +0 -0
  25. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/__init__.py +0 -0
  26. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/admin.py +0 -0
  27. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/cli/__init__.py +0 -0
  28. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/config.py +0 -0
  29. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/configs.py +0 -0
  30. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/launchpy.py +0 -0
  31. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/library.py +0 -0
  32. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/dependency_links.txt +0 -0
  33. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/entry_points.txt +0 -0
  34. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/requires.txt +0 -0
  35. {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/top_level.txt +0 -0
  36. {launchpy-0.5.0.post1 → launchpy-0.5.2}/requirements.txt +0 -0
  37. {launchpy-0.5.0.post1 → launchpy-0.5.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: launchpy
3
- Version: 0.5.0.post1
3
+ Version: 0.5.2
4
4
  Summary: Adobe Launch API wrapper
5
5
  Author-email: Julien Piccini <piccini.julien@gmail.com>
6
6
  License: Apache License 2.0
@@ -12,8 +12,6 @@ Classifier: License :: OSI Approved :: Apache Software License
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python
14
14
  Classifier: Topic :: Scientific/Engineering :: Information Analysis
15
- Classifier: Programming Language :: Python :: 3.10
16
- Classifier: Programming Language :: Python :: 3.11
17
15
  Classifier: Programming Language :: Python :: 3.12
18
16
  Classifier: Programming Language :: Python :: 3.13
19
17
  Classifier: Programming Language :: Python :: 3.14
@@ -36,6 +34,10 @@ Dynamic: license-file
36
34
  Python wrapper for the Adobe Launch API.\
37
35
  This module is named launchpy.
38
36
 
37
+ Current version: **0.5.2**. This version fixes GET retries and adds automatic
38
+ HTTP 429 retries across all five connector methods. See the
39
+ [0.5.2 release notes](./docs/releases.md#052) for details.
40
+
39
41
  ## Installation
40
42
 
41
43
  You can install the module by realising the following pip command:\
@@ -68,3 +70,29 @@ Most of the documentation has been imported from the datanalyst website [here](.
68
70
  ## Release information
69
71
 
70
72
  You can find release information [here](./docs/releases.md).
73
+
74
+ ## HTTP retries
75
+
76
+ The `AdobeRequest` connector always retries HTTP 429 responses for GET, POST,
77
+ PUT, PATCH, and DELETE, even when their body is not JSON or `retry=0`.
78
+ Rate-limit retries have no attempt limit and do not consume the configured
79
+ `retry` budget. A request can therefore keep waiting while the API remains
80
+ rate limited; it can be interrupted normally.
81
+
82
+ The connector
83
+ honors `Retry-After` in seconds or HTTP-date format; absent or invalid values
84
+ use exponential backoff starting at 45 seconds and capped at 300 seconds.
85
+ The JSON-returning methods also always retry Adobe error code `429050`.
86
+
87
+ The `retry` setting is reserved for other retryable errors: currently, GET
88
+ responses that cannot be decoded as JSON. It specifies the number of
89
+ **additional attempts**, with a default of `0`. Set
90
+ `property.connector.retry = 3` or pass `retry=3` to a connector method to
91
+ override this budget; neither setting limits rate-limit retries.
92
+
93
+ Successful return values are unchanged: DELETE returns the HTTP status code,
94
+ while the other methods return decoded JSON. GET also retries invalid JSON
95
+ responses with a 30-second delay. Invalid JSON from write operations and
96
+ network exceptions do not trigger retries, to avoid replaying writes that
97
+ may already have succeeded. This policy applies to the five connector methods,
98
+ not the separate OAuth token request or async property-extraction requests.
@@ -0,0 +1,67 @@
1
+ # Launchpy
2
+
3
+ Python wrapper for the Adobe Launch API.\
4
+ This module is named launchpy.
5
+
6
+ Current version: **0.5.2**. This version fixes GET retries and adds automatic
7
+ HTTP 429 retries across all five connector methods. See the
8
+ [0.5.2 release notes](./docs/releases.md#052) for details.
9
+
10
+ ## Installation
11
+
12
+ You can install the module by realising the following pip command:\
13
+ `pip install launchpy`
14
+
15
+ You can access the version of the module that you run via the following attribute:\
16
+ `launchpy.__version__`
17
+
18
+ ## Content of the module
19
+
20
+ This module is built around 7 main parts:
21
+
22
+ - Helping functions [Core Components](./docs/main.md)
23
+ - Admin instantiating functions [Admin Class](./docs/admin.md)
24
+ - Managing properties [Property Class](./docs/property.md)
25
+ - Managing Publishing Cycle [Library Class](./docs/library.md)
26
+ - Translator functionationality [Translator Class](./docs/translator.md)
27
+ - Synchronizer [Synchronizer Class](./docs/synchronizer.md)
28
+ - Command Line Interface [CLI](./docs/cli.md)
29
+
30
+ ## Get Started
31
+
32
+ A [get started guide](./docs/getstarted.md) has been created to explain the different functionality.
33
+ You can find a more detail description functionalities at [datanalyst.info](https://datanalyst.info).
34
+
35
+ ## Main documentation
36
+
37
+ Most of the documentation has been imported from the datanalyst website [here](./docs/main.md).
38
+
39
+ ## Release information
40
+
41
+ You can find release information [here](./docs/releases.md).
42
+
43
+ ## HTTP retries
44
+
45
+ The `AdobeRequest` connector always retries HTTP 429 responses for GET, POST,
46
+ PUT, PATCH, and DELETE, even when their body is not JSON or `retry=0`.
47
+ Rate-limit retries have no attempt limit and do not consume the configured
48
+ `retry` budget. A request can therefore keep waiting while the API remains
49
+ rate limited; it can be interrupted normally.
50
+
51
+ The connector
52
+ honors `Retry-After` in seconds or HTTP-date format; absent or invalid values
53
+ use exponential backoff starting at 45 seconds and capped at 300 seconds.
54
+ The JSON-returning methods also always retry Adobe error code `429050`.
55
+
56
+ The `retry` setting is reserved for other retryable errors: currently, GET
57
+ responses that cannot be decoded as JSON. It specifies the number of
58
+ **additional attempts**, with a default of `0`. Set
59
+ `property.connector.retry = 3` or pass `retry=3` to a connector method to
60
+ override this budget; neither setting limits rate-limit retries.
61
+
62
+ Successful return values are unchanged: DELETE returns the HTTP status code,
63
+ while the other methods return decoded JSON. GET also retries invalid JSON
64
+ responses with a 30-second delay. Invalid JSON from write operations and
65
+ network exceptions do not trigger retries, to avoid replaying writes that
66
+ may already have succeeded. This policy applies to the five connector methods,
67
+ not the separate OAuth token request or async property-extraction requests.
@@ -3,6 +3,21 @@
3
3
  This page gathered the changes made between version of the launchpy module.\
4
4
  This has been started after the 0.3.0 release.\
5
5
 
6
+ ## 0.5.2
7
+ * Fixing recursive GET retries and always retrying HTTP 429 responses for GET, POST, PUT, PATCH, and DELETE.
8
+ * Respecting `Retry-After` seconds and HTTP dates, with exponential backoff when the header is absent or invalid.
9
+ * Keeping rate-limit retries independent of the connector's `retry` budget and per-call overrides, including when `retry=0`. Other retryable errors remain bounded by that budget.
10
+ * Preserving DELETE request bodies when sending and retrying requests.
11
+
12
+ ## 0.5.1
13
+ * Raising the minimum supported Python version from 3.10 to 3.12.
14
+ * `syncComponent` now returns a dictionary of per-target status messages. `syncComponents`, `syncRules`, `syncDataElements`, `syncExtensions`, and `syncFromLibrary` now return results grouped by component name or ID, including error messages when library synchronization fails.
15
+ * Updating the CLI synchronization commands to report missing components instead of always reporting success. Successful per-target operations are displayed when verbose mode is enabled.
16
+ * Adding the `-v` / `--verbose` flag to the `sync_rules`, `sync_data_elements`, and `upgrade_extension` CLI commands.
17
+ * `checkExtensionUpdate` now raises a descriptive error when the requested extension is not installed, including the installed extension names.
18
+ * `upgradeTargetExtension` now respects the `platform` parameter and returns per-target upgrade, already-up-to-date, or failure messages. The CLI displays upgrade failures instead of reporting success.
19
+ * Fixing library-linked component preparation when no library component was retrieved, avoiding an uninitialized-variable error.
20
+
6
21
  ## 0.5.0
7
22
  * Fixing the `create_property` method in the CLI
8
23
  * Adding support for multi-org config files.
@@ -0,0 +1 @@
1
+ __version__ = "0.5.2"
@@ -578,7 +578,11 @@ class SynchronizerCLI(cmd.Cmd):
578
578
  result = self.synchronizer.syncComponent(componentId=args.id, publishedVersion=args.published, forceCreation=args.create, verbose=args.verbose)
579
579
  else:
580
580
  result = self.synchronizer.syncComponent(componentName=args.name, publishedVersion=args.published, forceCreation=args.create, verbose=args.verbose)
581
- console.print(f"Component '{args.name or args.id}' synced successfully.", style="green")
581
+ for prop, message in result.items():
582
+ if "does not exist" in message:
583
+ console.print(f"[{prop}] {message}", style="yellow")
584
+ elif args.verbose:
585
+ console.print(f"[{prop}] {message}", style="green")
582
586
  except Exception as e:
583
587
  console.print(f"(!) Error: {str(e)}", style="red")
584
588
  return
@@ -590,32 +594,44 @@ class SynchronizerCLI(cmd.Cmd):
590
594
  parser.add_argument("-r", "--regex", help="Regex pattern to filter rules by name (partial match, non-case sensitive). Default None.", type=str, default=None)
591
595
  parser.add_argument("-c", "--create", help="Create rules that do not exist in the destination property. Default False.", action="store_true", default=False)
592
596
  parser.add_argument("-p", "--published", help="Sync the latest published version of the rules. Default False.", action="store_true", default=False)
597
+ parser.add_argument("-v", "--verbose", help="Print detailed information about the sync process, including successes. Default False.", action="store_true", default=False)
593
598
  try:
594
599
  args = parser.parse_args(shlex.split(args))
595
600
  result = self.synchronizer.syncRules(regex=args.regex, forceCreation=args.create, publishedVersion=args.published)
596
- console.print(f"All rules synced successfully.", style="green")
601
+ for ruleName, targetResults in result.items():
602
+ for prop, message in (targetResults or {}).items():
603
+ if "does not exist" in message:
604
+ console.print(f"[{ruleName}][{prop}] {message}", style="yellow")
605
+ elif args.verbose:
606
+ console.print(f"[{ruleName}][{prop}] {message}", style="green")
597
607
  except Exception as e:
598
608
  console.print(f"(!) Error: {str(e)}", style="red")
599
609
  return
600
610
  except SystemExit:
601
611
  return
602
-
612
+
603
613
  def do_sync_data_elements(self,args:Any):
604
614
  """Sync all data elements between source and destination properties."""
605
615
  parser = argparse.ArgumentParser(prog='sync_data_elements', add_help=True)
606
616
  parser.add_argument("-r", "--regex", help="Regex pattern to filter data elements by name (partial match, non-case sensitive). Default None.", type=str, default=None)
607
617
  parser.add_argument("-c", "--create", help="Create data elements that do not exist in the destination property. Default False.", action="store_true", default=False)
608
618
  parser.add_argument("-p", "--published", help="Sync the latest published version of the data elements. Default False.", action="store_true", default=False)
619
+ parser.add_argument("-v", "--verbose", help="Print detailed information about the sync process, including successes. Default False.", action="store_true", default=False)
609
620
  try:
610
621
  args = parser.parse_args(shlex.split(args))
611
622
  result = self.synchronizer.syncDataElements(regex=args.regex, forceCreation=args.create, publishedVersion=args.published)
612
- console.print(f"All data elements synced successfully.", style="green")
623
+ for deName, targetResults in result.items():
624
+ for prop, message in (targetResults or {}).items():
625
+ if "does not exist" in message:
626
+ console.print(f"[{deName}][{prop}] {message}", style="yellow")
627
+ elif args.verbose:
628
+ console.print(f"[{deName}][{prop}] {message}", style="green")
613
629
  except Exception as e:
614
630
  console.print(f"(!) Error: {str(e)}", style="red")
615
631
  return
616
632
  except SystemExit:
617
633
  return
618
-
634
+
619
635
  def do_sync_extensions(self,args:Any):
620
636
  """Sync all extensions between source and destination properties."""
621
637
  parser = argparse.ArgumentParser(prog='sync_extensions', add_help=True)
@@ -625,13 +641,18 @@ class SynchronizerCLI(cmd.Cmd):
625
641
  try:
626
642
  args = parser.parse_args(shlex.split(args))
627
643
  result = self.synchronizer.syncExtensions(regex=args.regex, forceCreation=args.create, verbose=args.verbose)
628
- console.print(f"All extensions synced successfully.", style="green")
644
+ for extName, targetResults in result.items():
645
+ for prop, message in (targetResults or {}).items():
646
+ if "does not exist" in message:
647
+ console.print(f"[{extName}][{prop}] {message}", style="yellow")
648
+ elif args.verbose:
649
+ console.print(f"[{extName}][{prop}] {message}", style="green")
629
650
  except Exception as e:
630
651
  console.print(f"(!) Error: {str(e)}", style="red")
631
652
  return
632
653
  except SystemExit:
633
654
  return
634
-
655
+
635
656
  def do_rename_component(self,args:Any):
636
657
  """Rename a component in the destination property."""
637
658
  parser = argparse.ArgumentParser(prog='rename_component', add_help=True)
@@ -661,16 +682,22 @@ class SynchronizerCLI(cmd.Cmd):
661
682
  parser = argparse.ArgumentParser(prog='upgrade_extension', add_help=True)
662
683
  parser.add_argument("name", help="Name of the extension to upgrade", type=str)
663
684
  parser.add_argument("-p", "--platform", help="Platform to be used for extension upgrade. default 'web'. Possible values: web, app", type=str, default="web")
685
+ parser.add_argument("-v", "--verbose", help="Print detailed information about the upgrade process, including successes. Default False.", action="store_true", default=False)
664
686
  try:
665
687
  args = parser.parse_args(shlex.split(args))
666
- self.synchronizer.upgradeTargetExtension(extensionName=args.name, platform=args.platform)
667
- console.print(f"Extension '{args.name}' upgraded successfully.", style="green")
688
+ response = self.synchronizer.upgradeTargetExtension(extensionName=args.name, platform=args.platform, verbose=args.verbose)
689
+ for prop, message in response.items():
690
+ failed = message.startswith("Could not upgrade")
691
+ if failed:
692
+ console.print(f"[{prop}] {message}", style="red")
693
+ elif args.verbose:
694
+ console.print(f"[{prop}] {message}", style="green")
668
695
  except Exception as e:
669
696
  console.print(f"(!) Error: {str(e)}", style="red")
670
697
  return
671
698
  except SystemExit:
672
699
  return
673
-
700
+
674
701
  def do_get_base_rules(self,args:Any):
675
702
  """Get the base rules that are available for synchronization."""
676
703
  parser = argparse.ArgumentParser(prog='get_base_rules', add_help=True)
@@ -848,8 +875,12 @@ class SynchronizerCLI(cmd.Cmd):
848
875
  console.print(f"Upgrading {len(extensions)} extension{'s' if len(extensions) > 1 else ''} from library '{lib.name}'...", style="blue")
849
876
  for ext in extensions:
850
877
  try:
851
- self.synchronizer.upgradeTargetExtension(extensionName=ext['attributes']['name'], platform=ext['attributes']['platform'], publishedVersion=publishedVersion, verbose=args.verbose)
852
- console.print(f"Extension '{ext['attributes']['name']}' upgraded successfully.", style="green")
878
+ response = self.synchronizer.upgradeTargetExtension(extensionName=ext['attributes']['name'], platform=ext['attributes']['platform'], verbose=args.verbose)
879
+ for prop, message in response.items():
880
+ if message.startswith("Could not upgrade"):
881
+ console.print(f"[{prop}] {message}", style="red")
882
+ elif args.verbose:
883
+ console.print(f"[{prop}] {message}", style="green")
853
884
  except Exception as e:
854
885
  console.print(f"(!) Error upgrading extension '{ext['attributes']['name']}': {str(e)}", style="red")
855
886
  if len(rules)>0:
@@ -860,8 +891,12 @@ class SynchronizerCLI(cmd.Cmd):
860
891
  ruleId = rule['links']['self'].split('/').pop()
861
892
  else:
862
893
  ruleId = rule['id']
863
- self.synchronizer.syncComponent(componentId=ruleId, publishedVersion=publishedVersion, forceCreation=args.create,libraryLinked=libraryLinked,verbose=args.verbose)
864
- console.print(f"Rule '{rule['attributes']['name']}' synced successfully.", style="green")
894
+ response = self.synchronizer.syncComponent(componentId=ruleId, publishedVersion=publishedVersion, forceCreation=args.create,libraryLinked=libraryLinked,verbose=args.verbose)
895
+ for prop, message in response.items():
896
+ if "does not exist" in message:
897
+ console.print(f"[{prop}] {message}", style="yellow")
898
+ elif args.verbose:
899
+ console.print(f"[{prop}] {message}", style="green")
865
900
  except Exception as e:
866
901
  console.print(f"(!) Error syncing rule '{rule['attributes']['name']}': {str(e)}", style="red")
867
902
  if len(dataelements)>0:
@@ -872,8 +907,12 @@ class SynchronizerCLI(cmd.Cmd):
872
907
  deId = de['links']['self'].split('/').pop()
873
908
  else:
874
909
  deId = de['id']
875
- self.synchronizer.syncComponent(componentId=deId, publishedVersion=publishedVersion, forceCreation=args.create,libraryLinked=libraryLinked,verbose=args.verbose)
876
- console.print(f"Data element '{de['attributes']['name']}' synced successfully.", style="green")
910
+ response = self.synchronizer.syncComponent(componentId=deId, publishedVersion=publishedVersion, forceCreation=args.create,libraryLinked=libraryLinked,verbose=args.verbose)
911
+ for prop, message in response.items():
912
+ if "does not exist" in message:
913
+ console.print(f"[{prop}] {message}", style="yellow")
914
+ elif args.verbose:
915
+ console.print(f"[{prop}] {message}", style="green")
877
916
  except Exception as e:
878
917
  console.print(f"(!) Error syncing data element '{de['attributes']['name']}': {str(e)}", style="red")
879
918
  except Exception as e:
@@ -0,0 +1,193 @@
1
+ import os
2
+ import json
3
+ import logging
4
+ import time
5
+ from datetime import datetime, timezone
6
+ from email.utils import parsedate_to_datetime
7
+ from typing import Any, Dict, Union
8
+ from copy import deepcopy
9
+ # Non standard libraries
10
+ import requests
11
+ from launchpy import config, configs
12
+
13
+ class AdobeRequest:
14
+ """
15
+ Handle request to Audience Manager and taking care that the request have a valid token set each time.
16
+ """
17
+
18
+ def __init__(self,
19
+ config_objects: dict = config.config_objects,
20
+ headers: dict = config.headers,
21
+ org_name: str = None,
22
+ verbose: bool = False,
23
+ retry: int = 0
24
+ ) -> None:
25
+ """
26
+ Set the connector to be used for handling request to AAM
27
+ Arguments:
28
+ config_objects : OPTIONAL : Require the importConfig file to have been used.
29
+ headers : OPTIONAL : header of the config modules
30
+ verbose : OPTIONAL : display comment on the request.
31
+ retry : OPTIONAL : Number of additional attempts for GET responses
32
+ that cannot be decoded as JSON. Default: 0.
33
+ Rate-limited requests always retry and do not consume this budget.
34
+ """
35
+ if org_name is None:
36
+ if len(config_objects) >= 1:
37
+ self.config_key = list(config_objects.keys())[0]
38
+ config_object = config_objects[self.config_key]
39
+ else:
40
+ if org_name in config_objects.keys():
41
+ self.config_key = org_name
42
+ config_object = config_objects[self.config_key]
43
+ else:
44
+ raise ValueError(f"Configuration for org_name '{org_name}' not found.")
45
+ if config_object['org_id'] == '':
46
+ raise Exception(
47
+ 'You have to upload the configuration file with importConfigFile method.')
48
+ self.config = {self.config_key: deepcopy(config_object)}
49
+ header = headers[self.config_key]
50
+ self.header = deepcopy(header)
51
+ self.retry = retry
52
+ if self.config[self.config_key].get('token', '') == '' or time.time() > self.config[self.config_key].get('date_limit', 0):
53
+ if 'scopes' in self.config[self.config_key].keys() and self.config[self.config_key].get('scopes',None) is not None:
54
+ self.connectionType = 'oauthV2'
55
+ token_and_expiry = self.get_oauth_token_and_expiry_for_config(config=self.config[self.config_key], verbose=verbose)
56
+ else:
57
+ raise ValueError("Invalid configuration: missing 'scopes' for OAuth V2 authentication.")
58
+ token = token_and_expiry['token']
59
+ expiry = token_and_expiry['expiry']
60
+ self.token = deepcopy(token)
61
+ self.config[self.config_key]['token'] = deepcopy(token)
62
+ self.config[self.config_key]['date_limit'] = deepcopy(time.time() + expiry - 500)
63
+ self.header.update({'Authorization': f'Bearer {token}'})
64
+
65
+ def get_oauth_token_and_expiry_for_config(self,config:dict,verbose:bool=False,save:bool=False)->Dict[str,str]:
66
+ """
67
+ Retrieve the access token by using the OAuth information provided by the user
68
+ during the import importConfigFile function.
69
+ Arguments :
70
+ config : REQUIRED : Configuration object.
71
+ verbose : OPTIONAL : Default False. If set to True, print information.
72
+ save : OPTIONAL : Default False. If set to True, save the token in the 'token.txt' file.
73
+ """
74
+ if config is None:
75
+ raise ValueError("config dictionary is required")
76
+ oauth_payload = {
77
+ "grant_type": "client_credentials",
78
+ "client_id": config["client_id"],
79
+ "client_secret": config["secret"],
80
+ "scope": config["scopes"]
81
+ }
82
+ response = requests.post(
83
+ config["oauthTokenEndpointV2"], data=oauth_payload)
84
+ json_response = response.json()
85
+ if 'access_token' in json_response.keys():
86
+ token = json_response['access_token']
87
+ expiry = json_response["expires_in"]
88
+ else:
89
+ return json.dumps(json_response,indent=2)
90
+ if save:
91
+ with open('token.txt', 'w') as f:
92
+ f.write(token)
93
+ if verbose:
94
+ print('token valid till : ' + time.ctime(time.time() + expiry))
95
+ return {'token': token, 'expiry': expiry}
96
+
97
+ def _checkingDate(self) -> None:
98
+ """
99
+ Checking if the token is still valid
100
+ """
101
+ now = time.time()
102
+ if now > self.config[self.config_key]['date_limit']:
103
+ if self.connectionType =='oauthV2':
104
+ token_and_expiry = self.get_oauth_token_and_expiry_for_config(config=self.config[self.config_key], verbose=False)
105
+ token = token_and_expiry['token']
106
+ self.config[self.config_key]['token'] = deepcopy(token)
107
+ self.config[self.config_key]['date_limit'] = deepcopy(time.time() + token_and_expiry['expiry'] - 500)
108
+ self.header.update({'Authorization': f'Bearer {token}'})
109
+
110
+ @staticmethod
111
+ def _retry_delay(response: requests.Response, attempt: int) -> float:
112
+ retry_after = response.headers.get("Retry-After")
113
+ if retry_after is not None:
114
+ try:
115
+ seconds = int(retry_after)
116
+ if seconds >= 0:
117
+ return float(seconds)
118
+ except ValueError:
119
+ try:
120
+ retry_at = parsedate_to_datetime(retry_after)
121
+ return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
122
+ except (TypeError, ValueError, OverflowError):
123
+ pass
124
+ return min(45 * 2 ** min(attempt, 3), 300)
125
+
126
+ def _request(self, method: str, endpoint: str, params: dict | None = None,
127
+ data: Any = None, headers: dict | None = None, *,
128
+ retry: int | None = None, verbose: bool = False) -> Any:
129
+ retries = self.retry if retry is None else retry
130
+ if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0:
131
+ raise ValueError("retry must be a non-negative integer")
132
+ request_kwargs: dict[str, Any] = {}
133
+ if params is not None:
134
+ request_kwargs["params"] = params
135
+ if data is not None:
136
+ request_kwargs["data"] = data if method == "get" else json.dumps(data)
137
+ attempt = 0
138
+ rate_limit_attempt = 0
139
+ while True:
140
+ self._checkingDate()
141
+ response = getattr(requests, method)(
142
+ endpoint, headers=self.header if headers is None else headers, **request_kwargs)
143
+ if verbose:
144
+ print(f"request URL : {response.request.url}")
145
+ print(f"status_code : {response.status_code}")
146
+ rate_limited = response.status_code == 429
147
+ if not rate_limited:
148
+ if method == "delete":
149
+ return response.status_code
150
+ try:
151
+ result = response.json()
152
+ except ValueError:
153
+ if method == "get" and attempt < retries:
154
+ time.sleep(30)
155
+ attempt += 1
156
+ continue
157
+ logging.getLogger(__name__).warning(
158
+ "%s request returned invalid JSON (HTTP %s)",
159
+ method.upper(), response.status_code)
160
+ return {"error": "Request Error"}
161
+ rate_limited = isinstance(result, dict) and result.get("error_code") == "429050"
162
+ if not rate_limited:
163
+ return result
164
+ delay = self._retry_delay(response, rate_limit_attempt)
165
+ if verbose:
166
+ print(f"Rate limited; retrying in {delay:g} seconds")
167
+ time.sleep(delay)
168
+ rate_limit_attempt += 1
169
+
170
+ def getData(self, endpoint: str, params: dict = None, data: dict = None, headers: dict = None, *args, **kwargs):
171
+ """Get JSON data, always retrying rate limits independently of retry."""
172
+ return self._request("get", endpoint, params, data, headers,
173
+ retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
174
+
175
+ def postData(self, endpoint: str, params: dict = None, data: dict = None, headers: dict = None, *args, **kwargs):
176
+ """Post JSON data, always retrying rate limits."""
177
+ return self._request("post", endpoint, params, data, headers,
178
+ retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
179
+
180
+ def patchData(self, endpoint: str, params: dict = None, data=None, headers: dict = None, *args, **kwargs):
181
+ """Patch JSON data, always retrying rate limits."""
182
+ return self._request("patch", endpoint, params, data, headers,
183
+ retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
184
+
185
+ def putData(self, endpoint: str, params: dict = None, data=None, headers: dict = None, *args, **kwargs):
186
+ """Put JSON data, always retrying rate limits."""
187
+ return self._request("put", endpoint, params, data, headers,
188
+ retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
189
+
190
+ def deleteData(self, endpoint: str, params: dict = None, data=None, headers: dict = None, *args, **kwargs):
191
+ """Delete data and return the HTTP status, always retrying rate limits."""
192
+ return self._request("delete", endpoint, params, data, headers,
193
+ retry=kwargs.get("retry"), verbose=kwargs.get("verbose", False))
@@ -199,6 +199,9 @@ class Property:
199
199
  'update': False,
200
200
  'internal_id': ext['id']
201
201
  } for ext in extensions if ext['attributes']['name'] == name}
202
+ if len(dict_extensions) == 0:
203
+ installed_names = [ext['attributes']['name'] for ext in extensions]
204
+ raise ValueError(f"No extension named '{name}' is installed on this property. Installed extensions: {installed_names}")
202
205
  for name in dict_extensions:
203
206
  new_id = self._getExtensionPackageIdByName(name, platform=platform, verbose=verbose)
204
207
  if new_id != dict_extensions[name]['package_id']: