pybenutils 7.9.3__tar.gz → 7.11.0__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.
- {pybenutils-7.9.3/pybenutils.egg-info → pybenutils-7.11.0}/PKG-INFO +1 -2
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/browsers/simple_browser_controller_cls.py +405 -6
- {pybenutils-7.9.3 → pybenutils-7.11.0/pybenutils.egg-info}/PKG-INFO +1 -2
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils.egg-info/requires.txt +0 -1
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pyproject.toml +1 -2
- {pybenutils-7.9.3 → pybenutils-7.11.0}/DEPLOYMENT.md +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/LICENSE +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/MANIFEST.in +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/README.md +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/deploy_requirements.txt +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/__init__.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/amazon_boto3/__init__.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/amazon_boto3/amazon_obj.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/autogui/__init__.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/autogui/__main__.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/autogui/auto_gui_cls.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/browsers/__init__.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/browsers/chrome.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/browsers/firefox.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/browsers/selenium_utils.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/browsers/web_driver.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/browsers/windows_browsers_keywords.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/cli_tools.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/network/__init__.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/network/download_manager.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/network/http_cert_info.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/network/s3_bucket_cls.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/network/ssh_helper.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/network/ssh_utils.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/os_operations/__init__.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/os_operations/files_and_directories.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/os_operations/mac_application_control.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/os_operations/mac_input_control.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/os_operations/mac_operations.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/os_operations/mac_popup_handler.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/os_operations/process.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/os_operations/window_operations.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/proxmox_utils.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/tests_files_utils.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/useful.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/utils_logger/__init__.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/utils_logger/config_logger.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils/windows_registry.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils.egg-info/SOURCES.txt +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils.egg-info/dependency_links.txt +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/pybenutils.egg-info/top_level.txt +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/setup.cfg +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/tests/test_self_import.py +0 -0
- {pybenutils-7.9.3 → pybenutils-7.11.0}/webserver/pyserver.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pybenutils
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.11.0
|
|
4
4
|
Summary: PyBEN Utilities repository contains a variety of useful methods and classes designed to allow easy access to high-level operations
|
|
5
5
|
Author: Ben Moskovitch
|
|
6
6
|
Author-email: Ben Moskovitch <darkflameben@gmail.com>
|
|
@@ -29,7 +29,6 @@ Requires-Dist: selenium>=4.39.0
|
|
|
29
29
|
Requires-Dist: multiprocess>=0.70.18
|
|
30
30
|
Requires-Dist: boto>=2.49.0
|
|
31
31
|
Requires-Dist: pysocks>=1.7.1
|
|
32
|
-
Requires-Dist: typing>=3.7.4.3
|
|
33
32
|
Requires-Dist: idna>=3.11
|
|
34
33
|
Requires-Dist: pyOpenSSL>=25.3.0
|
|
35
34
|
Requires-Dist: cryptography>=46.0.3
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
1
4
|
import sys
|
|
2
5
|
import time
|
|
3
6
|
from pathlib import Path
|
|
@@ -12,12 +15,151 @@ from pybenutils.browsers.windows_browsers_keywords import set_browser_url, close
|
|
|
12
15
|
if sys.platform == 'win32':
|
|
13
16
|
import win32com.client
|
|
14
17
|
import win32gui
|
|
15
|
-
|
|
18
|
+
elif sys.platform == 'darwin':
|
|
16
19
|
from pybenutils.os_operations.mac_operations import run_apple_script
|
|
17
20
|
|
|
18
21
|
logger = get_logger()
|
|
19
22
|
|
|
20
23
|
|
|
24
|
+
def _linux_run(cmd, check=False, capture=True, timeout=10):
|
|
25
|
+
"""Run shell command on Linux and return CompletedProcess.
|
|
26
|
+
|
|
27
|
+
:param cmd: list or str command
|
|
28
|
+
:param check: raise on non-zero exit
|
|
29
|
+
:param capture: capture stdout/stderr
|
|
30
|
+
:param timeout: seconds
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
return subprocess.run(
|
|
34
|
+
cmd,
|
|
35
|
+
shell=isinstance(cmd, str),
|
|
36
|
+
check=check,
|
|
37
|
+
capture_output=capture,
|
|
38
|
+
text=True,
|
|
39
|
+
timeout=timeout,
|
|
40
|
+
)
|
|
41
|
+
except Exception as ex:
|
|
42
|
+
logger.error(f'_linux_run failed: cmd={cmd} err={ex}')
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _xdotool(*args, timeout=10):
|
|
47
|
+
"""Invoke xdotool with the given args. Returns stdout str (empty on failure)."""
|
|
48
|
+
if not shutil.which('xdotool'):
|
|
49
|
+
logger.error('xdotool not installed. Run: sudo apt install xdotool')
|
|
50
|
+
return ''
|
|
51
|
+
res = _linux_run(['xdotool', *map(str, args)], timeout=timeout)
|
|
52
|
+
if res and res.returncode == 0:
|
|
53
|
+
return (res.stdout or '').strip()
|
|
54
|
+
if res and res.stderr:
|
|
55
|
+
logger.debug(f'xdotool stderr: {res.stderr.strip()}')
|
|
56
|
+
return ''
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _xdotool_activate(window_name):
|
|
60
|
+
"""Activate (focus + raise) a window by name match (case-insensitive)."""
|
|
61
|
+
wid = _xdotool('search', '--name', window_name)
|
|
62
|
+
if not wid:
|
|
63
|
+
return False
|
|
64
|
+
# search returns one wid per line; take last (newest)
|
|
65
|
+
wid = wid.splitlines()[-1].strip()
|
|
66
|
+
_xdotool('windowactivate', '--sync', wid)
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Map of Windows SendKeys named keys to xdotool keysyms
|
|
71
|
+
_WIN_SENDKEYS_NAMED = {
|
|
72
|
+
'F1': 'F1', 'F2': 'F2', 'F3': 'F3', 'F4': 'F4', 'F5': 'F5', 'F6': 'F6',
|
|
73
|
+
'F7': 'F7', 'F8': 'F8', 'F9': 'F9', 'F10': 'F10', 'F11': 'F11', 'F12': 'F12',
|
|
74
|
+
'ENTER': 'Return',
|
|
75
|
+
'TAB': 'Tab',
|
|
76
|
+
'ESC': 'Escape',
|
|
77
|
+
'BACKSPACE': 'BackSpace', 'BS': 'BackSpace', 'BKSP': 'BackSpace',
|
|
78
|
+
'DEL': 'Delete', 'DELETE': 'Delete',
|
|
79
|
+
'HOME': 'Home',
|
|
80
|
+
'END': 'End',
|
|
81
|
+
'PGUP': 'Prior',
|
|
82
|
+
'PGDN': 'Next',
|
|
83
|
+
'INS': 'Insert', 'INSERT': 'Insert',
|
|
84
|
+
'LEFT': 'Left', 'RIGHT': 'Right', 'UP': 'Up', 'DOWN': 'Down',
|
|
85
|
+
'SPACE': 'space',
|
|
86
|
+
'-': 'minus', '+': 'plus',
|
|
87
|
+
'{': 'braceleft', '}': 'braceright',
|
|
88
|
+
'[': 'bracketleft', ']': 'bracketright',
|
|
89
|
+
'(': 'parenleft', ')': 'parenright',
|
|
90
|
+
'^': 'asciicircum', '%': 'percent', '~': 'asciitilde',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _sendkeys_to_xdotool_chord(text):
|
|
95
|
+
"""Parse a Windows-style SendKeys string into a list of xdotool key chords.
|
|
96
|
+
|
|
97
|
+
Returns None if `text` looks like a literal string (no SendKeys metacharacters),
|
|
98
|
+
so the caller can fall back to `xdotool type`.
|
|
99
|
+
|
|
100
|
+
Supported subset (sufficient for pybenutils browser keywords):
|
|
101
|
+
{KEYNAME} -> single named key (e.g. {F5}, {ENTER}, {TAB})
|
|
102
|
+
^x -> Ctrl+x (single character)
|
|
103
|
+
+x -> Shift+x
|
|
104
|
+
%x -> Alt+x
|
|
105
|
+
^+{KEY} -> Ctrl+Shift+key
|
|
106
|
+
^{KEY} -> Ctrl+key
|
|
107
|
+
Sequences may be concatenated, e.g. "^+{K}", "^t", "{F5}"
|
|
108
|
+
"""
|
|
109
|
+
# Quick reject — if no SendKeys metachar present, treat as plain text.
|
|
110
|
+
if not any(c in text for c in ('{', '^', '+', '%', '~')):
|
|
111
|
+
return None
|
|
112
|
+
# Heuristic: if the entire string is wrapped braces or starts with a modifier
|
|
113
|
+
# / brace, try to parse. Otherwise it's likely text containing a stray char.
|
|
114
|
+
if not (text.startswith('{') or text.startswith('^') or text.startswith('+')
|
|
115
|
+
or text.startswith('%') or text.startswith('~')):
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
chords = []
|
|
119
|
+
i = 0
|
|
120
|
+
while i < len(text):
|
|
121
|
+
mods = []
|
|
122
|
+
# collect modifiers
|
|
123
|
+
while i < len(text) and text[i] in ('^', '+', '%'):
|
|
124
|
+
mods.append({'^': 'ctrl', '+': 'shift', '%': 'alt'}[text[i]])
|
|
125
|
+
i += 1
|
|
126
|
+
if i >= len(text):
|
|
127
|
+
break
|
|
128
|
+
ch = text[i]
|
|
129
|
+
if ch == '{':
|
|
130
|
+
# find closing brace
|
|
131
|
+
end = text.find('}', i)
|
|
132
|
+
if end == -1:
|
|
133
|
+
return None
|
|
134
|
+
keyname = text[i + 1:end].strip().upper()
|
|
135
|
+
keysym = _WIN_SENDKEYS_NAMED.get(keyname, keyname)
|
|
136
|
+
i = end + 1
|
|
137
|
+
else:
|
|
138
|
+
# single literal char following modifiers
|
|
139
|
+
keysym = ch
|
|
140
|
+
i += 1
|
|
141
|
+
chord = '+'.join(mods + [keysym]) if mods else keysym
|
|
142
|
+
chords.append(chord)
|
|
143
|
+
return chords if chords else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Mapping from generic browser name to Linux binary name (used to launch / pgrep).
|
|
147
|
+
LINUX_BROWSER_BINARY_TABLE = {
|
|
148
|
+
'chrome': 'google-chrome',
|
|
149
|
+
'chromium': 'chromium-browser',
|
|
150
|
+
'firefox': 'firefox',
|
|
151
|
+
'msedge': 'microsoft-edge',
|
|
152
|
+
}
|
|
153
|
+
# Mapping from generic browser name to the visible window title fragment used
|
|
154
|
+
# by xdotool to focus the window.
|
|
155
|
+
LINUX_BROWSER_WINDOW_NAME_TABLE = {
|
|
156
|
+
'chrome': 'Google Chrome',
|
|
157
|
+
'chromium': 'Chromium',
|
|
158
|
+
'firefox': 'Mozilla Firefox',
|
|
159
|
+
'msedge': 'Microsoft Edge',
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
21
163
|
def kill_all_browsers(browsers_list=('iexplore.exe', 'IEDriverServer.exe', 'chrome', 'firefox', 'chromedriver',
|
|
22
164
|
'safari', 'chromium', 'geckodriver', 'msedge'),
|
|
23
165
|
attempt_graceful_shutdown=True):
|
|
@@ -79,19 +221,29 @@ class SimpleBrowserController:
|
|
|
79
221
|
'msedge.exe': 'Chrome_WidgetWin_1'
|
|
80
222
|
}
|
|
81
223
|
self.browser_name = browser_name
|
|
82
|
-
|
|
224
|
+
# Remember the original key (e.g. 'chrome') for Linux lookups
|
|
225
|
+
self._browser_key = browser_name
|
|
226
|
+
if sys.platform == 'darwin' and browser_name in process_names_mac_conversion_table:
|
|
83
227
|
self.browser_name = process_names_mac_conversion_table[browser_name]
|
|
84
228
|
self.mac_browser_path = mac_application_path_conversion_table.get(browser_name, '')
|
|
85
229
|
|
|
230
|
+
# Linux-specific lookups
|
|
231
|
+
self.linux_binary = LINUX_BROWSER_BINARY_TABLE.get(browser_name, browser_name)
|
|
232
|
+
self.linux_window_name = LINUX_BROWSER_WINDOW_NAME_TABLE.get(browser_name, browser_name)
|
|
233
|
+
|
|
86
234
|
self.browser_process_name = self.browser_name
|
|
87
235
|
if self.browser_name in ['chrome', 'chrome.exe', 'chromium', 'chromium.exe']:
|
|
88
236
|
self.browser_process_name = 'chrome.exe'
|
|
89
237
|
elif sys.platform == 'win32' and not self.browser_process_name.endswith('.exe'):
|
|
90
238
|
self.browser_process_name = '{brw}.exe'.format(brw=self.browser_process_name)
|
|
239
|
+
# On Linux the process name is the binary name (no .exe), e.g. 'chrome' / 'firefox'.
|
|
240
|
+
if sys.platform == 'linux':
|
|
241
|
+
# Default to the short browser key so ProcessHandler matches "chrome" etc.
|
|
242
|
+
self.browser_process_name = self._browser_key
|
|
91
243
|
|
|
92
244
|
self.hwnd = []
|
|
93
245
|
self.app_obj = None
|
|
94
|
-
if sys.platform
|
|
246
|
+
if sys.platform == 'darwin':
|
|
95
247
|
from pybenutils.os_operations.mac_application_control import ApplicationControl
|
|
96
248
|
self.app_obj = ApplicationControl(self.mac_browser_path)
|
|
97
249
|
else:
|
|
@@ -110,6 +262,90 @@ class SimpleBrowserController:
|
|
|
110
262
|
return False
|
|
111
263
|
self.hwnd.append(new_handler)
|
|
112
264
|
return True
|
|
265
|
+
elif sys.platform == 'linux':
|
|
266
|
+
binary = self.linux_binary if shutil.which(self.linux_binary) else self._browser_key
|
|
267
|
+
if not shutil.which(binary):
|
|
268
|
+
logger.error(f'Browser binary not found in PATH: {binary}')
|
|
269
|
+
return False
|
|
270
|
+
# Build chrome-specific args:
|
|
271
|
+
# - --ozone-platform=x11 forces X11 backend (else chrome attaches to
|
|
272
|
+
# the user's Wayland session and renders on the real desktop :0)
|
|
273
|
+
# - --user-data-dir=<tmp> isolates from the user's existing chrome
|
|
274
|
+
# profile (chrome's singleton would otherwise forward our URL to
|
|
275
|
+
# the running chrome on :0 instead of opening a window on our :100)
|
|
276
|
+
# - --no-first-run / --no-default-browser-check suppress modal dialogs
|
|
277
|
+
chrome_force_x11 = []
|
|
278
|
+
if 'chrome' in binary or 'chromium' in binary or 'edge' in binary:
|
|
279
|
+
# Use a fixed persistent profile dir so site permissions
|
|
280
|
+
# (granted manually once) survive across test runs.
|
|
281
|
+
# If env var PYBENUTILS_CHROME_PROFILE is set, use that;
|
|
282
|
+
# otherwise default to ~/gpa_chrome_profile.
|
|
283
|
+
persistent_profile = os.environ.get(
|
|
284
|
+
'PYBENUTILS_CHROME_PROFILE',
|
|
285
|
+
os.path.expanduser('~/gpa_chrome_profile')
|
|
286
|
+
)
|
|
287
|
+
os.makedirs(persistent_profile, exist_ok=True)
|
|
288
|
+
chrome_force_x11 = [
|
|
289
|
+
'--ozone-platform=x11',
|
|
290
|
+
'--no-sandbox',
|
|
291
|
+
'--disable-gpu',
|
|
292
|
+
'--disable-dev-shm-usage',
|
|
293
|
+
f'--user-data-dir={persistent_profile}',
|
|
294
|
+
'--no-first-run',
|
|
295
|
+
'--no-default-browser-check',
|
|
296
|
+
'--new-window',
|
|
297
|
+
'--window-size=1280,1024',
|
|
298
|
+
# Auto-launch eaaclient:// protocol from the Akamai IDP origin
|
|
299
|
+
# without prompting. Matches the Chrome enterprise policy
|
|
300
|
+
# AutoLaunchProtocolsFromOrigins but as a cmdline override.
|
|
301
|
+
'--auto-launch-protocols-from-origins='
|
|
302
|
+
'eaaclient,https://etp-client.login.go.akamai-access.com',
|
|
303
|
+
# Bypass the "request blocked" page by treating the IDP origin
|
|
304
|
+
# as a secure origin and disabling popup blocking for it.
|
|
305
|
+
'--disable-popup-blocking',
|
|
306
|
+
# Suppress "Chrome didn't shut down correctly" Restore popup
|
|
307
|
+
# that blocks the IDP page after profile clone.
|
|
308
|
+
'--disable-session-crashed-bubble',
|
|
309
|
+
'--disable-features=InfiniteSessionRestore',
|
|
310
|
+
'--hide-crash-restore-bubble',
|
|
311
|
+
# NOTE: --incognito tried earlier but broke first-auth
|
|
312
|
+
# because incognito doesn't load the profile permissions
|
|
313
|
+
# (Allow popup gets re-asked). The cloned profile's
|
|
314
|
+
# cookies/permissions are required.
|
|
315
|
+
]
|
|
316
|
+
cmd = [binary, *chrome_force_x11, *map(str, arguments)] if arguments else \
|
|
317
|
+
[binary, *chrome_force_x11]
|
|
318
|
+
# Strip Wayland env so chrome falls back to X11 (DISPLAY only).
|
|
319
|
+
child_env = os.environ.copy()
|
|
320
|
+
for var in ('WAYLAND_DISPLAY', 'XDG_SESSION_TYPE', 'GDK_BACKEND',
|
|
321
|
+
'QT_QPA_PLATFORM', 'CLUTTER_BACKEND', 'MOZ_ENABLE_WAYLAND'):
|
|
322
|
+
child_env.pop(var, None)
|
|
323
|
+
# Ensure DISPLAY is set; xvfb-run exports it.
|
|
324
|
+
if 'DISPLAY' not in child_env:
|
|
325
|
+
logger.warning('DISPLAY env not set; xdotool focus will fail. Run under xvfb-run.')
|
|
326
|
+
logger.info(f'Linux launch: {" ".join(cmd)} (DISPLAY={child_env.get("DISPLAY","<unset>")})')
|
|
327
|
+
try:
|
|
328
|
+
# Detach so the test can continue while browser runs
|
|
329
|
+
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
330
|
+
stdin=subprocess.DEVNULL, start_new_session=True,
|
|
331
|
+
env=child_env)
|
|
332
|
+
except Exception as ex:
|
|
333
|
+
logger.error(f'Failed to launch {binary}: {ex}')
|
|
334
|
+
return False
|
|
335
|
+
# Give chrome time to actually map its window in xvfb
|
|
336
|
+
for attempt in range(20):
|
|
337
|
+
time.sleep(1)
|
|
338
|
+
wid = _xdotool('search', '--name', self.linux_window_name)
|
|
339
|
+
if wid:
|
|
340
|
+
logger.info(f'Linux chrome window appeared on DISPLAY '
|
|
341
|
+
f'{child_env.get("DISPLAY","?")} after {attempt+1}s '
|
|
342
|
+
f'(wid={wid.splitlines()[-1]})')
|
|
343
|
+
return True
|
|
344
|
+
# Last-chance diagnostic — list all windows so we know what xdotool can see
|
|
345
|
+
all_wins = _xdotool('search', '--onlyvisible', '--name', '.+')
|
|
346
|
+
logger.warning(f'Linux chrome window NOT found after 20s. '
|
|
347
|
+
f'xdotool sees these visible windows: {all_wins!r}')
|
|
348
|
+
return False
|
|
113
349
|
else:
|
|
114
350
|
return self.app_obj.launch(arguments=arguments)
|
|
115
351
|
|
|
@@ -130,6 +366,12 @@ class SimpleBrowserController:
|
|
|
130
366
|
time.sleep(20)
|
|
131
367
|
self.kill(attempt_graceful_shutdown=attempt_graceful_shutdown)
|
|
132
368
|
self.hwnd = []
|
|
369
|
+
elif sys.platform == 'linux':
|
|
370
|
+
# pkill matches by process name. -f matches full command line which is
|
|
371
|
+
# safer for snap chrome wrappers etc.
|
|
372
|
+
_linux_run(['pkill', '-f', self.linux_binary], timeout=5)
|
|
373
|
+
time.sleep(1)
|
|
374
|
+
_linux_run(['pkill', '-9', '-f', self.linux_binary], timeout=5)
|
|
133
375
|
else:
|
|
134
376
|
self.app_obj.kill()
|
|
135
377
|
time.sleep(10)
|
|
@@ -152,6 +394,11 @@ class SimpleBrowserController:
|
|
|
152
394
|
return True
|
|
153
395
|
logger.warning('{n} could not be gracefully closed'.format(n=self.browser_name))
|
|
154
396
|
return False
|
|
397
|
+
elif sys.platform == 'linux':
|
|
398
|
+
# Try graceful SIGTERM first; pkill returns 0 if any process matched.
|
|
399
|
+
_linux_run(['pkill', '-TERM', '-f', self.linux_binary], timeout=5)
|
|
400
|
+
time.sleep(3)
|
|
401
|
+
return True
|
|
155
402
|
else:
|
|
156
403
|
self.app_obj.close()
|
|
157
404
|
time.sleep(3)
|
|
@@ -165,6 +412,47 @@ class SimpleBrowserController:
|
|
|
165
412
|
"""Returns the hwnd list parameter"""
|
|
166
413
|
return self.hwnd
|
|
167
414
|
|
|
415
|
+
def _linux_focus_browser(self) -> bool:
|
|
416
|
+
"""Bring the browser window to focus on Linux using xdotool. Returns True if found."""
|
|
417
|
+
found = _xdotool_activate(self.linux_window_name)
|
|
418
|
+
if not found:
|
|
419
|
+
logger.warning(f'_linux_focus_browser: no window matching "{self.linux_window_name}". '
|
|
420
|
+
f'xdotool search returned empty. Browser may not be visible to X server.')
|
|
421
|
+
return found
|
|
422
|
+
|
|
423
|
+
def press_key_on_linux_browser(self, keysym) -> bool:
|
|
424
|
+
"""Send an xdotool 'key' chord (e.g. 'ctrl+l', 'Return', 'F6') to the focused browser window.
|
|
425
|
+
|
|
426
|
+
:param keysym: xdotool keysym or chord
|
|
427
|
+
:return: True on success
|
|
428
|
+
"""
|
|
429
|
+
try:
|
|
430
|
+
self._linux_focus_browser()
|
|
431
|
+
logger.debug(f'xdotool key: {keysym}')
|
|
432
|
+
_xdotool('key', '--clearmodifiers', keysym)
|
|
433
|
+
return shutil.which('xdotool') is not None
|
|
434
|
+
except Exception as ex:
|
|
435
|
+
logger.error(f'press_key_on_linux_browser failed key={keysym} err={ex}')
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
def type_text_on_linux_browser(self, text, delay_ms=25) -> bool:
|
|
439
|
+
"""Type literal text into the focused browser window using xdotool type.
|
|
440
|
+
|
|
441
|
+
:param text: text to type
|
|
442
|
+
:param delay_ms: per-char delay
|
|
443
|
+
:return: True on success
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
self._linux_focus_browser()
|
|
447
|
+
# Redact obvious secrets from logs (passwords are commonly typed via this path)
|
|
448
|
+
preview = text if len(text) <= 60 else f'{text[:60]}...({len(text)} chars)'
|
|
449
|
+
logger.debug(f'xdotool type: "{preview}"')
|
|
450
|
+
_xdotool('type', '--clearmodifiers', '--delay', str(delay_ms), text)
|
|
451
|
+
return shutil.which('xdotool') is not None
|
|
452
|
+
except Exception as ex:
|
|
453
|
+
logger.error(f'type_text_on_linux_browser failed: {ex}')
|
|
454
|
+
return False
|
|
455
|
+
|
|
168
456
|
def press_key_combination_on_mac_browser(self, key_command) -> bool:
|
|
169
457
|
"""Attempts to press the requested command on the keyboard using Applescript
|
|
170
458
|
|
|
@@ -174,6 +462,38 @@ class SimpleBrowserController:
|
|
|
174
462
|
if sys.platform == 'win32':
|
|
175
463
|
# logger.warning('You are trying to use a mac function on a windows os')
|
|
176
464
|
return False
|
|
465
|
+
elif sys.platform == 'linux':
|
|
466
|
+
# Translate common Mac key codes to xdotool keysyms. Best-effort —
|
|
467
|
+
# anything unknown logs and returns False so callers don't crash.
|
|
468
|
+
mapping = {
|
|
469
|
+
'key code 96': 'F5', # reload
|
|
470
|
+
'key code 97 using option down': 'F6', # focus url bar
|
|
471
|
+
'key code 76': 'Return',
|
|
472
|
+
'key code 53 using option down': 'Escape',
|
|
473
|
+
'key code 48': 'Tab',
|
|
474
|
+
'key code 48 using control down': 'ctrl+Tab',
|
|
475
|
+
'key code 17 using command down': 'ctrl+t', # new tab
|
|
476
|
+
'key code 82 using command down': 'ctrl+0', # zoom reset
|
|
477
|
+
'key code 78 using command down': 'ctrl+minus',
|
|
478
|
+
'key code 69 using command down': 'ctrl+plus',
|
|
479
|
+
'key code 121': 'Next',
|
|
480
|
+
'key code 116': 'Prior',
|
|
481
|
+
'key code 115 using command down': 'ctrl+Home',
|
|
482
|
+
'key code 119 using command down': 'ctrl+End',
|
|
483
|
+
'key code 0 using command down': 'ctrl+a',
|
|
484
|
+
'key code 37 using command down': 'ctrl+l',
|
|
485
|
+
'keystroke "c" using command down': 'ctrl+c',
|
|
486
|
+
'key code 4 using {command down, shift down}': 'ctrl+shift+h',
|
|
487
|
+
'key code 115 using option down': 'alt+Home',
|
|
488
|
+
}
|
|
489
|
+
key = mapping.get(key_command)
|
|
490
|
+
if key:
|
|
491
|
+
return self.press_key_on_linux_browser(key)
|
|
492
|
+
# Handle keystroke "literal text" pattern
|
|
493
|
+
if key_command.startswith('keystroke "') and key_command.endswith('"'):
|
|
494
|
+
return self.type_text_on_linux_browser(key_command[11:-1])
|
|
495
|
+
logger.debug(f'press_key_combination_on_mac_browser: no Linux mapping for "{key_command}"')
|
|
496
|
+
return False
|
|
177
497
|
else:
|
|
178
498
|
cmd = f"""
|
|
179
499
|
set chExist to false
|
|
@@ -217,6 +537,9 @@ class SimpleBrowserController:
|
|
|
217
537
|
errors.append(str(ex))
|
|
218
538
|
else:
|
|
219
539
|
logger.error('Failed to click on HOME button for errors: {err}'.format(err=errors))
|
|
540
|
+
elif sys.platform == 'linux':
|
|
541
|
+
# Most browsers on Linux use Alt+Home for home page
|
|
542
|
+
return self.press_key_on_linux_browser('alt+Home')
|
|
220
543
|
else:
|
|
221
544
|
if 'firefox' in self.browser_name.lower():
|
|
222
545
|
return self.press_key_combination_on_mac_browser('key code 115 using option down')
|
|
@@ -236,6 +559,8 @@ class SimpleBrowserController:
|
|
|
236
559
|
if sys.platform == 'win32':
|
|
237
560
|
shell = win32com.client.Dispatch("WScript.Shell")
|
|
238
561
|
shell.SendKeys("{ESC}", 1)
|
|
562
|
+
elif sys.platform == 'linux':
|
|
563
|
+
return self.press_key_on_linux_browser('Escape')
|
|
239
564
|
else:
|
|
240
565
|
return self.press_key_combination_on_mac_browser('key code 53 using option down')
|
|
241
566
|
except Exception as ex:
|
|
@@ -252,6 +577,8 @@ class SimpleBrowserController:
|
|
|
252
577
|
if sys.platform == 'win32':
|
|
253
578
|
shell = win32com.client.Dispatch("WScript.Shell")
|
|
254
579
|
shell.SendKeys("{ENTER}", 1)
|
|
580
|
+
elif sys.platform == 'linux':
|
|
581
|
+
return self.press_key_on_linux_browser('Return')
|
|
255
582
|
else:
|
|
256
583
|
return self.press_key_combination_on_mac_browser('key code 76')
|
|
257
584
|
except Exception as ex:
|
|
@@ -268,6 +595,19 @@ class SimpleBrowserController:
|
|
|
268
595
|
if sys.platform == 'win32':
|
|
269
596
|
shell = win32com.client.Dispatch("WScript.Shell")
|
|
270
597
|
shell.SendKeys(text, 1)
|
|
598
|
+
elif sys.platform == 'linux':
|
|
599
|
+
# The Mac/Win callers send strings in two distinct shapes:
|
|
600
|
+
# 1. Literal text to type: "etp.automation@gmail.com"
|
|
601
|
+
# 2. Windows SendKeys notation: "{F5}", "^a", "^+{K}", "^t" etc.
|
|
602
|
+
# xdotool's `type` is literal-only; `key` takes a chord. Translate
|
|
603
|
+
# SendKeys-style sequences to xdotool key chords. Plain text falls
|
|
604
|
+
# through to xdotool type.
|
|
605
|
+
converted = _sendkeys_to_xdotool_chord(text)
|
|
606
|
+
if converted is not None:
|
|
607
|
+
for chord in converted:
|
|
608
|
+
self.press_key_on_linux_browser(chord)
|
|
609
|
+
else:
|
|
610
|
+
self.type_text_on_linux_browser(text)
|
|
271
611
|
else:
|
|
272
612
|
self.press_key_combination_on_mac_browser('keystroke "{text}"'.format(text=text))
|
|
273
613
|
except Exception as ex:
|
|
@@ -285,6 +625,9 @@ class SimpleBrowserController:
|
|
|
285
625
|
if sys.platform == 'win32':
|
|
286
626
|
shell = win32com.client.Dispatch("WScript.Shell")
|
|
287
627
|
shell.SendKeys("{F6}", 1)
|
|
628
|
+
elif sys.platform == 'linux':
|
|
629
|
+
# Ctrl+L focuses the address bar in Chrome/Firefox on Linux (F6 also works)
|
|
630
|
+
self.press_key_on_linux_browser('ctrl+l')
|
|
288
631
|
else:
|
|
289
632
|
self.press_key_combination_on_mac_browser('key code 97 using option down')
|
|
290
633
|
except Exception as ex:
|
|
@@ -295,6 +638,8 @@ class SimpleBrowserController:
|
|
|
295
638
|
if sys.platform == 'win32':
|
|
296
639
|
shell = win32com.client.Dispatch("WScript.Shell")
|
|
297
640
|
shell.SendKeys("^c", 1)
|
|
641
|
+
elif sys.platform == 'linux':
|
|
642
|
+
self.press_key_on_linux_browser('ctrl+c')
|
|
298
643
|
else:
|
|
299
644
|
self.press_key_combination_on_mac_browser('keystroke "c" using command down')
|
|
300
645
|
except Exception as ex:
|
|
@@ -327,6 +672,12 @@ class SimpleBrowserController:
|
|
|
327
672
|
else:
|
|
328
673
|
logger.error('Failed to set browser url for errors: {err}'.format(err=errors))
|
|
329
674
|
|
|
675
|
+
elif sys.platform == 'linux':
|
|
676
|
+
self.press_key_on_linux_browser('ctrl+l')
|
|
677
|
+
time.sleep(0.3)
|
|
678
|
+
self.type_text_on_linux_browser(url)
|
|
679
|
+
time.sleep(0.3)
|
|
680
|
+
self.press_enter_button()
|
|
330
681
|
else:
|
|
331
682
|
self.press_key_combination_on_mac_browser('key code 37 using command down')
|
|
332
683
|
self.send_keyboard_keys(url)
|
|
@@ -345,6 +696,8 @@ class SimpleBrowserController:
|
|
|
345
696
|
if sys.platform == 'win32':
|
|
346
697
|
shell = win32com.client.Dispatch("WScript.Shell")
|
|
347
698
|
shell.SendKeys("^{TAB}", 1)
|
|
699
|
+
elif sys.platform == 'linux':
|
|
700
|
+
return self.press_key_on_linux_browser('ctrl+Tab')
|
|
348
701
|
else:
|
|
349
702
|
return self.press_key_combination_on_mac_browser('key code 48 using control down')
|
|
350
703
|
except Exception as ex:
|
|
@@ -370,6 +723,9 @@ class SimpleBrowserController:
|
|
|
370
723
|
except Exception as ex:
|
|
371
724
|
print(ex)
|
|
372
725
|
return False
|
|
726
|
+
elif sys.platform == 'linux':
|
|
727
|
+
# xdotool windowactivate is the closest analogue: brings window to front + focus.
|
|
728
|
+
return self._linux_focus_browser()
|
|
373
729
|
else:
|
|
374
730
|
print('Not yet implemented')
|
|
375
731
|
return False
|
|
@@ -401,6 +757,18 @@ class SimpleBrowserController:
|
|
|
401
757
|
self.send_keyboard_keys('{F12}')
|
|
402
758
|
else:
|
|
403
759
|
raise AssertionError(f'The requested operation is not yet implemented for {self.browser_name}')
|
|
760
|
+
elif sys.platform == 'linux':
|
|
761
|
+
# Linux: open DevTools console, type command, Enter, close DevTools
|
|
762
|
+
if 'firefox' in self.browser_name.lower():
|
|
763
|
+
self.press_key_on_linux_browser('ctrl+shift+k')
|
|
764
|
+
else:
|
|
765
|
+
self.press_key_on_linux_browser('ctrl+shift+j')
|
|
766
|
+
time.sleep(1)
|
|
767
|
+
self.type_text_on_linux_browser(command)
|
|
768
|
+
time.sleep(0.5)
|
|
769
|
+
self.press_enter_button()
|
|
770
|
+
time.sleep(0.5)
|
|
771
|
+
self.press_key_on_linux_browser('F12')
|
|
404
772
|
else:
|
|
405
773
|
raise AssertionError(f'The requested operation is only implemented for windows')
|
|
406
774
|
|
|
@@ -409,14 +777,27 @@ class SimpleBrowserController:
|
|
|
409
777
|
if sys.platform == 'win32':
|
|
410
778
|
self.refresh_hwnd_list()
|
|
411
779
|
return bool(self.get_hwnd())
|
|
780
|
+
elif sys.platform == 'linux':
|
|
781
|
+
# Check via xdotool (window present in the current DISPLAY's X server),
|
|
782
|
+
# not pgrep — pgrep would match chrome on a different DISPLAY (e.g. the
|
|
783
|
+
# user's :0 session while we run under xvfb on :100), giving a false
|
|
784
|
+
# positive that makes the caller skip launching our own chrome.
|
|
785
|
+
if not shutil.which('xdotool'):
|
|
786
|
+
# Fallback to pgrep if xdotool missing
|
|
787
|
+
res = _linux_run(['pgrep', '-f', self.linux_binary], timeout=5)
|
|
788
|
+
return bool(res and res.returncode == 0 and (res.stdout or '').strip())
|
|
789
|
+
return bool(_xdotool('search', '--name', self.linux_window_name))
|
|
412
790
|
else:
|
|
413
791
|
return self.app_obj.is_running()
|
|
414
792
|
|
|
415
793
|
def press_tab_button(self):
|
|
416
794
|
"""Using the keyboard, press the TAB button"""
|
|
417
|
-
|
|
418
|
-
self.
|
|
419
|
-
|
|
795
|
+
if sys.platform == 'win32':
|
|
796
|
+
return self.send_keyboard_keys("{TAB}")
|
|
797
|
+
elif sys.platform == 'linux':
|
|
798
|
+
return self.press_key_on_linux_browser('Tab')
|
|
799
|
+
else:
|
|
800
|
+
return self.press_key_combination_on_mac_browser('key code 48')
|
|
420
801
|
|
|
421
802
|
def approve_chrome_changed_assets_alert(self, tab_presses=4):
|
|
422
803
|
"""Using the keyboard to click on "keep changes" at chrome's "accept asset change" popup
|
|
@@ -435,6 +816,8 @@ class SimpleBrowserController:
|
|
|
435
816
|
logger.debug('Attempting to open a new tab')
|
|
436
817
|
if sys.platform == 'win32':
|
|
437
818
|
self.send_keyboard_keys('^t')
|
|
819
|
+
elif sys.platform == 'linux':
|
|
820
|
+
self.press_key_on_linux_browser('ctrl+t')
|
|
438
821
|
else:
|
|
439
822
|
self.press_key_combination_on_mac_browser('key code 17 using command down')
|
|
440
823
|
|
|
@@ -442,6 +825,8 @@ class SimpleBrowserController:
|
|
|
442
825
|
"""Press the combination ctrl/command + '0' keyboard keys - reset zoom level to 100%"""
|
|
443
826
|
if sys.platform == 'win32':
|
|
444
827
|
self.send_keyboard_keys('^0')
|
|
828
|
+
elif sys.platform == 'linux':
|
|
829
|
+
self.press_key_on_linux_browser('ctrl+0')
|
|
445
830
|
else:
|
|
446
831
|
self.press_key_combination_on_mac_browser('key code 82 using command down')
|
|
447
832
|
|
|
@@ -449,6 +834,8 @@ class SimpleBrowserController:
|
|
|
449
834
|
"""Press the combination ctrl/command + '-' keyboard keys - zoom out"""
|
|
450
835
|
if sys.platform == 'win32':
|
|
451
836
|
self.send_keyboard_keys('^{-}')
|
|
837
|
+
elif sys.platform == 'linux':
|
|
838
|
+
self.press_key_on_linux_browser('ctrl+minus')
|
|
452
839
|
else:
|
|
453
840
|
self.press_key_combination_on_mac_browser('key code 78 using command down')
|
|
454
841
|
|
|
@@ -456,6 +843,8 @@ class SimpleBrowserController:
|
|
|
456
843
|
"""Press the combination ctrl/command + '+' keyboard keys - zoom in"""
|
|
457
844
|
if sys.platform == 'win32':
|
|
458
845
|
self.send_keyboard_keys('^{+}')
|
|
846
|
+
elif sys.platform == 'linux':
|
|
847
|
+
self.press_key_on_linux_browser('ctrl+plus')
|
|
459
848
|
else:
|
|
460
849
|
self.press_key_combination_on_mac_browser('key code 69 using command down')
|
|
461
850
|
|
|
@@ -475,6 +864,8 @@ class SimpleBrowserController:
|
|
|
475
864
|
"""Press the page down key"""
|
|
476
865
|
if sys.platform == 'win32':
|
|
477
866
|
self.send_keyboard_keys('{PGDN}')
|
|
867
|
+
elif sys.platform == 'linux':
|
|
868
|
+
self.press_key_on_linux_browser('Next')
|
|
478
869
|
else:
|
|
479
870
|
self.press_key_combination_on_mac_browser('key code 121')
|
|
480
871
|
|
|
@@ -482,6 +873,8 @@ class SimpleBrowserController:
|
|
|
482
873
|
"""Press the page up key"""
|
|
483
874
|
if sys.platform == 'win32':
|
|
484
875
|
self.send_keyboard_keys('{PGUP}')
|
|
876
|
+
elif sys.platform == 'linux':
|
|
877
|
+
self.press_key_on_linux_browser('Prior')
|
|
485
878
|
else:
|
|
486
879
|
self.press_key_combination_on_mac_browser('key code 116')
|
|
487
880
|
|
|
@@ -489,6 +882,8 @@ class SimpleBrowserController:
|
|
|
489
882
|
"""Press the combination ctrl/command + home keyboard keys - Scroll to top of the page"""
|
|
490
883
|
if sys.platform == 'win32':
|
|
491
884
|
self.send_keyboard_keys('^{HOME}')
|
|
885
|
+
elif sys.platform == 'linux':
|
|
886
|
+
self.press_key_on_linux_browser('ctrl+Home')
|
|
492
887
|
else:
|
|
493
888
|
self.press_key_combination_on_mac_browser('key code 115 using command down')
|
|
494
889
|
|
|
@@ -496,6 +891,8 @@ class SimpleBrowserController:
|
|
|
496
891
|
"""Press the combination ctrl/command + end keyboard keys - Scroll to end of the page"""
|
|
497
892
|
if sys.platform == 'win32':
|
|
498
893
|
self.send_keyboard_keys('^{END}')
|
|
894
|
+
elif sys.platform == 'linux':
|
|
895
|
+
self.press_key_on_linux_browser('ctrl+End')
|
|
499
896
|
else:
|
|
500
897
|
self.press_key_combination_on_mac_browser('key code 119 using command down')
|
|
501
898
|
|
|
@@ -505,5 +902,7 @@ class SimpleBrowserController:
|
|
|
505
902
|
self.send_keyboard_keys('^a')
|
|
506
903
|
elif sys.platform == 'darwin':
|
|
507
904
|
self.press_key_combination_on_mac_browser('key code 0 using command down')
|
|
905
|
+
elif sys.platform == 'linux':
|
|
906
|
+
self.press_key_on_linux_browser('ctrl+a')
|
|
508
907
|
else:
|
|
509
908
|
self.send_keyboard_keys('^a')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pybenutils
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.11.0
|
|
4
4
|
Summary: PyBEN Utilities repository contains a variety of useful methods and classes designed to allow easy access to high-level operations
|
|
5
5
|
Author: Ben Moskovitch
|
|
6
6
|
Author-email: Ben Moskovitch <darkflameben@gmail.com>
|
|
@@ -29,7 +29,6 @@ Requires-Dist: selenium>=4.39.0
|
|
|
29
29
|
Requires-Dist: multiprocess>=0.70.18
|
|
30
30
|
Requires-Dist: boto>=2.49.0
|
|
31
31
|
Requires-Dist: pysocks>=1.7.1
|
|
32
|
-
Requires-Dist: typing>=3.7.4.3
|
|
33
32
|
Requires-Dist: idna>=3.11
|
|
34
33
|
Requires-Dist: pyOpenSSL>=25.3.0
|
|
35
34
|
Requires-Dist: cryptography>=46.0.3
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pybenutils"
|
|
7
|
-
version = "7.
|
|
7
|
+
version = "7.11.0"
|
|
8
8
|
description = "PyBEN Utilities repository contains a variety of useful methods and classes designed to allow easy access to high-level operations"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -46,7 +46,6 @@ dependencies = [
|
|
|
46
46
|
"multiprocess>=0.70.18",
|
|
47
47
|
"boto>=2.49.0",
|
|
48
48
|
"pysocks>=1.7.1",
|
|
49
|
-
"typing>=3.7.4.3",
|
|
50
49
|
"idna>=3.11",
|
|
51
50
|
"pyOpenSSL>=25.3.0",
|
|
52
51
|
"cryptography>=46.0.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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|