launchpy 0.5.3__tar.gz → 0.5.4__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.3 → launchpy-0.5.4}/PKG-INFO +1 -1
  2. {launchpy-0.5.3 → launchpy-0.5.4}/docs/cli.md +6 -0
  3. {launchpy-0.5.3 → launchpy-0.5.4}/docs/releases.md +7 -0
  4. {launchpy-0.5.3 → launchpy-0.5.4}/docs/synchronizer.md +10 -0
  5. launchpy-0.5.4/launchpy/__version__.py +1 -0
  6. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/cli/__main__.py +4 -2
  7. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/synchronizer.py +38 -29
  8. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy.egg-info/PKG-INFO +1 -1
  9. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy.egg-info/SOURCES.txt +2 -1
  10. launchpy-0.5.4/tests/test_synchronizer.py +428 -0
  11. launchpy-0.5.3/launchpy/__version__.py +0 -1
  12. {launchpy-0.5.3 → launchpy-0.5.4}/LICENSE +0 -0
  13. {launchpy-0.5.3 → launchpy-0.5.4}/README.md +0 -0
  14. {launchpy-0.5.3 → launchpy-0.5.4}/docs/admin.md +0 -0
  15. {launchpy-0.5.3 → launchpy-0.5.4}/docs/getstarted.md +0 -0
  16. {launchpy-0.5.3 → launchpy-0.5.4}/docs/library.md +0 -0
  17. {launchpy-0.5.3 → launchpy-0.5.4}/docs/main.md +0 -0
  18. {launchpy-0.5.3 → launchpy-0.5.4}/docs/property.md +0 -0
  19. {launchpy-0.5.3 → launchpy-0.5.4}/docs/translator.md +0 -0
  20. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/__init__.py +0 -0
  21. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/admin.py +0 -0
  22. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/cli/__init__.py +0 -0
  23. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/config.py +0 -0
  24. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/configs.py +0 -0
  25. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/connector.py +0 -0
  26. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/launchpy.py +0 -0
  27. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/library.py +0 -0
  28. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy/property.py +0 -0
  29. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy.egg-info/dependency_links.txt +0 -0
  30. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy.egg-info/entry_points.txt +0 -0
  31. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy.egg-info/requires.txt +0 -0
  32. {launchpy-0.5.3 → launchpy-0.5.4}/launchpy.egg-info/top_level.txt +0 -0
  33. {launchpy-0.5.3 → launchpy-0.5.4}/pyproject.toml +0 -0
  34. {launchpy-0.5.3 → launchpy-0.5.4}/requirements.txt +0 -0
  35. {launchpy-0.5.3 → launchpy-0.5.4}/setup.cfg +0 -0
  36. {launchpy-0.5.3 → launchpy-0.5.4}/tests/test_connector.py +0 -0
  37. {launchpy-0.5.3 → launchpy-0.5.4}/tests/test_proxies.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: launchpy
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Adobe Launch API wrapper
5
5
  Author-email: Julien Piccini <piccini.julien@gmail.com>
6
6
  License: Apache License 2.0
@@ -533,9 +533,12 @@ Arguments:
533
533
  `-r`, `--regex`: Regex pattern to filter rules by name (partial match, non-case sensitive). This is an optional parameter, if not provided, all rules are synced.
534
534
  `-c`, `--create` : Flag. Create rules that do not exist in the destination property. Default False.
535
535
  `-p`, `--published` : Flag. Sync the latest published version of the rules. Default False.
536
+ `-i`, `--ignore_disabled` : Optional boolean. Skip disabled source rules. Default False; a bare flag means True. With `-p`, checks the latest published source revision.
536
537
 
537
538
  ```bash
538
539
  synchronizer:Base_Property_Name> sync_rules -r "Rule Name" -c
540
+ synchronizer:Base_Property_Name> sync_rules -i
541
+ synchronizer:Base_Property_Name> sync_rules --ignore_disabled True -p
539
542
  ```
540
543
 
541
544
  #### sync_data_elements
@@ -544,9 +547,12 @@ Arguments:
544
547
  `-r`, `--regex`: Regex pattern to filter data elements by name (partial match, non-case sensitive). This is an optional parameter, if not provided, all data elements are synced.
545
548
  `-c`, `--create` : Flag. Create data elements that do not exist in the destination property. Default False.
546
549
  `-p`, `--published` : Flag. Sync the latest published version of the data elements. Default False.
550
+ `-i`, `--ignore_disabled` : Optional boolean. Skip disabled source data elements. Default False; a bare flag means True. With `-p`, checks the latest published source revision.
547
551
 
548
552
  ```bash
549
553
  synchronizer:Base_Property_Name> sync_data_elements -r "Data Element Name" -c
554
+ synchronizer:Base_Property_Name> sync_data_elements -i
555
+ synchronizer:Base_Property_Name> sync_data_elements --ignore_disabled False
550
556
  ```
551
557
 
552
558
  #### sync_extensions
@@ -3,6 +3,13 @@
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.4
7
+ * Fixing enabled state handling for rules, data elements, and extensions during synchronization. Before release, state enablement was respected only during -published synchronization.
8
+ * Ensuring that the verbose flag correctly displays detailed information for all synchronized entities, including those with enabled state changes.
9
+ * Adding the `ignore_disabled` flag to the synchronization commands to allow skipping disabled source entities.
10
+ * Adding `-i` / `--ignore_disabled` flag to the CLI synchronization commands.
11
+
12
+
6
13
  ## 0.5.3
