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.
- {launchpy-0.5.0.post1/launchpy.egg-info → launchpy-0.5.2}/PKG-INFO +31 -3
- launchpy-0.5.2/README.md +67 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/releases.md +15 -0
- launchpy-0.5.2/launchpy/__version__.py +1 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/cli/__main__.py +55 -16
- launchpy-0.5.2/launchpy/connector.py +193 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/property.py +3 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/synchronizer.py +68 -34
- {launchpy-0.5.0.post1 → launchpy-0.5.2/launchpy.egg-info}/PKG-INFO +31 -3
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/SOURCES.txt +2 -1
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/pyproject.toml +0 -2
- launchpy-0.5.2/tests/test_connector.py +253 -0
- launchpy-0.5.0.post1/README.md +0 -37
- launchpy-0.5.0.post1/launchpy/__version__.py +0 -1
- launchpy-0.5.0.post1/launchpy/connector.py +0 -237
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/LICENSE +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/admin.md +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/cli.md +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/getstarted.md +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/library.md +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/main.md +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/property.md +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/synchronizer.md +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/docs/translator.md +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/__init__.py +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/admin.py +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/cli/__init__.py +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/config.py +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/configs.py +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/launchpy.py +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy/library.py +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/dependency_links.txt +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/entry_points.txt +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/requires.txt +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/launchpy.egg-info/top_level.txt +0 -0
- {launchpy-0.5.0.post1 → launchpy-0.5.2}/requirements.txt +0 -0
- {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.
|
|
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.
|
launchpy-0.5.2/README.md
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'],
|
|
852
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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']:
|