pyxecm 2.0.4__py3-none-any.whl → 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pyxecm might be problematic. Click here for more details.

Files changed (94) hide show
  1. pyxecm/coreshare.py +5 -3
  2. pyxecm/helper/data.py +4 -4
  3. pyxecm/helper/otel_config.py +26 -0
  4. pyxecm/helper/web.py +1 -2
  5. pyxecm/otca.py +1356 -16
  6. pyxecm/otcs.py +2354 -593
  7. pyxecm/otds.py +1 -1
  8. pyxecm/otmm.py +4 -5
  9. pyxecm/py.typed +0 -0
  10. pyxecm-3.0.1.dist-info/METADATA +126 -0
  11. pyxecm-3.0.1.dist-info/RECORD +96 -0
  12. {pyxecm-2.0.4.dist-info → pyxecm-3.0.1.dist-info}/WHEEL +1 -2
  13. pyxecm-3.0.1.dist-info/entry_points.txt +4 -0
  14. {pyxecm/customizer/api → pyxecm_api}/__main__.py +1 -1
  15. pyxecm_api/agents/__init__.py +7 -0
  16. pyxecm_api/agents/app.py +13 -0
  17. pyxecm_api/agents/functions.py +119 -0
  18. pyxecm_api/agents/models.py +10 -0
  19. pyxecm_api/agents/otcm_knowledgegraph/functions.py +85 -0
  20. pyxecm_api/agents/otcm_knowledgegraph/models.py +61 -0
  21. pyxecm_api/agents/otcm_knowledgegraph/router.py +74 -0
  22. pyxecm_api/agents/otcm_user_agent/models.py +20 -0
  23. pyxecm_api/agents/otcm_user_agent/router.py +65 -0
  24. pyxecm_api/agents/otcm_workspace_agent/models.py +40 -0
  25. pyxecm_api/agents/otcm_workspace_agent/router.py +200 -0
  26. pyxecm_api/app.py +221 -0
  27. {pyxecm/customizer/api → pyxecm_api}/auth/functions.py +10 -2
  28. {pyxecm/customizer/api → pyxecm_api}/auth/router.py +4 -3
  29. {pyxecm/customizer/api → pyxecm_api}/common/functions.py +39 -9
  30. {pyxecm/customizer/api → pyxecm_api}/common/metrics.py +1 -2
  31. {pyxecm/customizer/api → pyxecm_api}/common/router.py +7 -8
  32. {pyxecm/customizer/api → pyxecm_api}/settings.py +21 -6
  33. {pyxecm/customizer/api → pyxecm_api}/terminal/router.py +1 -1
  34. {pyxecm/customizer/api → pyxecm_api}/v1_csai/router.py +39 -10
  35. pyxecm_api/v1_csai/statics/bindings/utils.js +189 -0
  36. pyxecm_api/v1_csai/statics/tom-select/tom-select.complete.min.js +356 -0
  37. pyxecm_api/v1_csai/statics/tom-select/tom-select.css +334 -0
  38. pyxecm_api/v1_csai/statics/vis-9.1.2/vis-network.css +1 -0
  39. pyxecm_api/v1_csai/statics/vis-9.1.2/vis-network.min.js +27 -0
  40. pyxecm_api/v1_maintenance/__init__.py +1 -0
  41. {pyxecm/customizer/api → pyxecm_api}/v1_maintenance/functions.py +3 -3
  42. {pyxecm/customizer/api → pyxecm_api}/v1_maintenance/router.py +8 -8
  43. pyxecm_api/v1_otcs/__init__.py +1 -0
  44. {pyxecm/customizer/api → pyxecm_api}/v1_otcs/functions.py +7 -5
  45. {pyxecm/customizer/api → pyxecm_api}/v1_otcs/router.py +8 -7
  46. pyxecm_api/v1_payload/__init__.py +1 -0
  47. {pyxecm/customizer/api → pyxecm_api}/v1_payload/functions.py +10 -7
  48. {pyxecm/customizer/api → pyxecm_api}/v1_payload/router.py +11 -10
  49. {pyxecm/customizer → pyxecm_customizer}/__init__.py +8 -0
  50. {pyxecm/customizer → pyxecm_customizer}/__main__.py +15 -21
  51. {pyxecm/customizer → pyxecm_customizer}/browser_automation.py +414 -103
  52. {pyxecm/customizer → pyxecm_customizer}/customizer.py +178 -116
  53. {pyxecm/customizer → pyxecm_customizer}/guidewire.py +60 -20
  54. {pyxecm/customizer → pyxecm_customizer}/k8s.py +4 -4
  55. pyxecm_customizer/knowledge_graph.py +719 -0
  56. pyxecm_customizer/log.py +35 -0
  57. {pyxecm/customizer → pyxecm_customizer}/m365.py +41 -33
  58. {pyxecm/customizer → pyxecm_customizer}/payload.py +2265 -1933
  59. {pyxecm/customizer/api/common → pyxecm_customizer}/payload_list.py +18 -55
  60. {pyxecm/customizer → pyxecm_customizer}/salesforce.py +1 -1
  61. {pyxecm/customizer → pyxecm_customizer}/sap.py +6 -2
  62. {pyxecm/customizer → pyxecm_customizer}/servicenow.py +2 -4
  63. {pyxecm/customizer → pyxecm_customizer}/settings.py +7 -6
  64. {pyxecm/customizer → pyxecm_customizer}/successfactors.py +40 -28
  65. {pyxecm/customizer → pyxecm_customizer}/translate.py +1 -1
  66. {pyxecm/maintenance_page → pyxecm_maintenance_page}/__main__.py +1 -1
  67. {pyxecm/maintenance_page → pyxecm_maintenance_page}/app.py +14 -8
  68. pyxecm/customizer/api/app.py +0 -157
  69. pyxecm/customizer/log.py +0 -107
  70. pyxecm/customizer/nhc.py +0 -1169
  71. pyxecm/customizer/openapi.py +0 -258
  72. pyxecm/customizer/pht.py +0 -1357
  73. pyxecm-2.0.4.dist-info/METADATA +0 -119
  74. pyxecm-2.0.4.dist-info/RECORD +0 -78
  75. pyxecm-2.0.4.dist-info/licenses/LICENSE +0 -202
  76. pyxecm-2.0.4.dist-info/top_level.txt +0 -1
  77. {pyxecm/customizer/api → pyxecm_api}/__init__.py +0 -0
  78. {pyxecm/customizer/api/auth → pyxecm_api/agents/otcm_knowledgegraph}/__init__.py +0 -0
  79. {pyxecm/customizer/api/common → pyxecm_api/agents/otcm_user_agent}/__init__.py +0 -0
  80. {pyxecm/customizer/api/v1_csai → pyxecm_api/agents/otcm_workspace_agent}/__init__.py +0 -0
  81. {pyxecm/customizer/api/v1_maintenance → pyxecm_api/auth}/__init__.py +0 -0
  82. {pyxecm/customizer/api → pyxecm_api}/auth/models.py +0 -0
  83. {pyxecm/customizer/api/v1_otcs → pyxecm_api/common}/__init__.py +0 -0
  84. {pyxecm/customizer/api → pyxecm_api}/common/models.py +0 -0
  85. {pyxecm/customizer/api → pyxecm_api}/terminal/__init__.py +0 -0
  86. {pyxecm/customizer/api/v1_payload → pyxecm_api/v1_csai}/__init__.py +0 -0
  87. {pyxecm/customizer/api → pyxecm_api}/v1_csai/models.py +0 -0
  88. {pyxecm/customizer/api → pyxecm_api}/v1_maintenance/models.py +0 -0
  89. {pyxecm/customizer/api → pyxecm_api}/v1_payload/models.py +0 -0
  90. {pyxecm/customizer → pyxecm_customizer}/exceptions.py +0 -0
  91. {pyxecm/maintenance_page → pyxecm_maintenance_page}/__init__.py +0 -0
  92. {pyxecm/maintenance_page → pyxecm_maintenance_page}/settings.py +0 -0
  93. {pyxecm/maintenance_page → pyxecm_maintenance_page}/static/favicon.avif +0 -0
  94. {pyxecm/maintenance_page → pyxecm_maintenance_page}/templates/maintenance.html +0 -0