7
14
  * Adding optional `proxy` support to `configure`, `importConfigFile`, `createConfigFile` and JSON configuration files. A single proxy URL is used for both HTTP and HTTPS, with separate settings per organization.
8
15
  * Applying proxy settings to OAuth token acquisition and refresh, all API requests and their retries, and asynchronous rule-component downloads during `extractProperty`.
@@ -150,6 +150,7 @@ synchronizor.syncComponent('my Component Name',publishedVersion=True)
150
150
  #### Data Elements
151
151
  The method will replace the existing configuration of a data element by the template configuration.\
152
152
  Completely overriding the existing element configuration in the target properties.
153
+ When a data element is created, its enabled state is updated if needed to match the selected source revision. The response from that update is retained in the target cache and library stack.
153
154
 
154
155
  #### Rules
155
156
  The method will delete all rule components existing in the Target property for that rule.\
@@ -198,6 +199,7 @@ Arguments:
198
199
  * regex : OPTIONAL : If you want to filter the data elements to sync based on a regex pattern. Default: None (sync all data elements)
199
200
  * forceCreation : OPTIONAL : If set to True, it will sync the data elements even if they do not exist in the target properties. If set to False, it will only sync the data elements that already exist in the target properties. Default: False.
200
201
  * publishedVersion : OPTIONAL : If set to True, it will sync the latest published version of the data elements. Default: False.
202
+ * ignoreDisabled : OPTIONAL : If set to True, skip data elements whose selected source revision has `enabled=False`. Default: False.
201
203
 
202
204
  ## SyncRules
203
205
  It will synchronize the rules in the base property to the target properties.
@@ -205,7 +207,14 @@ Arguments:
205
207
  * regex : OPTIONAL : If you want to filter the rules to sync based on a regex pattern. Default: None (sync all rules)
206
208
  * forceCreation : OPTIONAL : If set to True, it will sync the rules even if they do not exist in the target properties. If set to False, it will only sync the rules that already exist in the target properties. Default: False.
207
209
  * publishedVersion : OPTIONAL : If set to True, it will sync the latest published version of the rules. Default: False.
210
+ * ignoreDisabled : OPTIONAL : If set to True, skip rules whose selected source revision has `enabled=False`. Default: False.
208
211
 
212
+ For both methods, `ignoreDisabled=True` checks the source revision being synchronized, not the target's enabled state. With `publishedVersion=True`, it checks the latest published source revision rather than the current draft. Disabled components are not created or updated, even with `forceCreation=True`, and retain an empty per-target dictionary in the returned results.
213
+
214
+ ```python
215
+ synchronizor.syncRules(ignoreDisabled=True)
216
+ synchronizor.syncDataElements(ignoreDisabled=True, publishedVersion=True)
217
+ ```
209
218
 
210
219
  ## createTargetsLibrary
211
220
 
@@ -238,6 +247,7 @@ Check if the component,from the base property, is synced to the different target
238
247
  It can also check for the Extensions.\
239
248
  It will return a dictionary with the key being the target property and the value being the result of the evaluation, such as {targetProperty: 'similar'} when component have same settings or {targetPropery : '<difference>'} when component do not have same settings.\
240
249
  See below for differences.\
250
+ For rules and data elements, enabled-state differences are checked in both current and published modes. A matching configuration does not override an enabled-state difference with `"Similar"`. When a data element has multiple differences, they are returned together as a comma-separated message. Library dry runs use the same comparison logic.
241
251
  Arguments:
242
252
  * componentName : REQUIRED : the name of the component to compare
243
253
  * componentID : REQUIRED : the id of the component to compare
@@ -0,0 +1 @@
1
+ __version__ = "0.5.4"
@@ -595,10 +595,11 @@ class SynchronizerCLI(cmd.Cmd):
595
595
  parser.add_argument("-r", "--regex", help="Regex pattern to filter rules by name (partial match, non-case sensitive). Default None.", type=str, default=None)
596
596
  parser.add_argument("-c", "--create", help="Create rules that do not exist in the destination property. Default False.", action="store_true", default=False)
597
597
  parser.add_argument("-p", "--published", help="Sync the latest published version of the rules. Default False.", action="store_true", default=False)
598
+ parser.add_argument("-i", "--ignore_disabled", help="Skip disabled source rules. Default False. A bare flag means True.", type=str2bool, nargs="?", const=True, default=False)
598
599
  parser.add_argument("-v", "--verbose", help="Print detailed information about the sync process, including successes. Default False.", action="store_true", default=False)
599
600
  try:
600
601
  args = parser.parse_args(shlex.split(args))
601
- result = self.synchronizer.syncRules(regex=args.regex, forceCreation=args.create, publishedVersion=args.published)
602
+ result = self.synchronizer.syncRules(regex=args.regex, forceCreation=args.create, publishedVersion=args.published, ignoreDisabled=args.ignore_disabled)
602
603
  for ruleName, targetResults in result.items():
603
604
  for prop, message in (targetResults or {}).items():
604
605
  if "does not exist" in message:
@@ -617,10 +618,11 @@ class SynchronizerCLI(cmd.Cmd):
617
618
  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)
618
619
  parser.add_argument("-c", "--create", help="Create data elements that do not exist in the destination property. Default False.", action="store_true", default=False)
619
620
  parser.add_argument("-p", "--published", help="Sync the latest published version of the data elements. Default False.", action="store_true", default=False)
621
+ parser.add_argument("-i", "--ignore_disabled", help="Skip disabled source data elements. Default False. A bare flag means True.", type=str2bool, nargs="?", const=True, default=False)
620
622
  parser.add_argument("-v", "--verbose", help="Print detailed information about the sync process, including successes. Default False.", action="store_true", default=False)
