Skip to content

Commit 4b4db9d

Browse files
committed
Release of version 1.1.0
1 parent 6cad209 commit 4b4db9d

File tree

18 files changed

+5053
-752
lines changed

18 files changed

+5053
-752
lines changed

docs/payload-syntax.md

Lines changed: 1297 additions & 0 deletions
Large diffs are not rendered by default.

docs/pyxecm/browser.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: pyxecm.customizer.browser_automation

mkdocs.yml

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ theme:
66
name: "material"
77
icon:
88
repo: fontawesome/brands/gitlab
9+
palette:
10+
primary: black
11+
features:
12+
- content.action.edit
13+
- content.code.copy
14+
- content.tabs.link
915

1016
plugins:
1117
- search
@@ -20,18 +26,35 @@ edit_uri: edit/main/docs/
2026
markdown_extensions:
2127
- pymdownx.snippets:
2228
check_paths: true
23-
29+
- pymdownx.highlight:
30+
anchor_linenums: true
31+
line_spans: __span
32+
pygments_lang_class: true
33+
- pymdownx.inlinehilite
34+
- pymdownx.snippets
35+
- pymdownx.superfences
36+
- pymdownx.critic
37+
- pymdownx.caret
38+
- pymdownx.keys
39+
- pymdownx.mark
40+
- pymdownx.tilde
41+
- pymdownx.superfences
42+
- pymdownx.tabbed:
43+
alternate_style: true
44+
2445
nav:
2546
- Index: index.md
47+
- Payload Syntax: payload-syntax.md
2648
- xECM Classes:
2749
- OTCS: pyxecm/otcs.md
2850
- OTDS: pyxecm/otds.md
2951
- OTAC: pyxecm/otac.md
3052
- OTIV: pyxecm/otiv.md
3153
- OTPD: pyxecm/otpd.md
3254
- Customizer Classes:
33-
- Customizer: pyxecm/customizer.md
3455
- Payload: pyxecm/payload.md
56+
- Browser Automation: pyxecm/browser.md
57+
- Customizer: pyxecm/customizer.md
3558
- K8s: pyxecm/k8s.md
3659
- M365: pyxecm/m365.md
3760
- Translate: pyxecm/translate.md

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ keywords = [
4343
packages = ['pyxecm', 'pyxecm.customizer', 'pyxecm.helper']
4444

4545
[project.urls]
46-
"Homepage" = "https://ecm.glpages.otxlab.net/pyxecm/"
46+
"Homepage" = "https://github.com/opentext/pyxecm"
4747

4848
[project.optional-dependencies]
4949
customizer = ['python-hcl2', 'lxml', 'pyrfc']
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
"""
2+
browser_automation Module to implement a class to automate configuration
3+
via a browser interface. These are typically used as fallback options if
4+
no REST API or LLConfig can be used.
5+
6+
Class: BrowserAutomation
7+
Methods:
8+
9+
__init__ : class initializer
10+
set_chrome_options: Sets chrome options for Selenium. Chrome options for headless browser is enabled
11+
run_otcs_login: Login to OTCS via the browser
12+
run_configure_vertex_datasource: Run the configuration of the Aviator Vertex datasource
13+
end_session: End the browser session
14+
"""
15+
16+
import os
17+
import logging
18+
19+
logger = logging.getLogger("pyxecm.customizer.browser_automation")
20+
21+
# For backwards compatibility we also want to handle
22+
# cases where the selenium and chromedriver_autoinstaller
23+
# modules have not been installed in the customizer container:
24+
try:
25+
from selenium.webdriver.chrome.options import Options
26+
from selenium import webdriver
27+
from selenium.webdriver.common.by import By
28+
from selenium.common.exceptions import (
29+
WebDriverException,
30+
NoSuchElementException,
31+
ElementNotInteractableException,
32+
ElementClickInterceptedException,
33+
)
34+
except ModuleNotFoundError as module_exception:
35+
logger.warning("Module selenium is not installed")
36+
37+
class Options:
38+
"""Dummy class to avoid errors if selenium module cannot be imported"""
39+
40+
class By:
41+
"""Dummy class to avoid errors if selenium module cannot be imported"""
42+
43+
ID: str = ""
44+
45+
46+
try:
47+
import chromedriver_autoinstaller
48+
except ModuleNotFoundError as module_exception:
49+
logger.warning("Module chromedriver_autoinstaller is not installed")
50+
51+
52+
class BrowserAutomation:
53+
"""Class to automate settings via a browser interface."""
54+
55+
def __init__(
56+
self,
57+
base_url: str,
58+
user_name: str,
59+
user_password: str,
60+
download_directory: str = "/tmp",
61+
) -> None:
62+
self.base_url = base_url
63+
self.user_name = user_name
64+
self.user_password = user_password
65+
self.logged_in = False
66+
self.download_directory = download_directory
67+
68+
chromedriver_autoinstaller.install()
69+
self.browser = webdriver.Chrome(options=self.set_chrome_options())
70+
71+
# end method definition
72+
73+
def __del__(self):
74+
if self.browser:
75+
self.browser.close()
76+
del self.browser
77+
self.browser = None
78+
79+
def set_chrome_options(self) -> Options:
80+
"""Sets chrome options for Selenium.
81+
Chrome options for headless browser is enabled.
82+
83+
Returns:
84+
Options: Options to call the browser with
85+
"""
86+
87+
chrome_options = Options()
88+
chrome_options.add_argument("--headless")
89+
chrome_options.add_argument("--no-sandbox")
90+
chrome_options.add_argument("--disable-dev-shm-usage")
91+
chrome_prefs = {}
92+
chrome_options.experimental_options["prefs"] = chrome_prefs
93+
chrome_prefs["profile.default_content_settings"] = {"images": 2}
94+
95+
chrome_options.add_experimental_option(
96+
"prefs", {"download.default_directory": self.download_directory}
97+
)
98+
99+
return chrome_options
100+
101+
# end method definition
102+
103+
def get_page(self, url: str = "") -> bool:
104+
"""Load a page into the browser based on a given URL.
105+
Required authorization need
106+
107+
Args:
108+
url (str): URL to load. If empty just the base URL will be used
109+
Returns:
110+
bool: True if successful, False otherwise
111+
"""
112+
113+
page_url = self.base_url + url
114+
115+
try:
116+
logger.info("Load page -> %s", page_url)
117+
self.browser.get(page_url)
118+
except WebDriverException as exception:
119+
logger.error("Cannot load page -> %s; error -> %s", page_url, exception)
120+
return False
121+
122+
logger.info("Page title get page -> %s", self.browser.title)
123+
124+
return True
125+
126+
# end method definition
127+
128+
def find_elem_and_click(self, find_elem: str, find_method: str = By.ID) -> bool:
129+
"""Find an page element and click it.
130+
131+
Args:
132+
find_elem (str): name of the page element
133+
find_method (str): either By.ID, By.NAME, By.CLASS_NAME, BY.XPATH
134+
Returns:
135+
bool: True if successful, False otherwise
136+
"""
137+
138+
# We don't want to expose class "By" outside this module,
139+
# so we map the string values to the By class values:
140+
if find_method == "id":
141+
find_method = By.ID
142+
elif find_method == "name":
143+
find_method = By.NAME
144+
elif find_method == "class_name":
145+
find_method = By.CLASS_NAME
146+
elif find_method == "xpath":
147+
find_method = By.XPATH
148+
else:
149+
logger.error("Unsupported find method!")
150+
return False
151+
152+
try:
153+
elem = self.browser.find_element(by=find_method, value=find_elem)
154+
except NoSuchElementException as exception:
155+
logger.error(
156+
"Cannot find page element -> %s by -> %s; error -> %s",
157+
find_elem,
158+
find_method,
159+
exception,
160+
)
161+
return False
162+
163+
try:
164+
elem.click()
165+
except ElementClickInterceptedException as exception:
166+
logger.error(
167+
"Cannot click page element -> %s; error -> %s", find_elem, exception
168+
)
169+
return False
170+
171+
return True
172+
173+
# end method definition
174+
175+
def find_elem_and_set(
176+
self, find_elem: str, elem_value: str, find_method: str = By.ID
177+
) -> bool:
178+
"""Find an page element and fill it with a new text.
179+
180+
Args:
181+
find_elem (str): name of the page element
182+
elem_value (str): new text string for the page element
183+
find_method (str): either By.ID, By.NAME, By.CLASS_NAME, or By.XPATH
184+
Returns:
185+
bool: True if successful, False otherwise
186+
"""
187+
188+
# We don't want to expose class "By" outside this module,
189+
# so we map the string values to the By class values:
190+
if find_method == "id":
191+
find_method = By.ID
192+
elif find_method == "name":
193+
find_method = By.NAME
194+
elif find_method == "class_name":
195+
find_method = By.CLASS_NAME
196+
elif find_method == "xpath":
197+
find_method = By.XPATH
198+
else:
199+
logger.error("Unsupported find method!")
200+
return False
201+
202+
logger.info("Try to find element -> %s by -> %s...", find_elem, find_method)
203+
204+
try:
205+
elem = self.browser.find_element(find_method, find_elem)
206+
except NoSuchElementException as exception:
207+
logger.error(
208+
"Cannot find page element -> %s by -> %s; error -> %s",
209+
find_elem,
210+
find_method,
211+
exception,
212+
)
213+
return False
214+
215+
logger.info("Set element -> %s to value -> %s...", find_elem, elem_value)
216+
217+
try:
218+
elem.clear() # clear existing text in the input field
219+
elem.send_keys(elem_value) # write new text into the field
220+
except ElementNotInteractableException as exception:
221+
logger.error(
222+
"Cannot set page element -> %s to value -> %s; error -> %s",
223+
find_elem,
224+
elem_value,
225+
exception,
226+
)
227+
return False
228+
229+
return True
230+
231+
# end method definition
232+
233+
def find_element_and_download(
234+
self, find_elem: str, find_method: str = By.ID, download_time: int = 30
235+
) -> str | None:
236+
"""Clicks a page element to initiate a download
237+
238+
Args:
239+
find_elem (str): page element to click for download
240+
find_method (str, optional): method to find the element. Defaults to By.ID.
241+
download_time (int, optional): time in seconds to wait for the download to complete
242+
Returns:
243+
str | None: filename of the download
244+
"""
245+
246+
# Record the list of files in the download directory before the download
247+
initial_files = set(os.listdir(self.download_directory))
248+
249+
if not self.find_elem_and_click(
250+
find_elem=find_elem,
251+
find_method=find_method,
252+
):
253+
return None
254+
255+
# Wait for the download to complete
256+
# time.sleep(download_time)
257+
258+
self.browser.implicitly_wait(download_time)
259+
260+
# Record the list of files in the download directory after the download
261+
current_files = set(os.listdir(self.download_directory))
262+
263+
# Determine the name of the downloaded file
264+
new_file = (current_files - initial_files).pop()
265+
266+
return new_file
267+
268+
# end method definition
269+
270+
def run_login(
271+
self,
272+
user_field: str = "otds_username",
273+
password_field: str = "otds_password",
274+
login_button: str = "loginbutton",
275+
) -> bool:
276+
"""Login to target system via the browser"""
277+
278+
self.logged_in = False
279+
280+
if (
281+
not self.get_page() # assuming the base URL leads towards the login page
282+
or not self.find_elem_and_set(
283+
find_elem=user_field, elem_value=self.user_name
284+
)
285+
or not self.find_elem_and_set(
286+
find_elem=password_field, elem_value=self.user_password
287+
)
288+
or not self.find_elem_and_click(find_elem=login_button)
289+
):
290+
logger.error("Cannot log into target system using URL -> %s", self.base_url)
291+
return False
292+
293+
logger.info("Page title after login -> %s", self.browser.title)
294+
if "Verify" in self.browser.title:
295+
logger.error(
296+
"Site is asking for a Verification Token. You may need to whitelist your IP!"
297+
)
298+
return False
299+
300+
self.logged_in = True
301+
302+
return True
303+
304+
# end method definition
305+
306+
def implict_wait(self, wait_time: float):
307+
"""Waits for the browser to finish tasks (e.g. fully loading a page)
308+
309+
Args:
310+
wait_time (float): time in seconds to wait
311+
"""
312+
313+
logger.info("Implicit wait for max -> %s seconds...", str(wait_time))
314+
self.browser.implicitly_wait(wait_time)
315+
316+
def end_session(self):
317+
"""End the browser session"""
318+
319+
self.browser.close()
320+
self.logged_in = False
321+
322+
# end method definition

0 commit comments

Comments
 (0)