@@ -59,6 +59,7 @@ __email__ = "mdiefenb@opentext.com"
59
59
 
60
60
  import logging
61
61
  import os
62
+ import re
62
63
  import subprocess
63
64
  import tempfile
64
65
  import time
@@ -66,7 +67,7 @@ import traceback
66
67
  from http import HTTPStatus
67
68
  from types import TracebackType
68
69
 
69
- default_logger = logging.getLogger("pyxecm.customizer.browser_automation")
70
+ default_logger = logging.getLogger("pyxecm_customizer.browser_automation")
70
71
 
71
72
  # For backwards compatibility we also want to handle
72
73
  # cases where the playwright modules have not been installed
@@ -151,7 +152,7 @@ class BrowserAutomation:
151
152
  download_directory = os.path.join(
152
153
  tempfile.gettempdir(),
153
154
  "browser_automations",
154
- automation_name,
155
+ self.sanitize_filename(filename=automation_name),
155
156
  "downloads",
156
157
  )
157
158
 
@@ -165,10 +166,11 @@ class BrowserAutomation:
165
166
  self.user_password = user_password
166
167
  self.logged_in = False
167
168
  self.download_directory = download_directory
169
+ self.headless = headless
168
170
 
169
171
  # Screenshot configurations:
170
172
  self.take_screenshots = take_screenshots
171
- self.screenshot_names = automation_name
173
+ self.screenshot_names = self.sanitize_filename(filename=automation_name)
172
174
  self.screenshot_counter = 1
173
175
  self.screenshot_full_page = True
174
176
 
@@ -177,19 +179,16 @@ class BrowserAutomation:
177
179
  self.screenshot_directory = os.path.join(
178
180
  tempfile.gettempdir(),
179
181
  "browser_automations",
180
- automation_name,
182
+ self.screenshot_names,
181
183
  "screenshots",
182
184
  )
183
185
  self.logger.debug("Creating Screenshot directory... -> %s", self.screenshot_directory)
184
186
  if self.take_screenshots and not os.path.exists(self.screenshot_directory):
185
187
  os.makedirs(self.screenshot_directory)
186
188
 
187
- self.logger.debug("Creating Playwright instance...")
188
- self.playwright = sync_playwright().start()
189
-
190
- proxy = None
189
+ self.proxy = None
191
190
  if os.getenv("HTTP_PROXY"):
192
- proxy = {
191
+ self.proxy = {
193
192
  "server": os.getenv("HTTP_PROXY"),
194
193
  }
195
194
  self.logger.info("Using HTTP proxy -> %s", os.getenv("HTTP_PROXY"))
@@ -197,71 +196,110 @@ class BrowserAutomation:
197
196
  browser = browser or os.getenv("BROWSER", "webkit")
198
197
  self.logger.info("Using Browser -> '%s'...", browser)
199
198
 
199
+ if not self.setup_playwright(browser=browser):
200
+ self.logger.error("Failed to initialize Playwright browser automation!")
201
+ return
202
+
203
+ self.logger.info("Creating Browser Context...")
204
+ self.context: BrowserContext = self.browser.new_context(
205
+ accept_downloads=True,
206
+ )
207
+
208
+ self.logger.info("Creating Page...")
209
+ self.page: Page = self.context.new_page()
210
+ self.main_page = self.page
211
+ self.logger.info("Browser Automation initialized.")
212
+
213
+ # end method definition
214
+
215
+ def setup_playwright(self, browser: str) -> bool:
216
+ """Initialize Playwright browser automation.
217
+
218
+ Args:
219
+ browser (str):
220
+ Name of the browser engine.
221
+
222
+ Returns:
223
+ bool:
224
+ True = Success, False = Error.
225
+
226
+ """
227
+
228
+ try:
229
+ self.logger.debug("Creating Playwright instance...")
230
+ self.playwright = sync_playwright().start()
231
+ except Exception:
232
+ self.logger.error("Failed to start Playwright!")
233
+ return False
234
+
200
235
  match browser:
201
236
  case "chromium":
202
237
  try:
203
238
  self.browser: Browser = self.playwright.chromium.launch(
204
- headless=headless, slow_mo=100 if not headless else None, proxy=proxy
239
+ headless=self.headless, slow_mo=100 if not self.headless else None, proxy=self.proxy
205
240
  )
206
241
  except Exception:
207
242
  self.install_browser(browser=browser)
208
243
  self.browser: Browser = self.playwright.chromium.launch(
209
- headless=headless, slow_mo=100 if not headless else None, proxy=proxy
244
+ headless=self.headless, slow_mo=100 if not self.headless else None, proxy=self.proxy
210
245
  )
211
246
 
212
247
  case "chrome":
213
248
  try:
214
249
  self.browser: Browser = self.playwright.chromium.launch(
215
- channel="chrome", headless=headless, slow_mo=100 if not headless else None, proxy=proxy
250
+ channel="chrome",
251
+ headless=self.headless,
252
+ slow_mo=100 if not self.headless else None,
253
+ proxy=self.proxy,
216
254
  )
217
255
  except Exception:
218
256
  self.install_browser(browser=browser)
219
257
  self.browser: Browser = self.playwright.chromium.launch(
220
- channel="chrome", headless=headless, slow_mo=100 if not headless else None, proxy=proxy
258
+ channel="chrome",
259
+ headless=self.headless,
260
+ slow_mo=100 if not self.headless else None,
261
+ proxy=self.proxy,
221
262
  )
222
263
 
223
264
  case "msedge":
224
265
  try:
225
266
  self.browser: Browser = self.playwright.chromium.launch(
226
- channel="msedge", headless=headless, slow_mo=100 if not headless else None, proxy=proxy
267
+ channel="msedge",
268
+ headless=self.headless,
269
+ slow_mo=100 if not self.headless else None,
270
+ proxy=self.proxy,
227
271
  )
228
272
  except Exception:
229
273
  self.install_browser(browser=browser)
230
274
  self.browser: Browser = self.playwright.chromium.launch(
231
- channel="msedge", headless=headless, slow_mo=100 if not headless else None, proxy=proxy
275
+ channel="msedge",
276
+ headless=self.headless,
277
+ slow_mo=100 if not self.headless else None,
278
+ proxy=self.proxy,
232
279
  )
233
280
 
234
281
  case "webkit":
235
282
  try:
236
283
  self.browser: Browser = self.playwright.webkit.launch(
237
- headless=headless, slow_mo=100 if not headless else None, proxy=proxy
284
+ headless=self.headless, slow_mo=100 if not self.headless else None, proxy=self.proxy
238
285
  )
239
286
  except Exception:
240
287
  self.install_browser(browser=browser)
241
288
  self.browser: Browser = self.playwright.webkit.launch(
242
- headless=headless, slow_mo=100 if not headless else None, proxy=proxy
289
+ headless=self.headless, slow_mo=100 if not self.headless else None, proxy=self.proxy
243
290
  )
244
291
 
245
292
  case "firefox":
246
293
  try:
247
294
  self.browser: Browser = self.playwright.firefox.launch(
248
- headless=headless, slow_mo=100 if not headless else None, proxy=proxy
295
+ headless=self.headless, slow_mo=100 if not self.headless else None, proxy=self.proxy
249
296
  )
250
297
  except Exception:
251
298
  self.install_browser(browser=browser)
252
299
  self.browser: Browser = self.playwright.firefox.launch(
253
- headless=headless, slow_mo=100 if not headless else None, proxy=proxy
300
+ headless=self.headless, slow_mo=100 if not self.headless else None, proxy=self.proxy
254
301
  )
255
-
256
- self.logger.info("Creating Browser Context...")
257
- self.context: BrowserContext = self.browser.new_context(
258
- accept_downloads=True,
259
- )
260
-
261
- self.logger.info("Creating Page...")
262
- self.page: Page = self.context.new_page()
263
- self.main_page = self.page
264
- self.logger.info("Browser Automation initialized.")
302
+ return True
265
303
 
266
304
  # end method definition
267
305
 
@@ -287,6 +325,32 @@ class BrowserAutomation:
287
325
 
288
326
  # end method definition
289
327
 
328
+ def sanitize_filename(self, filename: str) -> str:
329
+ """Sanitize a string to be safe for use as a filename.
330
+
331
+ - Replaces spaces with underscores
332
+ - Removes unsafe characters
333
+ - Converts to lowercase
334
+ - Trims length and dots
335
+
336
+ Args:
337
+ filename (str):
338
+ The filename to sanitize.
339
+
340
+ """
341
+
342
+ filename = filename.lower()
343
+ filename = filename.replace(" ", "_")
344
+ filename = re.sub(r'[<>:"/\\|?*\x00-\x1F]', "", filename) # Remove unsafe chars
345
+ filename = re.sub(r"\.+$", "", filename) # Remove trailing dots
346
+ filename = filename.strip()
347
+ if not filename:
348
+ filename = "untitled"
349
+
350
+ return filename
351
+
352
+ # end method definition
353
+
290
354
  def take_screenshot(self) -> bool:
291
355
  """Take a screenshot of the current browser window and save it as PNG file.
292
356
 
@@ -438,17 +502,29 @@ class BrowserAutomation:
438
502
  """
439
503
 
440
504
  if not element:
441
- self.logger.error("Undefined element!")
505
+ self.logger.error("Undefined element! Cannot scroll to it.")
442
506
  return
443
507
 
444
508
  try:
445
509
  element.scroll_into_view_if_needed()
446
510
  except PlaywrightError as e:
447
- self.logger.error("Error while scrolling element into view -> %s", str(e))
511
+ self.logger.warning("Cannot scroll element -> %s into view; error -> %s", str(element), str(e))
448
512
 
449
513
  # end method definition
450
514
 
451
- def get_locator(self, selector: str, selector_type: str, role_type: str | None = None) -> Locator | None:
515
+ def get_locator(
516
+ self,
517
+ selector: str,
518
+ selector_type: str,
519
+ role_type: str | None = None,
520
+ exact_match: bool | None = None,
521
+ iframe: str | None = None,
522
+ regex: bool = False,
523
+ filter_has_text: str | None = None,
524
+ filter_has: Locator | None = None,
525
+ filter_has_not_text: str | None = None,
526
+ filter_has_not: Locator | None = None,
527
+ ) -> Locator | None:
452
528
  """Determine the locator for the given selector type and (optional) role type.
453
529
 
454
530
  Args:
@@ -467,10 +543,27 @@ class BrowserAutomation:
467
543
  role_type (str | None, optional):
468
544
  ARIA role when using selector_type="role", e.g., "button", "textbox".
469
545
  If irrelevant then None should be passed for role_type.
546
+ exact_match (bool | None, optional):
547
+ Controls whether the text or name must match exactly.
548
+ Default is None (not set, i.e. using playwrights default).
549
+ iframe (str | None):
550
+ Is the element in an iFrame? Then provide the name of the iframe with this parameter.
551
+ regex (bool, optional):
552
+ Should the name be interpreted as a regular expression?
553
+ filter_has_text (str | None, optional):
554
+ Applies `locator.filter(has_text=...)` to narrow the selection based on text content.
555
+ filter_has (Locator | None, optional):
556
+ Applies `locator.filter(has=...)` to match elements containing a descendant matching the given Locator.
557
+ filter_has_not_text (str | None, optional):
558
+ Applies `locator.filter(has_not_text=...)` to exclude elements with matching text content.
559
+ filter_has_not (Locator | None, optional):
560
+ Applies `locator.filter(has_not=...)` to exclude elements containing a matching descendant.
470
561
 
471
562
  """
472
563
 
473
564
  try:
565
+ name_or_text = re.compile(selector) if regex else selector
566
+
474
567
  match selector_type:
475
568
  case "id":
476
569
  locator = self.page.locator("#{}".format(selector))
@@ -481,27 +574,50 @@ class BrowserAutomation:
481
574
  case "xpath":
482
575
  locator = self.page.locator("xpath={}".format(selector))
483
576
  case "css":
484
- locator = self.page.locator(selector)
577
+ if iframe is None:
578
+ locator = self.page.locator(selector)
579
+ else:
580
+ locator = self.page.locator("iframe[name='{}']".format(iframe)).content_frame.locator(selector)
485
581
  case "text":
486
- # locator = self.page.get_by_text(selector)
487
- locator = self.page.locator("text={}".format(selector))
582
+ if iframe is None:
583
+ locator = self.page.get_by_text(text=name_or_text)
584
+ else:
585
+ locator = self.page.locator("iframe[name='{}']".format(iframe)).content_frame.get_by_text(
586
+ name_or_text
587
+ )
488
588
  case "title":
489
- locator = self.page.get_by_title(selector)
589
+ locator = self.page.get_by_title(text=name_or_text)
490
590
  case "label":
491
- locator = self.page.get_by_label(selector)
591
+ locator = self.page.get_by_label(text=name_or_text)
492
592
  case "placeholder":
493
- locator = self.page.get_by_placeholder(selector)
593
+ locator = self.page.get_by_placeholder(text=name_or_text)
494
594
  case "alt":
495
- locator = self.page.get_by_alt_text(selector)
595
+ locator = self.page.get_by_alt_text(text=name_or_text)
496
596
  case "role":
497
597
  if not role_type:
498
598
  self.logger.error("Role type must be specified when using find method 'role'!")
499
599
  return None
500
- locator = self.page.get_by_role(role=role_type, name=selector)
600
+ if iframe is None:
601
+ if regex:
602
+ locator = self.page.get_by_role(role=role_type, name=name_or_text)
603
+ else:
604
+ locator = self.page.get_by_role(role=role_type, name=selector, exact=exact_match)
605
+ else:
606
+ content_frame = self.page.locator("iframe[name='{}']".format(iframe)).content_frame
607
+ if regex:
608
+ locator = content_frame.get_by_role(role=role_type, name=name_or_text)
609
+ else:
610
+ locator = content_frame.get_by_role(role=role_type, name=selector, exact=exact_match)
501
611
  case _:
502
612
  self.logger.error("Unsupported selector type -> '%s'", selector_type)
503
613
  return None
504
614
 
615
+ # Apply filter if needed
616
+ if any([filter_has_text, filter_has, filter_has_not_text, filter_has_not]):
617
+ locator = locator.filter(
618
+ has_text=filter_has_text, has=filter_has, has_not_text=filter_has_not_text, has_not=filter_has_not
619
+ )
620
+
505
621
  except PlaywrightError as e:
506
622
  self.logger.error("Failure to determine page locator; error -> %s", str(e))
507
623
  return None
@@ -516,6 +632,11 @@ class BrowserAutomation:
516
632
  selector_type: str = "id",
517
633
  role_type: str | None = None,
518
634
  wait_state: str = "visible",
635
+ exact_match: bool | None = None,
636
+ regex: bool = False,
637
+ iframe: str | None = None,
638
+ repeat_reload: int | None = None,
639
+ repeat_reload_delay: int = 60,
519
640
  show_error: bool = True,
520
641
  ) -> Locator | None:
521
642
  """Find a page element.
@@ -532,51 +653,106 @@ class BrowserAutomation:
532
653
  wait_state (str, optional):
533
654
  Defines if we wait for attached (element is part of DOM) or
534
655
  if we wait for elem to be visible (attached, displayed, and has non-zero size).
656
+ exact_match (bool | None, optional):
657
+ If an exact matching is required. Default is None (not set).
658
+ regex (bool, optional):
659
+ Should the name be interpreted as a regular expression?
660
+ iframe (str | None):
661
+ Is the element in an iFrame? Then provide the name of the iframe with this parameter.
662
+ repeat_reload (int | None):
663
+ For pages that are not dynamically updated and require a reload to show an update
664
+ a number of page reloads can be configured.
665
+ repeat_reload_delay (float | None):
666
+ Number of seconds to wait.
535
667
  show_error (bool, optional):
536
668
  Show an error if not found or not visible.
537
669
 
670
+
538
671
  Returns:
539
672
  Locator:
540
673
  The web element or None in case an error occured.
541
674
 
542
675
  """
543
676
 
544
- failure_message = "Cannot find page element with selector -> '{}' ({}){}".format(
545
- selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
677
+ failure_message = "Cannot find page element with selector -> '{}' ({}){}{}".format(
678
+ selector,
679
+ selector_type,
680
+ " and role type -> '{}'".format(role_type) if role_type else "",
681
+ " in iframe -> '{}'".format(iframe) if iframe else "",
546
682
  )
547
- success_message = "Found page element with selector -> '{}' ('{}'){}".format(
548
- selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
683
+ success_message = "Found page element with selector -> '{}' ('{}'){}{}".format(
684
+ selector,
685
+ selector_type,
686
+ " and role type -> '{}'".format(role_type) if role_type else "",
687
+ " in iframe -> '{}'".format(iframe) if iframe else "",
549
688
  )
550
689
 
551
- # Determine the locator for the element:
552
- locator = self.get_locator(selector=selector, selector_type=selector_type, role_type=role_type)
553
- if not locator:
554
- if show_error:
555
- self.logger.error(failure_message)
690
+ def do_find() -> Locator | None:
691
+ # Determine the locator for the element:
692
+ locator = self.get_locator(
693
+ selector=selector,
694
+ selector_type=selector_type,
695
+ role_type=role_type,
696
+ exact_match=exact_match,
697
+ iframe=iframe,
698
+ regex=regex,
699
+ )
700
+ if not locator:
701
+ if show_error:
702
+ self.logger.error(failure_message)
703
+ else:
704
+ self.logger.warning(failure_message)
705
+ return None
706
+
707
+ # Wait for the element to be visible - don't use logic like
708
+ # locator.count() as this does not wait but fail immideately if elements
709
+ # are not yet loaded:
710
+
711
+ try:
712
+ self.logger.debug(
713
+ "Wait for locator to find element with selector -> '%s' (%s%s%s) and state -> '%s'%s...",
714
+ selector,
715
+ "selector type -> '{}'".format(selector_type),
716
+ ", role type -> '{}'".format(role_type) if role_type else "",
717
+ ", using regular expression" if regex else "",
718
+ wait_state,
719
+ " in iframe -> '{}'".format(iframe) if iframe else "",
720
+ )
721
+ locator = locator.first
722
+ locator.wait_for(state=wait_state)
723
+ except PlaywrightError as pe:
724
+ if show_error and repeat_reload is None:
725
+ self.logger.error("%s (%s)", failure_message, str(pe))
726
+ else:
727
+ self.logger.warning("%s", failure_message)
728
+ return None
556
729
  else:
557
- self.logger.warning(failure_message)
558
- return None
730
+ self.logger.debug(success_message)
559
731
 
560
- # Wait for the element to be visible - don't use logic like
561
- # locator.count() as this does not wait but fail immideately if elements
562
- # are not yet loaded:
563
- try:
564
- self.logger.info(
565
- "Wait for locator to find element with selector -> '%s' (%s%s) and state -> '%s'...",
566
- selector,
567
- "selector type -> '{}'".format(selector_type),
568
- ", role type -> '{}'".format(role_type) if role_type else "",
569
- wait_state,
570
- )
571
- locator.wait_for(state=wait_state)
572
- except PlaywrightError:
573
- if show_error:
574
- self.logger.error("%s (timeout)", failure_message)
732
+ return locator
733
+
734
+ # end def do_find():
735
+
736
+ locator = do_find()
737
+
738
+ # Retry logic for pages that are not updated dynamically:
739
+ if locator is None and repeat_reload is not None:
740
+ for i in range(repeat_reload):
741
+ self.logger.warning(
742
+ "Wait %f seconds before reloading page -> %s to retrieve updates from server...",
743
+ repeat_reload_delay,
744
+ self.page.url,
745
+ )
746
+ time.sleep(repeat_reload_delay)
747
+ self.logger.warning(
748
+ "Reloading page -> %s (retry %d) to retrieve updates from server...", self.page.url, i + 1
749
+ )
750
+ self.page.reload()
751
+ locator = do_find()
752
+ if locator:
753
+ break
575
754
  else:
576
- self.logger.warning("%s (timeout)", failure_message)
577
- return None
578
- else:
579
- self.logger.debug(success_message)
755
+ self.logger.error(failure_message)
580
756
 
581
757
  return locator
582
758
 
@@ -594,6 +770,16 @@ class BrowserAutomation:
594
770
  is_page_close_trigger: bool = False,
595
771
  wait_until: str | None = None,
596
772
  wait_time: float = 0.0,
773
+ exact_match: bool | None = None,
774
+ regex: bool = False,
775
+ hover_only: bool = False,
776
+ iframe: str | None = None,
777
+ force: bool | None = None,
778
+ click_button: str | None = None,
779
+ click_count: int | None = None,
780
+ click_modifiers: list | None = None,
781
+ repeat_reload: int | None = None,
782
+ repeat_reload_delay: float = 60.0,
597
783
  show_error: bool = True,
598
784
  ) -> bool:
599
785
  """Find a page element and click it.
@@ -630,6 +816,32 @@ class BrowserAutomation:
630
816
  but subresources may still load).
631
817
  wait_time (float):
632
818
  Time in seconds to wait for elements to appear.
819
+ exact_match (bool | None, optional):
820
+ If an exact matching is required. Default is None (not set).
821
+ regex (bool, optional):
822
+ Should the name be interpreted as a regular expression?
823
+ hover_only (bool, optional):
824
+ Should we only hover over the element and not click it? Helpful for
825
+ menus that are opening on hovering.
826
+ iframe (str | None, optional):
827
+ Is the element in an iFrame? Then provide the name of the iframe with this parameter.
828
+ force (bool | None, optional):
829
+ If sure the element is interactable and visible (even partly), you can bypass visibility checks
830
+ by setting this option to True. Default is None (undefined, i.e. using the playwright default which is False)
831
+ click_button (Literal['left', 'middle', 'right'] | None, optional):
832
+ Which mouse button to use to do the click. The default is "left". This will be used by playwright if None
833
+ is passed.
834
+ click_count (int | None, optional):
835
+ Number of clicks. E.g. 2 for a "double-click".
836
+ click_modifiers (list | None, optional):
837
+ Key pressed together with the mouse click.
838
+ Possible values:'Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'.
839
+ Default is None = no key pressed.
840
+ repeat_reload (int | None):
841
+ For pages that are not dynamically updated and require a reload to show an update
842
+ a number of page reloads can be configured.
843
+ repeat_reload_delay (float | None):
844
+ Number of seconds to wait.
633
845
  show_error (bool, optional):
634
846
  Show an error if the element is not found or not clickable.
635
847
 
@@ -662,7 +874,15 @@ class BrowserAutomation:
662
874
  return False
663
875
 
664
876
  elem = self.find_elem(
665
- selector=selector, selector_type=selector_type, role_type=role_type, show_error=show_error
877
+ selector=selector,
878
+ selector_type=selector_type,
879
+ role_type=role_type,
880
+ exact_match=exact_match,
881
+ regex=regex,
882
+ iframe=iframe,
883
+ repeat_reload=repeat_reload,
884
+ repeat_reload_delay=repeat_reload_delay,
885
+ show_error=show_error,
666
886
  )
667
887
  if not elem:
668
888
  return not show_error
@@ -680,7 +900,7 @@ class BrowserAutomation:
680
900
  else:
681
901
  # Will this click trigger a naviagation?
682
902
  if is_navigation_trigger:
683
- self.logger.info(
903
+ self.logger.debug(
684
904
  "Clicking on navigation-triggering element -> '%s' (%s%s) and wait until -> '%s'...",
685
905
  selector,
686
906
  "selector type -> '{}'".format(selector_type),
@@ -688,29 +908,38 @@ class BrowserAutomation:
688
908
  wait_until,
689
909
  )
690
910
  with self.page.expect_navigation(wait_until=wait_until):
691
- elem.click()
911
+ elem.click(force=force, button=click_button, click_count=click_count, modifiers=click_modifiers)
692
912
  # Will this click trigger a a new popup window?
693
913
  elif is_popup_trigger:
694
914
  with self.page.expect_popup() as popup_info:
695
- elem.click()
915
+ elem.click(force=force, button=click_button, click_count=click_count, modifiers=click_modifiers)
696
916
  if not popup_info or not popup_info.value:
697
917
  self.logger.info("Popup window did not open as expected!")
698
918
  success = False
699
919
  else:
700
920
  self.page = popup_info.value
701
921
  self.logger.info("Move browser automation to popup window -> %s...", self.page.url)
922
+ elif hover_only:
923
+ self.logger.debug(
924
+ "Hovering over element -> '%s' (%s%s)...",
925
+ selector,
926
+ "selector type -> '{}'".format(selector_type),
927
+ ", role type -> '{}'".format(role_type) if role_type else "",
928
+ )
929
+ elem.hover()
702
930
  else:
703
- self.logger.info(
931
+ self.logger.debug(
704
932
  "Clicking on non-navigating element -> '%s' (%s%s)...",
705
933
  selector,
706
934
  "selector type -> '{}'".format(selector_type),
707
935
  ", role type -> '{}'".format(role_type) if role_type else "",
708
936
  )
709
- elem.click()
937
+ elem.click(force=force, button=click_button, click_count=click_count, modifiers=click_modifiers)
710
938
  time.sleep(1)
711
939
  if success:
712
940
  self.logger.debug(
713
- "Successfully clicked element -> '%s' (%s%s)",
941
+ "Successfully %s element -> '%s' (%s%s)",
942
+ "clicked" if not hover_only else "hovered over",
714
943
  selector,
715
944
  "selector type -> '{}'".format(selector_type),
716
945
  ", role type -> '{}'".format(role_type) if role_type else "",
@@ -749,6 +978,11 @@ class BrowserAutomation:
749
978
  selector_type: str = "id",
750
979
  role_type: str | None = None,
751
980
  is_sensitive: bool = False,
981
+ press_enter: bool = False,
982
+ exact_match: bool | None = None,
983
+ regex: bool = False,
984
+ iframe: str | None = None,
985
+ typing: bool = False,
752
986
  show_error: bool = True,
753
987
  ) -> bool:
754
988
  """Find an page element and fill it with a new text.
@@ -766,6 +1000,17 @@ class BrowserAutomation:
766
1000
  If irrelevant then None should be passed for role_type.
767
1001
  is_sensitive (bool, optional):
768
1002
  True for suppressing sensitive information in logging.
1003
+ press_enter (bool, optional):
1004
+ Whether or not to press "Enter" after entering
1005
+ exact_match (bool | None, optional):
1006
+ If an exact matching is required. Default is None (not set).
1007
+ regex (bool, optional):
1008
+ Should the name be interpreted as a regular expression?
1009
+ iframe (str | None):
1010
+ Is the element in an iFrame? Then provide the name of the iframe with this parameter.
1011
+ typing (bool, optional):
1012
+ Not just set the value of the elem but simulate real typing.
1013
+ This is required for pages with fields that do react in a "typeahead" manner.
769
1014
  show_error (bool, optional):
770
1015
  Show an error if the element is not found or not clickable.
771
1016
 
@@ -777,9 +1022,17 @@ class BrowserAutomation:
777
1022
 
778
1023
  success = False # Final return value
779
1024
 
780
- elem = self.find_elem(selector=selector, selector_type=selector_type, role_type=role_type, show_error=True)
1025
+ elem = self.find_elem(
1026
+ selector=selector,
1027
+ selector_type=selector_type,
1028
+ role_type=role_type,
1029
+ exact_match=exact_match,
1030
+ regex=regex,
1031
+ iframe=iframe,
1032
+ show_error=True,
1033
+ )
781
1034
  if not elem:
782
- return False
1035
+ return not show_error
783
1036
 
784
1037
  is_enabled = elem.is_enabled()
785
1038
  if not is_enabled:
@@ -837,7 +1090,12 @@ class BrowserAutomation:
837
1090
 
838
1091
  success = retry < 5 # True is less than 5 retries were needed
839
1092
  else:
840
- elem.fill(value)
1093
+ if typing:
1094
+ elem.type(value, delay=50)
1095
+ else:
1096
+ elem.fill(value)
1097
+ if press_enter:
1098
+ self.page.keyboard.press("Enter")
841
1099
  success = True
842
1100
  except PlaywrightError as e:
843
1101
  message = "Cannot set page element selected by -> '{}' ({}) to value -> '{}'; error -> {}".format(
@@ -861,6 +1119,9 @@ class BrowserAutomation:
861
1119
  selector: str,
862
1120
  selector_type: str = "id",
863
1121
  role_type: str | None = None,
1122
+ exact_match: bool | None = None,
1123
+ regex: bool = False,
1124
+ iframe: str | None = None,
864
1125
  download_time: int = 30,
865
1126
  ) -> str | None:
866
1127
  """Click a page element to initiate a download.
@@ -874,6 +1135,12 @@ class BrowserAutomation:
874
1135
  role_type (str | None, optional):
875
1136
  ARIA role when using selector_type="role", e.g., "button", "textbox".
876
1137
  If irrelevant then None should be passed for role_type.
1138
+ exact_match (bool | None, optional):
1139
+ If an exact matching is required. Default is None (not set).
1140
+ regex (bool, optional):
1141
+ Should the name be interpreted as a regular expression?
1142
+ iframe (str | None):
1143
+ Is the element in an iFrame? Then provide the name of the iframe with this parameter.
877
1144
  download_time (int, optional):
878
1145
  Time in seconds to wait for the download to complete.
879
1146
 
@@ -885,7 +1152,14 @@ class BrowserAutomation:
885
1152
 
886
1153
  try:
887
1154
  with self.page.expect_download(timeout=download_time * 1000) as download_info:
888
- clicked = self.find_elem_and_click(selector=selector, selector_type=selector_type, role_type=role_type)
1155
+ clicked = self.find_elem_and_click(
1156
+ selector=selector,
1157
+ selector_type=selector_type,
1158
+ role_type=role_type,
1159
+ exact_match=exact_match,
1160
+ regex=regex,
1161
+ iframe=iframe,
1162
+ )
889
1163
  if not clicked:
890
1164
  self.logger.error("Element not found to initiate download.")
891
1165
  return None
@@ -910,8 +1184,10 @@ class BrowserAutomation:
910
1184
  selector_type: str = "id",
911
1185
  role_type: str | None = None,
912
1186
  value: str | None = None,
1187
+ exact_match: bool | None = None,
913
1188
  attribute: str | None = None,
914
1189
  substring: bool = True,
1190
+ iframe: str | None = None,
915
1191
  min_count: int = 1,
916
1192
  wait_time: float = 0.0,
917
1193
  wait_state: str = "visible",
@@ -937,10 +1213,14 @@ class BrowserAutomation:
937
1213
  If irrelevant then None should be passed for role_type.
938
1214
  value (str, optional):
939
1215
  Value to match in attribute or element content.
1216
+ exact_match (bool | None, optional):
1217
+ If an exact matching is required. Default is None (not set).
940
1218
  attribute (str, optional):
941
1219
  Attribute name to inspect. If None, uses element's text.
942
1220
  substring (bool):
943
1221
  If True, allow partial match.
1222
+ iframe (str | None):
1223
+ Is the element in an iFrame? Then provide the name of the iframe with this parameter.
944
1224
  min_count (int):
945
1225
  Minimum number of required matches (# elements on page).
946
1226
  wait_time (float):
@@ -960,12 +1240,21 @@ class BrowserAutomation:
960
1240
 
961
1241
  """
962
1242
 
963
- failure_message = "No matching page element found with selector -> '{}' ({}){}".format(
964
- selector, selector_type, " and role type -> '{}'".format(role_type) if role_type else ""
1243
+ failure_message = "No matching page element found with selector -> '{}' ({}){}{}".format(
1244
+ selector,
1245
+ selector_type,
1246
+ " and role type -> '{}'".format(role_type) if role_type else "",
1247
+ " in iframe -> '{}'".format(iframe) if iframe else "",
965
1248
  )
966
1249
 
967
1250
  # Determine the locator for the elements:
968
- locator = self.get_locator(selector=selector, selector_type=selector_type, role_type=role_type)
1251
+ locator = self.get_locator(
1252
+ selector=selector,
1253
+ selector_type=selector_type,
1254
+ role_type=role_type,
1255
+ exact_match=exact_match,
1256
+ iframe=iframe,
1257
+ )
969
1258
  if not locator:
970
1259
  if show_error:
971
1260
  self.logger.error(
@@ -974,7 +1263,7 @@ class BrowserAutomation:
974
1263
  return (None, 0)
975
1264
 
976
1265
  self.logger.info(
977
- "Check if at least %d element%s found by selector -> %s (%s%s)%s%s...",
1266
+ "Check if at least %d element%s found by selector -> %s (%s%s)%s%s%s...",
978
1267
  min_count,
979
1268
  "s are" if min_count > 1 else " is",
980
1269
  selector,
@@ -982,24 +1271,22 @@ class BrowserAutomation:
982
1271
  ", role type -> {}".format(role_type) if role_type else "",
983
1272
  " with value -> '{}'".format(value) if value else "",
984
1273
  " in attribute -> '{}'".format(attribute) if attribute and value else "",
1274
+ " in iframe -> '{}'".format(iframe) if iframe else "",
985
1275
  )
986
1276
 
987
1277
  # Wait for the element to be visible - don't immediately use logic like
988
1278
  # locator.count() as this does not wait but then fail immideately
989
1279
  try:
990
1280
  self.logger.info(
991
- "Wait for locator to find first matching element with selector -> '%s' (%s%s) and state -> '%s'...",
1281
+ "Wait for locator to find first matching element with selector -> '%s' (%s%s) and state -> '%s'%s...",
992
1282
  selector,
993
1283
  "selector type -> '{}'".format(selector_type),
994
1284
  ", role type -> {}".format(role_type) if role_type else "",
995
1285
  wait_state,
1286
+ " in iframe -> '{}'".format(iframe) if iframe else "",
996
1287
  )
997
1288
  self.logger.info("Locator count before waiting: %d", locator.count())
998
1289
 
999
- # for i in range(locator.count()):
1000
- # elem = locator.nth(i)
1001
- # self.logger.info("Element #%d visible: %s", i, elem.is_visible())
1002
-
1003
1290
  # IMPORTANT: We wait for the FIRST element. otherwise we get errors like
1004
1291
  # 'Locator.wait_for: Error: strict mode violation'.
1005
1292
  # IMPORTANT: if the first match does not comply to the
@@ -1021,12 +1308,14 @@ class BrowserAutomation:
1021
1308
  self.logger.info("Wait additional %d milliseconds before checking...", wait_time * 1000)
1022
1309
  self.page.wait_for_timeout(wait_time * 1000)
1023
1310
 
1024
- matching_elems = []
1025
-
1026
1311
  count = locator.count()
1027
1312
  if count == 0:
1028
1313
  if show_error:
1029
1314
  self.logger.error("No elements found using selector -> '%s' ('%s')", selector, selector_type)
1315
+
1316
+ if self.take_screenshots:
1317
+ self.take_screenshot()
1318
+
1030
1319
  return (None, 0)
1031
1320
 
1032
1321
  self.logger.info(
@@ -1045,13 +1334,18 @@ class BrowserAutomation:
1045
1334
  value,
1046
1335
  )
1047
1336
 
1337
+ matching_elems = []
1338
+
1339
+ # Iterate over all elements found by the locator and checkif
1340
+ # they comply with the additional value conditions (if provided).
1341
+ # We collect all matching elements in a list:
1048
1342
  for i in range(count):
1049
1343
  elem = locator.nth(i)
1050
1344
  if not elem:
1051
1345
  continue
1052
1346
 
1053
1347
  if value is None:
1054
- # No filtering, accept all elements
1348
+ # If value is None we do no filtering, accept all elements:
1055
1349
  matching_elems.append(elem)
1056
1350
  continue
1057
1351
 
@@ -1059,15 +1353,15 @@ class BrowserAutomation:
1059
1353
  attr_value = elem.get_attribute(attribute) if attribute else elem.text_content()
1060
1354
 
1061
1355
  if not attr_value:
1356
+ # Nothing to compare with - continue:
1062
1357
  continue
1063
1358
 
1359
+ # If substring is True we check with "in" otherwise we use the eual operator (==):
1064
1360
  if (substring and value in attr_value) or (not substring and value == attr_value):
1065
1361
  matching_elems.append(elem)
1066
1362
 
1067
1363
  matching_elements_count = len(matching_elems)
1068
1364
 
1069
- success = True
1070
-
1071
1365
  if matching_elements_count < min_count:
1072
1366
  success = False
1073
1367
  if show_error:
@@ -1077,7 +1371,20 @@ class BrowserAutomation:
1077
1371
  "s" if matching_elements_count > 1 else "",
1078
1372
  min_count,
1079
1373
  )
1080
- self.logger.info("Found %d matching elements.", matching_elements_count)
1374
+ else:
1375
+ success = True
1376
+ self.logger.info(
1377
+ "Found %d matching elements.%s",
1378
+ matching_elements_count,
1379
+ " This is {} the minimum {} element{} probed for.".format(
1380
+ "exactly" if matching_elements_count == min_count else "more than",
1381
+ min_count,
1382
+ "s" if min_count > 1 else "",
1383
+ ),
1384
+ )
1385
+
1386
+ if self.take_screenshots:
1387
+ self.take_screenshot()
1081
1388
 
1082
1389
  return (success, matching_elements_count)
1083
1390
 
@@ -1119,7 +1426,8 @@ class BrowserAutomation:
1119
1426
  Default is "id".
1120
1427
 
1121
1428
  Returns:
1122
- bool: True = success, False = error.
1429
+ bool:
1430
+ True = success, False = error.
1123
1431
 
1124
1432
  """
1125
1433
 
@@ -1147,7 +1455,7 @@ class BrowserAutomation:
1147
1455
  )
1148
1456
  return False
1149
1457
 
1150
- self.logger.info("Wait for -> '%s' to assure login is completed and target page is loaded.", wait_until)
1458
+ self.logger.debug("Wait for -> '%s' to assure login is completed and target page is loaded.", wait_until)
1151
1459
  self.page.wait_for_load_state(wait_until)
1152
1460
 
1153
1461
  title = self.get_title()
@@ -1208,7 +1516,7 @@ class BrowserAutomation:
1208
1516
  # end method definition
1209
1517
 
1210
1518
  def __enter__(self) -> object:
1211
- """Enable use with 'with' statement."""
1519
+ """Enable use with 'with' statement (context manager block)."""
1212
1520
 
1213
1521
  return self
1214
1522
 
@@ -1217,15 +1525,18 @@ class BrowserAutomation:
1217
1525
  def __exit__(
1218
1526
  self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback_obj: TracebackType | None
1219
1527
  ) -> None:
1220
- """Handle cleanup when exiting a context manager block.
1528
+ """Handle cleanup when exiting a context manager block ('with' statement).
1221
1529
 
1222
1530
  Ensures all browser-related resources are released. If an unhandled exception
1223
1531
  occurs within the context block, it will be logged before cleanup.
1224
1532
 
1225
1533
  Args:
1226
- exc_type (type[BaseException] | None): The class of the raised exception, if any.
1227
- exc_value (BaseException | None): The exception instance raised, if any.
1228
- traceback_obj (TracebackType | None): The traceback object associated with the exception, if any.
1534
+ exc_type (type[BaseException] | None):
1535
+ The class of the raised exception, if any.
1536
+ exc_value (BaseException | None):
1537
+ The exception instance raised, if any.
1538
+ traceback_obj (TracebackType | None):
1539
+ The traceback object associated with the exception, if any.
1229
1540
 
1230
1541
  """
1231
1542