621
623
  try:
622
624
  args = parser.parse_args(shlex.split(args))
623
- result = self.synchronizer.syncDataElements(regex=args.regex, forceCreation=args.create, publishedVersion=args.published)
625
+ result = self.synchronizer.syncDataElements(regex=args.regex, forceCreation=args.create, publishedVersion=args.published, ignoreDisabled=args.ignore_disabled)
624
626
  for deName, targetResults in result.items():
625
627
  for prop, message in (targetResults or {}).items():
626
628
  if "does not exist" in message:
@@ -263,6 +263,7 @@ class Synchronizer:
263
263
  timeout : OPTIONAL : The timeout to be used for the rule component. If not provided, the existing timeout will be used.
264
264
  libraryLinked : OPTIONAL : If you want to take a component ID that are associated to a specific library. Default: False. If set to True, the method will look for the component in the library and take the version linked to the library instead of the latest version in the property. Do not work for Extensions.
265
265
  verbose : OPTIONAL : If set to True, it will print the synchronization status of each property. Default: False.
266
+ ignoreDisabled : OPTIONAL : Skip disabled source rules and data elements, returning an empty dictionary. Uses the selected revision. Default: False.
266
267
  """
267
268
  timeout = kwargs.get('timeout',None)
268
269
  libraryLinked = kwargs.get('libraryLinked',False)
@@ -271,6 +272,10 @@ class Synchronizer:
271
272
  raise ValueError('Require a component Name of a component ID')
272
273
  response = {}
273
274
  cmp_baseDict = self.__prepareBaseComponent__(componentName=componentName,componentId=componentId,publishedVersion=publishedVersion,libraryLinked=libraryLinked)
275
+ if (kwargs.get('ignoreDisabled',False)
276
+ and cmp_baseDict['component']['type'] in ('rules','data_elements')
277
+ and not cmp_baseDict['component']['attributes']['enabled']):
278
+ return response
274
279
  ## handling the data element
275
280
  if cmp_baseDict['component']['type'] == 'data_elements':
276
281
  for target in list(self.targets.keys()):
@@ -288,6 +293,16 @@ class Synchronizer:
288
293
  if len(self.target_configs.get(target,{}).get('inclComponents',[]))==0 or flagAllowList:
289
294
  with self._target_locks[target]:
290
295
  translatedComponent = self.translator.translate(target,data_element=cmp_baseDict['copy'])
296
+ attributes = {
297
+ "name" : translatedComponent['name'],
298
+ "enabled" : translatedComponent["enabled"],
299
+ "delegate_descriptor_id" : translatedComponent["descriptor"],
300
+ "storage_duration" : translatedComponent["storage_duration"],
301
+ "force_lower_case" : translatedComponent["force_lower_case"],
302
+ "clean_text" : translatedComponent["clean_text"],
303
+ "settings" : translatedComponent["settings"],
304
+ "default_value": translatedComponent["default_value"]
305
+ }
291
306
  ## if it does not exist
292
307
  if cmp_baseDict['name'] not in [de.get('attributes',{}).get('name') for de in self.targets[target]['dataElements']]:
293
308
  if forceCreation:
@@ -304,9 +319,9 @@ class Synchronizer:
304
319
  default_value= translatedComponent["default_value"]
305
320
  )
306
321
  if cmp_baseDict['component']['attributes']['enabled'] != comp['attributes']['enabled']:
307
- updateDE = self.targets[target]['api'].updateDataElement(
322
+ comp = self.targets[target]['api'].updateDataElement(
308
323
  dataElement_id=comp['id'],
309
- attr_dict=translatedComponent)
324
+ attr_dict=attributes)
310
325
  self.targets[target]['libraryStack']['dataElements'].append(comp)
311
326
  self.targets[target]['dataElements'].append(comp)
312
327
  response[target] = f'Data element "{cmp_baseDict["name"]}" created in "{target}".'
@@ -316,16 +331,6 @@ class Synchronizer:
316
331
  response[target] = message
317
332
  else:
318
333
  index,old_component = [(index,de) for index,de in enumerate(self.targets[target]['dataElements']) if de.get('attributes',{}).get('name') == cmp_baseDict['name']][0]
319
- attributes = {
320
- "name" : translatedComponent['name'],
321
- "enabled" : translatedComponent["enabled"],
322
- "delegate_descriptor_id" : translatedComponent["descriptor"],
323
- "storage_duration" : translatedComponent["storage_duration"],
324
- "force_lower_case" : translatedComponent["force_lower_case"],
325
- "clean_text" : translatedComponent["clean_text"],
326
- "settings" : translatedComponent["settings"],
327
- "default_value": translatedComponent["default_value"]
328
- }
329
334
  comp = self.targets[target]['api'].updateDataElement(
330
335
  dataElement_id=old_component['id'],
331
336
  attr_dict=attributes,
@@ -513,7 +518,7 @@ class Synchronizer:
513
518
  results[component] = self.syncComponent(componentId=component,publishedVersion=publishedVersion,forceCreation=forceCreation)
514
519
  return results
515
520
 
516
- def syncRules(self,regex:str=None,forceCreation:bool=False,publishedVersion:bool=False)->dict:
521
+ def syncRules(self,regex:str=None,forceCreation:bool=False,publishedVersion:bool=False,ignoreDisabled:bool=False)->dict:
517
522
  """
518
523
  Synchronizing the rules in the base property to the target properties.
519
524
  Returns a dictionary keyed by rule name, with the per-target status (created, updated, or not synced because the rule does not exist and forceCreation is False).
@@ -521,18 +526,19 @@ class Synchronizer:
521
526
  regex : OPTIONAL : If you want to filter the rules to sync based on a regex pattern. Default: None (sync all rules)
522
527
  forceCreation : OPTIONAL : If set to True, it will sync the rules even if they do not exist in the target properties. If set to False, it will only sync the rules that already exist in the target properties. Default: False.
523
528
  publishedVersion : OPTIONAL : If set to True, it will sync the latest published version of the rules. Default: False.
529
+ ignoreDisabled : OPTIONAL : Skip rules whose selected source revision is disabled. Skipped rules have an empty per-target result. Default: False.
524
530
  """
525
531
  base_rule_names = [rule['attributes']['name'] for rule in self.base['rules']]
526
532
  if regex is not None:
527
533
  base_rule_names = [rule for rule in base_rule_names if re.search(regex,rule)]
528
534
  results = {}
529
535
  with ThreadPoolExecutor() as executor:
530
- future_to_name = {executor.submit(self.syncComponent, componentName=rule, forceCreation=forceCreation, publishedVersion=publishedVersion): rule for rule in base_rule_names}
536
+ future_to_name = {executor.submit(self.syncComponent, componentName=rule, forceCreation=forceCreation, publishedVersion=publishedVersion, ignoreDisabled=ignoreDisabled): rule for rule in base_rule_names}
531
537
  for future in future_to_name:
532
538
  results[future_to_name[future]] = future.result()
533
539
  return results
534
540
 
535
- def syncDataElements(self,regex:str=None,forceCreation:bool=False,publishedVersion:bool=False)->dict:
541
+ def syncDataElements(self,regex:str=None,forceCreation:bool=False,publishedVersion:bool=False,ignoreDisabled:bool=False)->dict:
536
542
  """
537
543
  Synchronizing the data elements in the base property to the target properties.
538
544
  Returns a dictionary keyed by data element name, with the per-target status (created, updated, or not synced because the data element does not exist and forceCreation is False).
@@ -540,13 +546,14 @@ class Synchronizer:
540
546
  regex : OPTIONAL : If you want to filter the data elements to sync based on a regex pattern. Default: None (sync all data elements)
541
547
  forceCreation : OPTIONAL : If set to True, it will sync the data elements even if they do not exist in the target properties. If set to False, it will only sync the data elements that already exist in the target properties. Default: False.
542
548
  publishedVersion : OPTIONAL : If set to True, it will sync the latest published version of the data elements. Default: False.
549
+ ignoreDisabled : OPTIONAL : Skip data elements whose selected source revision is disabled. Skipped data elements have an empty per-target result. Default: False.
543
550
  """
544
551
  base_de_names = [de['attributes']['name'] for de in self.base['dataElements']]
545
552
  if regex is not None:
546
553
  base_de_names = [de for de in base_de_names if re.search(regex,de)]
547
554
  results = {}
548
555
  with ThreadPoolExecutor() as executor:
549
- future_to_name = {executor.submit(self.syncComponent, componentName=de, forceCreation=forceCreation, publishedVersion=publishedVersion): de for de in base_de_names}
556
+ future_to_name = {executor.submit(self.syncComponent, componentName=de, forceCreation=forceCreation, publishedVersion=publishedVersion, ignoreDisabled=ignoreDisabled): de for de in base_de_names}
550
557
  for future in future_to_name:
551
558
  results[future_to_name[future]] = future.result()
552
559
  return results
@@ -793,21 +800,23 @@ class Synchronizer:
793
800
  else:
794
801
  index,target_de = [(index,de) for index,de in enumerate(self.targets[target]['dataElements']) if de['attributes']['name'] == cmp_baseDict['name']][0]
795
802
  issue_pub = ""
803
+ differences: list[str] = []
796
804
  if publishedVersion:
797
805
  try:
798
806
  revisions_dataElement = self.targets[target]["api"].getRevisions(target_de)
799
807
  target_de = self.targets[target]["api"].getLatestPublishedVersion(revisions_dataElement)
800
808
  except:
801
809
  issue_pub = " (draft)"
802
- if target_de['attributes']['enabled'] != cmp_baseDict['component']['attributes']['enabled']:
803
- dict_result[target] = "Data Element enabled status is different"+issue_pub
804
- if target_de['attributes']['published'] != cmp_baseDict['component']['attributes']['published']:
805
- dict_result[target] = "Data Element published status is different"+issue_pub
806
- if target_de['attributes']['settings'] == cmp_baseDict['component']['attributes']['settings']:
807
- if not excludeSimilar:
808
- dict_result[target] = "Similar"+issue_pub
809
- else:
810
- dict_result[target] = "Data Element settings are different"+issue_pub
810
+ if target_de['attributes']['enabled'] != cmp_baseDict['component']['attributes']['enabled']:
811
+ differences.append("Data Element enabled status is different")
812
+ if publishedVersion and target_de['attributes']['published'] != cmp_baseDict['component']['attributes']['published']:
813
+ differences.append("Data Element published status is different")
814
+ if target_de['attributes']['settings'] != cmp_baseDict['component']['attributes']['settings']:
815
+ differences.append("Data Element settings are different")
816
+ if differences:
817
+ dict_result[target] = ','.join(differences) + issue_pub
818
+ elif not excludeSimilar:
819
+ dict_result[target] = "Similar"+issue_pub
811
820
  if cmp_baseDict['component']['type'] == 'rules':
812
821
  rcsLink = cmp_baseDict['component'].get('relationships',{}).get('rule_components',{}).get('links',{}).get('related')
813
822
  resResource = self.base['api'].getRessource(rcsLink)
@@ -829,10 +838,10 @@ class Synchronizer:
829
838
  targetRule = self.targets[target]["api"].getLatestPublishedVersion(revisions_targetRule)
830
839
  except:
831
840
  issue_pub = " (not published)"
832
- if targetRule['attributes']['enabled'] != cmp_baseDict['component']['attributes']['enabled']:
833
- componentsDifferences.append("Rule enabled status is different")
834
- if targetRule['attributes']['published'] != cmp_baseDict['component']['attributes']['published']:
835
- componentsDifferences.append("Rule published status is different")
841
+ if targetRule['attributes']['enabled'] != cmp_baseDict['component']['attributes']['enabled']:
842
+ componentsDifferences.append("Rule enabled status is different")
843
+ if publishedVersion and targetRule['attributes']['published'] != cmp_baseDict['component']['attributes']['published']:
844
+ componentsDifferences.append("Rule published status is different")
836
845
  rcsLinkTarget = targetRule.get('relationships',{}).get('rule_components',{}).get('links',{}).get('related')
837
846
  resResource = self.targets[target]['api'].getRessource(rcsLinkTarget)
838
847
  target_rule_components:list = resResource['data']
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: launchpy
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Adobe Launch API wrapper
5
5
  Author-email: Julien Piccini <piccini.julien@gmail.com>
6
6
  License: Apache License 2.0
@@ -30,4 +30,5 @@ launchpy.egg-info/top_level.txt
30
30
  launchpy/cli/__init__.py
31
31
  launchpy/cli/__main__.py
32
32
  tests/test_connector.py
33
- tests/test_proxies.py
33
+ tests/test_proxies.py
34
+ tests/test_synchronizer.py
@@ -0,0 +1,428 @@
1
+ from copy import deepcopy
2
+ import threading
3
+ import unittest
4
+ from unittest.mock import Mock, patch
5
+
6
+ from launchpy.cli.__main__ import SynchronizerCLI
7
+ from launchpy.launchpy import Translator
8
+ from launchpy.property import Property
9
+ from launchpy.synchronizer import Synchronizer
10
+
11
+
12
+ def component(kind: str, identifier: str, enabled: bool) -> dict:
13
+ data = {
14
+ "id": identifier,
15
+ "type": kind,
16
+ "attributes": {
17
+ "name": "Sample component",
18
+ "enabled": enabled,
19
+ "published": False,
20
+ "revision_number": 2,
21
+ },
22
+ "links": {
23
+ "self": "https://example.invalid/" + identifier,
24
+ "origin": "https://example.invalid/" + identifier,
25
+ },
26
+ "relationships": {
27
+ "rule_components": {"links": {"related": "mock:components"}},
28
+ },
29
+ }
30
+ if kind == "data_elements":
31
+ data["attributes"].update(
32
+ settings="{}",
33
+ delegate_descriptor_id="core::dataElements::custom-code",
34
+ storage_duration="pageview",
35
+ force_lower_case=False,
36
+ default_value="",
37
+ clean_text=False,
38
+ )
39
+ data["relationships"]["extension"] = {
40
+ "data": {"id": "EXbase", "type": "extensions"},
41
+ }
42
+ return data
43
+
44
+
45
+ class SynchronizerTests(unittest.TestCase):
46
+ def make_sync(
47
+ self, kind: str, source_enabled: bool = False, target_enabled: bool = True
48
+ ) -> tuple[Synchronizer, dict, dict]:
49
+ prefix = "RL" if kind == "rules" else "DE"
50
+ source = component(kind, prefix + "base", source_enabled)
51
+ target = component(kind, prefix + "target", target_enabled)
52
+ key = "rules" if kind == "rules" else "dataElements"
53
+ source_api = Mock(spec=Property)
54
+ source_api.connector = Mock()
55
+ target_api = Mock(spec=Property)
56
+ source_api.getRessource.return_value = {"data": []}
57
+ target_api.getRessource.return_value = {"data": []}
58
+ source_api.getRevisions.return_value = [source]
59
+ target_api.getRevisions.return_value = [target]
60
+ source_api.getLatestPublishedVersion.return_value = source
61
+ target_api.getLatestPublishedVersion.return_value = target
62
+ target_api.updateDataElement.side_effect = (
63
+ lambda dataElement_id, attr_dict: self.updated(target, attr_dict)
64
+ )
65
+ target_api.updateRule.side_effect = (
66
+ lambda rule_id, attr_dict: self.updated(target, attr_dict)
67
+ )
68
+ sync = Synchronizer.__new__(Synchronizer)
69
+ sync.base = {
70
+ "name": "Base",
71
+ "api": source_api,
72
+ "rules": [],
73
+ "dataElements": [],
74
+ "extensions": [],
75
+ }
76
+ sync.base[key] = [source]
77
+ sync.targets = {
78
+ "Target": {
79
+ "api": target_api,
80
+ "rules": [],
81
+ "dataElements": [],
82
+ "libraryStack": {"rules": [], "dataElements": [], "extensions": []},
83
+ },
84
+ }
85
+ sync.targets["Target"][key] = [target]
86
+ sync.target_configs = {}
87
+ sync._base_lock = threading.Lock()
88
+ sync._target_locks = {"Target": threading.Lock()}
89
+ sync.translator = Translator()
90
+ sync.translator.setBaseExtensions(
91
+ [{"id": "EXbase", "attributes": {"name": "core"}}], "Base"
92
+ )
93
+ sync.translator.extendExtensions(
94
+ [{"id": "EXtarget", "attributes": {"name": "core"}}], "Target"
95
+ )
96
+ sync.translator.setBaseRules(sync.base["rules"], "Base")
97
+ sync.translator.extendRules(sync.targets["Target"]["rules"], "Target")
98
+ return sync, source, target
99
+
100
+ @staticmethod
101
+ def updated(target: dict, attributes: dict) -> dict:
102
+ result = deepcopy(target)
103
+ result["attributes"].update(attributes)
104
+ return result
105
+
106
+ def test_created_data_element_update_uses_api_attributes_and_tracks_response(self):
107
+ for source_enabled in (False, True):
108
+ with self.subTest(source_enabled=source_enabled):
109
+ sync, source, created = self.make_sync(
110
+ "data_elements", source_enabled, not source_enabled
111
+ )
112
+ target = sync.targets["Target"]
113
+ target["dataElements"] = []
114
+ api = target["api"]
115
+ api.createDataElement.return_value = created
116
+ attributes = {
117
+ "name": source["attributes"]["name"],
118
+ "enabled": source_enabled,
119
+ "delegate_descriptor_id": source["attributes"]["delegate_descriptor_id"],
120
+ "storage_duration": source["attributes"]["storage_duration"],
121
+ "force_lower_case": source["attributes"]["force_lower_case"],
122
+ "clean_text": source["attributes"]["clean_text"],
123
+ "settings": source["attributes"]["settings"],
124
+ "default_value": source["attributes"]["default_value"],
125
+ }
126
+ updated = self.updated(created, attributes)
127
+ updated["attributes"]["revision_number"] += 1
128
+ api.updateDataElement.side_effect = None
129
+ api.updateDataElement.return_value = updated
130
+
131
+ result = sync.syncComponent(componentId=source["id"], forceCreation=True)
132
+
133
+ api.updateDataElement.assert_called_once_with(
134
+ dataElement_id=created["id"], attr_dict=attributes
135
+ )
136
+ self.assertEqual(
137
+ result,
138
+ {"Target": 'Data element "Sample component" created in "Target".'},
139
+ )
140
+ self.assertEqual(len(target["dataElements"]), 1)
141
+ self.assertEqual(len(target["libraryStack"]["dataElements"]), 1)
142
+ self.assertIs(target["dataElements"][0], updated)
143
+ self.assertIs(target["libraryStack"]["dataElements"][0], updated)
144
+ self.assertIs(created["attributes"]["enabled"], not source_enabled)
145
+ self.assertEqual(
146
+ sync.checkComponentSync(componentId=source["id"])["Target"], "Similar"
147
+ )
148
+
149
+ def test_created_data_element_needs_no_update_when_enabled_state_matches(self):
150
+ for enabled in (False, True):
151
+ with self.subTest(enabled=enabled):
152
+ sync, source, created = self.make_sync("data_elements", enabled, enabled)
153
+ target = sync.targets["Target"]
154
+ target["dataElements"] = []
155
+ api = target["api"]
156
+ api.createDataElement.return_value = created
157
+
158
+ sync.syncComponent(componentId=source["id"], forceCreation=True)
159
+
160
+ api.updateDataElement.assert_not_called()
161
+ self.assertEqual(target["dataElements"], [created])
162
+ self.assertEqual(target["libraryStack"]["dataElements"], [created])
163
+
164
+ def test_created_data_element_update_failure_is_not_cached_as_success(self):
165
+ sync, source, created = self.make_sync("data_elements")
166
+ target = sync.targets["Target"]
167
+ target["dataElements"] = []
168
+ api = target["api"]
169
+ api.createDataElement.return_value = created
170
+ api.updateDataElement.side_effect = RuntimeError("Data element update failed")
171
+
172
+ with self.assertRaisesRegex(RuntimeError, "Data element update failed"):
173
+ sync.syncComponent(componentId=source["id"], forceCreation=True)
174
+
175
+ self.assertEqual(target["dataElements"], [])
176
+ self.assertEqual(target["libraryStack"]["dataElements"], [])
177
+
178
+ def test_enabled_mismatches_are_reported_in_all_comparison_modes(self):
179
+ for kind, label in (("rules", "Rule"), ("data_elements", "Data Element")):
180
+ for enabled in (False, True):
181
+ for published in (False, True):
182
+ for exclude_similar in (False, True):
183
+ with self.subTest(
184
+ kind=kind, enabled=enabled, published=published,
185
+ exclude_similar=exclude_similar,
186
+ ):
187
+ sync, source, target = self.make_sync(
188
+ kind, enabled, not enabled
189
+ )
190
+ result = sync.checkComponentSync(
191
+ componentId=source["id"],
192
+ publishedVersion=published,
193
+ excludeSimilar=exclude_similar,
194
+ )
195
+ self.assertEqual(
196
+ result["Target"], f"{label} enabled status is different"
197
+ )
198
+
199
+ def test_matching_components_preserve_similar_and_exclude_similar_results(self):
200
+ for kind in ("rules", "data_elements"):
201
+ for published in (False, True):
202
+ for exclude_similar in (False, True):
203
+ with self.subTest(
204
+ kind=kind, published=published, exclude_similar=exclude_similar,
205
+ ):
206
+ sync, source, target = self.make_sync(kind, False, False)
207
+ result = sync.checkComponentSync(
208
+ componentId=source["id"],
209
+ publishedVersion=published,
210
+ excludeSimilar=exclude_similar,
211
+ )
212
+ self.assertEqual(result["Target"], "" if exclude_similar else "Similar")
213
+
214
+ def test_data_element_comparison_retains_all_differences(self):
215
+ sync, source, target = self.make_sync("data_elements")
216
+ source["attributes"]["published"] = True
217
+ target["attributes"]["settings"] = '{"different": true}'
218
+ result = sync.checkComponentSync(componentId=source["id"], publishedVersion=True)
219
+ self.assertEqual(
220
+ result["Target"],
221
+ "Data Element enabled status is different,"
222
+ "Data Element published status is different,"
223
+ "Data Element settings are different",
224
+ )
225
+
226
+ def test_data_element_published_difference_is_not_overwritten_by_similar_settings(self):
227
+ sync, source, target = self.make_sync("data_elements", False, False)
228
+ source["attributes"]["published"] = True
229
+ result = sync.checkComponentSync(componentId=source["id"], publishedVersion=True)
230
+ self.assertEqual(result["Target"], "Data Element published status is different")
231
+
232
+ def test_comparison_reports_enabled_difference_when_target_has_no_published_revision(self):
233
+ for kind, label, suffix in (
234
+ ("rules", "Rule", " (not published)"),
235
+ ("data_elements", "Data Element", " (draft)"),
236
+ ):
237
+ with self.subTest(kind=kind):
238
+ sync, source, target = self.make_sync(kind)
239
+ sync.targets["Target"]["api"].getLatestPublishedVersion.side_effect = (
240
+ IndexError("No published revision")
241
+ )
242
+ result = sync.checkComponentSync(
243
+ componentId=source["id"], publishedVersion=True
244
+ )
245
+ self.assertEqual(
246
+ result["Target"], f"{label} enabled status is different{suffix}"
247
+ )
248
+
249
+ def test_library_dry_run_reports_enabled_differences(self):
250
+ for kind, label in (("rules", "Rule"), ("data_elements", "Data Element")):
251
+ for published in (False, True):
252
+ with self.subTest(kind=kind, published=published):
253
+ sync, source, target = self.make_sync(kind)
254
+ api = sync.base["api"]
255
+ api.getLibraries.return_value = [
256
+ {"id": "LBbase", "attributes": {"name": "Base library"}},
257
+ ]
258
+ api.getRule.return_value = source
259
+ api.getDataElement.return_value = source
260
+ library = Mock()
261
+ library.getFullLibrary.return_value = {
262
+ "rules": [source] if kind == "rules" else [],
263
+ "data_elements": [source] if kind == "data_elements" else [],
264
+ "extensions": [],
265
+ }
266
+ with patch("launchpy.synchronizer.Library", return_value=library):
267
+ result = sync.syncFromLibrary(
268
+ library="LBbase", dryRun=True, publishedVersion=published
269
+ )
270
+ self.assertEqual(
271
+ result[source["attributes"]["name"]]["Target"],
272
+ f"{label} enabled status is different",
273
+ )
274
+ sync.targets["Target"]["api"].updateRule.assert_not_called()
275
+ sync.targets["Target"]["api"].updateDataElement.assert_not_called()
276
+
277
+ def test_bulk_sync_defaults_to_including_disabled_components(self):
278
+ for kind in ("rules", "data_elements"):
279
+ for options in ({}, {"ignoreDisabled": False}):
280
+ with self.subTest(kind=kind, options=options):
281
+ sync, source, target = self.make_sync(kind)
282
+ method = sync.syncRules if kind == "rules" else sync.syncDataElements
283
+ result = method(**options)
284
+ self.assertIn("Target", result[source["attributes"]["name"]])
285
+ update = (
286
+ sync.targets["Target"]["api"].updateRule
287
+ if kind == "rules"
288
+ else sync.targets["Target"]["api"].updateDataElement
289
+ )
290
+ self.assertIs(update.call_args.kwargs["attr_dict"]["enabled"], False)
291
+
292
+ def test_bulk_sync_ignores_disabled_components_without_touching_targets(self):
293
+ for kind in ("rules", "data_elements"):
294
+ for force_creation in (False, True):
295
+ for target_exists in (False, True):
296
+ with self.subTest(
297
+ kind=kind, force_creation=force_creation,
298
+ target_exists=target_exists,
299
+ ):
300
+ sync, source, target = self.make_sync(kind)
301
+ if not target_exists:
302
+ key = "rules" if kind == "rules" else "dataElements"
303
+ sync.targets["Target"][key] = []
304
+ method = sync.syncRules if kind == "rules" else sync.syncDataElements
305
+ result = method(ignoreDisabled=True, forceCreation=force_creation)
306
+ self.assertEqual(result, {source["attributes"]["name"]: {}})
307
+ self.assertEqual(sync.targets["Target"]["api"].mock_calls, [])
308
+ self.assertEqual(sync.base["api"].mock_calls, [])
309
+ self.assertEqual(
310
+ sync.targets["Target"]["libraryStack"],
311
+ {"rules": [], "dataElements": [], "extensions": []},
312
+ )
313
+
314
+ def test_bulk_sync_only_updates_enabled_components_in_a_mixed_batch(self):
315
+ for kind in ("rules", "data_elements"):
316
+ with self.subTest(kind=kind):
317
+ sync, source, target = self.make_sync(kind)
318
+ key = "rules" if kind == "rules" else "dataElements"
319
+ enabled_source = deepcopy(source)
320
+ enabled_source["id"] += "enabled"
321
+ enabled_source["attributes"].update(name="Enabled component", enabled=True)
322
+ enabled_target = deepcopy(target)
323
+ enabled_target["id"] += "enabled"
324
+ enabled_target["attributes"].update(name="Enabled component", enabled=False)
325
+ sync.base[key].append(enabled_source)
326
+ sync.targets["Target"][key].append(enabled_target)
327
+ api = sync.targets["Target"]["api"]
328
+ update = api.updateRule if kind == "rules" else api.updateDataElement
329
+ update.side_effect = lambda **kwargs: self.updated(
330
+ enabled_target, kwargs["attr_dict"]
331
+ )
332
+ method = sync.syncRules if kind == "rules" else sync.syncDataElements
333
+ result = method(ignoreDisabled=True)
334
+ self.assertEqual(result[source["attributes"]["name"]], {})
335
+ self.assertIn("Target", result["Enabled component"])
336
+ update.assert_called_once()
337
+ self.assertIs(update.call_args.kwargs["attr_dict"]["enabled"], True)
338
+
339
+ def test_bulk_sync_still_syncs_enabled_components_to_disabled_targets(self):
340
+ for kind in ("rules", "data_elements"):
341
+ with self.subTest(kind=kind):
342
+ sync, source, target = self.make_sync(kind, True, False)
343
+ method = sync.syncRules if kind == "rules" else sync.syncDataElements
344
+ result = method(ignoreDisabled=True)
345
+ self.assertIn("Target", result[source["attributes"]["name"]])
346
+ key = "rules" if kind == "rules" else "dataElements"
347
+ self.assertIs(
348
+ sync.targets["Target"][key][0]["attributes"]["enabled"], True
349
+ )
350
+
351
+ def test_bulk_sync_filters_the_selected_published_revision(self):
352
+ for kind in ("rules", "data_elements"):
353
+ for published_enabled in (False, True):
354
+ with self.subTest(kind=kind, published_enabled=published_enabled):
355
+ sync, source, target = self.make_sync(
356
+ kind, not published_enabled, not published_enabled
357
+ )
358
+ revision = deepcopy(source)
359
+ revision["id"] += "revision"
360
+ revision["attributes"]["enabled"] = published_enabled
361
+ revision["attributes"]["published"] = True
362
+ sync.base["api"].getLatestPublishedVersion.return_value = revision
363
+ method = sync.syncRules if kind == "rules" else sync.syncDataElements
364
+ result = method(ignoreDisabled=True, publishedVersion=True)
365
+ sync.base["api"].getRevisions.assert_called_once_with(source)
366
+ if published_enabled:
367
+ self.assertIn("Target", result[source["attributes"]["name"]])
368
+ else:
369
+ self.assertEqual(result[source["attributes"]["name"]], {})
370
+ self.assertEqual(sync.targets["Target"]["api"].mock_calls, [])
371
+
372
+ def test_bulk_sync_keeps_regex_filtering(self):
373
+ for kind in ("rules", "data_elements"):
374
+ with self.subTest(kind=kind):
375
+ sync, source, target = self.make_sync(kind, True, False)
376
+ method = sync.syncRules if kind == "rules" else sync.syncDataElements
377
+ self.assertEqual(method(regex="Does not match", ignoreDisabled=True), {})
378
+ self.assertEqual(sync.targets["Target"]["api"].mock_calls, [])
379
+
380
+
381
+ class SynchronizerCLITests(unittest.TestCase):
382
+ def test_ignore_disabled_accepts_optional_boolean_and_bare_flags(self):
383
+ options = (
384
+ ("", False),
385
+ ("-i", True),
386
+ ("--ignore_disabled", True),
387
+ ("-i True", True),
388
+ ("--ignore_disabled true", True),
389
+ ("-i False", False),
390
+ ("--ignore_disabled false", False),
391
+ ("--ignore_disabled=False", False),
392
+ )
393
+ for command, method_name in (
394
+ ("do_sync_rules", "syncRules"),
395
+ ("do_sync_data_elements", "syncDataElements"),
396
+ ):
397
+ for arguments, expected in options:
398
+ with self.subTest(command=command, arguments=arguments):
399
+ sync = Mock(spec=Synchronizer)
400
+ sync.base = {"name": "Base"}
401
+ method = getattr(sync, method_name)
402
+ method.return_value = {}
403
+ shell = SynchronizerCLI(sync)
404
+ with patch("launchpy.cli.__main__.console") as console:
405
+ getattr(shell, command)(arguments + " -r Sample -c -p")
406
+ method.assert_called_once_with(
407
+ regex="Sample", forceCreation=True, publishedVersion=True,
408
+ ignoreDisabled=expected,
409
+ )
410
+ console.print.assert_not_called()
411
+
412
+ def test_ignore_disabled_rejects_invalid_boolean_without_syncing(self):
413
+ for command, method_name in (
414
+ ("do_sync_rules", "syncRules"),
415
+ ("do_sync_data_elements", "syncDataElements"),
416
+ ):
417
+ with self.subTest(command=command):
418
+ sync = Mock(spec=Synchronizer)
419
+ sync.base = {"name": "Base"}
420
+ shell = SynchronizerCLI(sync)
421
+ with patch("sys.stderr") as stderr:
422
+ getattr(shell, command)("--ignore_disabled invalid")
423
+ getattr(sync, method_name).assert_not_called()
424
+ self.assertTrue(stderr.write.called)
425
+
426
+
427
+ if __name__ == "__main__":
428
+ unittest.main()
@@ -1 +0,0 @@
1
- __version__ = "0.5.3"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes